├── .github └── workflows │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Readme.md ├── cache.go ├── cache_test.go ├── examples ├── grafana_dashboard.json ├── kubernetes │ ├── deployment.yaml │ └── service.yaml └── prometheus │ ├── kubernetes_service_discovery.yaml │ └── static_targets.yaml ├── go.mod ├── go.sum ├── internal ├── build │ └── build.go ├── exporter │ ├── exporter.go │ ├── exporter_test.go │ ├── grades.go │ └── grades_test.go └── ssllabs │ ├── analyze.go │ ├── info.go │ ├── info_test.go │ └── ssllabs.go ├── ssllabs_exporter.go └── ssllabs_exporter_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Install Go 9 | uses: actions/setup-go@v1 10 | with: 11 | go-version: 1.20 12 | - name: Checkout code 13 | uses: actions/checkout@v1 14 | - id: go-cache-paths 15 | run: | 16 | echo "go-build=$(go env GOCACHE)" >> $GITHUB_OUTPUT 17 | echo "go-mod=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT 18 | # Cache go build cache, used to speedup go test 19 | - name: Go Build Cache 20 | uses: actions/cache@v3 21 | with: 22 | path: ${{ steps.go-cache-paths.outputs.go-build }} 23 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 24 | # Cache go mod cache, used to speedup builds 25 | - name: Go Mod Cache 26 | uses: actions/cache@v3 27 | with: 28 | path: ${{ steps.go-cache-paths.outputs.go-mod }} 29 | key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} 30 | - name: Download dependencies 31 | run: go mod download 32 | - name: Test 33 | run: go test -race -covermode=atomic ./... 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore all 2 | * 3 | 4 | # Unignore all with extensions 5 | !*.* 6 | 7 | # Unignore all dirs 8 | !*/ 9 | 10 | # Unignore Dockerfile 11 | !Dockerfile 12 | 13 | !LICENSE 14 | 15 | # OS X garbage 16 | .DS_Store -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine3.21 AS builder 2 | 3 | RUN apk update && \ 4 | apk upgrade && \ 5 | apk add --no-cache git 6 | 7 | WORKDIR /workdir 8 | 9 | # Download the dependecies first for faster iterations 10 | COPY go.mod go.sum /workdir/ 11 | RUN go mod download 12 | 13 | COPY . /workdir/ 14 | 15 | # Generate build parameters 16 | RUN git describe --exact-match --tags HEAD > version || true 17 | RUN git rev-parse HEAD > revision 18 | RUN git rev-parse --abbrev-ref HEAD > branch 19 | 20 | RUN export VERSION=$(cat version) && \ 21 | export BRANCH=$(cat branch) && \ 22 | export REVISION=$(cat revision) && \ 23 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo \ 24 | -ldflags="-w -s -X github.com/anas-aso/ssllabs_exporter/internal/build.Branch=${BRANCH} -X github.com/anas-aso/ssllabs_exporter/internal/build.Revision=${REVISION} -X github.com/anas-aso/ssllabs_exporter/internal/build.Version=${VERSION}" \ 25 | -o /workdir/ssllabs_exporter 26 | 27 | # Create a "nobody" user for the next image 28 | RUN echo "nobody:x:65534:65534:Nobody:/:" > /etc_passwd 29 | 30 | 31 | 32 | FROM scratch 33 | 34 | COPY --from=builder /workdir/ssllabs_exporter /bin/ssllabs_exporter 35 | # Required for HTTPS requests done by the application 36 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 37 | # Required to be able to run as a non-root user (nobody) 38 | COPY --from=builder /etc_passwd /etc/passwd 39 | 40 | USER nobody 41 | 42 | ENTRYPOINT ["/bin/ssllabs_exporter"] 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Anas Ait Said Oubrahim 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # SSLLabs exporter 2 | [![Release](https://img.shields.io/github/release/anas-aso/ssllabs_exporter.svg?style=flat)](https://github.com/anas-aso/ssllabs_exporter/releases/latest) 3 | [![Build Status](https://github.com/anas-aso/ssllabs_exporter/workflows/test/badge.svg)](https://github.com/anas-aso/ssllabs_exporter/actions) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/anas-aso/ssllabs_exporter)](https://goreportcard.com/report/github.com/anas-aso/ssllabs_exporter) 5 | 6 | Getting deep analysis of the configuration of any SSL web server on the public Internet à la blackbox_exporter style. 7 | 8 | This exporter relays the target server hostname to [SSLLabs API](https://www.ssllabs.com/ssltest), parses the result and export it as Prometheus metrics. It covers retries in case of failures and simplifies the assessment result. 9 | 10 | ## SSLLabs 11 | > SSL Labs is a non-commercial research effort, run by [Qualys](https://www.qualys.com/), to better understand how SSL, TLS, and PKI technologies are used in practice. 12 | 13 | source: https://www.ssllabs.com/about/assessment.html 14 | 15 | This exporter implements SSLLabs API client that would get you the same results as if you use the [web interface](https://www.ssllabs.com/ssltest/). 16 | 17 | ## Configuration 18 | ssllabs_exporter doesn't require any configuration file and the available flags can be found as below : 19 | ```bash 20 | $ ssllabs_exporter --help 21 | usage: ssllabs_exporter [] 22 | 23 | Flags: 24 | --help Show context-sensitive help (also try --help-long and --help-man). 25 | --listen-address=":19115" The address to listen on for HTTP requests. 26 | --timeout="10m" Time duration before canceling an ongoing probe such as 30m or 1h5m. This value must be at least 1m. Valid duration units are ns, us (or µs), ms, s, m, h. 27 | --log-level=debug Printed logs level. 28 | --cache-retention="1h" Time duration to keep entries in cache such as 30m or 1h5m. Valid duration units are ns, us (or µs), ms, s, m, h. 29 | --cache-ignore-failed Do not cache failed results due to intermittent SSLLabs issues. 30 | --version Show application version. 31 | ``` 32 | 33 | ## Docker 34 | The Prometheus exporter is available as a [docker image](https://hub.docker.com/repository/docker/anasaso/ssllabs_exporter) : 35 | ``` 36 | docker run --rm -it anasaso/ssllabs_exporter:latest --help 37 | ``` 38 | 39 | ## How To Use it 40 | Deploy the exporter to your infrastructure. Kubernetes deployment and service Yaml file are provided [here](examples/kubernetes) as an example. 41 | 42 | Then adjust Prometheus config to add a new scrape configuration. Examples of how this look like can be found [here](examples/prometheus) (it includes both static config and Kubernetes service discovery to auto check all the cluster ingresses). 43 | 44 | Once deployed, Prometheus Targets view page should look like this : 45 | ![prometheus-targets-view](https://i.imgur.com/fJCun72.png "Prometheus Targets View") 46 | 47 | The Grafana dashboard below is available [here](examples/grafana_dashboard.json). 48 | ![grafana-dashboard](https://i.imgur.com/T00RtYk.png "Grafana Dashboard") 49 | 50 | ## Available metrics 51 | | Metric Name | Description | 52 | |----|-----------| 53 | | ssllabs_probe_duration_seconds | how long the assessment took in seconds | 54 | | ssllabs_probe_success | whether we were able to fetch an assessment result from SSLLabs API (value of 1) or not (value of 0) regardless of the result content | 55 | | ssllabs_grade | the grade of the target host | 56 | | ssllabs_grade_time_seconds | when the result was generated in Unix time | 57 | 58 | #### `ssllabs_grade` possible values: 59 | - `1` : Assessment was successful and the grade is exposed in the `grade` label of the metric. 60 | - `0` : Target host doesn't have any endpoint (list of returned [endpoints](https://github.com/ssllabs/ssllabs-scan/blob/master/ssllabs-api-docs-v3.md#host) is empty). 61 | - `-1` : Error while processing the assessment (e.g rate limiting from SSLLabs API side). 62 | 63 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "container/list" 19 | "sync" 20 | "time" 21 | 22 | "github.com/prometheus/client_golang/prometheus" 23 | ) 24 | 25 | // cacheEntry contains cache elements meta data 26 | type cacheEntry struct { 27 | // the target host is used as a unique cache entry identifier 28 | id string 29 | 30 | // expiry time for the cache entry (calculated on creation time) 31 | expiryTime int64 32 | } 33 | 34 | type cache struct { 35 | mu sync.Mutex 36 | 37 | // map of cached Prometheus registry for a fast access 38 | entries map[string]*prometheus.Gatherer 39 | 40 | // a linked ordered list for a faster cache retention 41 | lru *list.List 42 | 43 | // how long each cache entry should be kept 44 | retention time.Duration 45 | 46 | // how frequent the cache retention is verified/applied 47 | pruneDelay time.Duration 48 | } 49 | 50 | // add a new cache entry or update it if already exists 51 | func (c *cache) add(id string, result prometheus.Gatherer) { 52 | c.mu.Lock() 53 | defer c.mu.Unlock() 54 | 55 | entry := &cacheEntry{ 56 | id: id, 57 | expiryTime: int64(c.retention.Seconds()) + time.Now().Unix(), 58 | } 59 | 60 | _, alreadyExists := c.entries[id] 61 | if alreadyExists { 62 | e := c.lru.Front() 63 | for e != nil { 64 | if e.Value.(*cacheEntry).id == id { 65 | c.lru.MoveToBack(e) 66 | break 67 | } 68 | e = e.Next() 69 | } 70 | } else { 71 | c.lru.PushBack(entry) 72 | } 73 | 74 | c.entries[id] = &result 75 | } 76 | 77 | // retrieve a cache entry if exists, otherwise return nil 78 | func (c *cache) get(id string) prometheus.Gatherer { 79 | c.mu.Lock() 80 | defer c.mu.Unlock() 81 | 82 | result, found := c.entries[id] 83 | if found { 84 | return *result 85 | } 86 | 87 | return nil 88 | } 89 | 90 | // prune expired entries from the cache 91 | func (c *cache) prune() { 92 | c.mu.Lock() 93 | defer c.mu.Unlock() 94 | 95 | e := c.lru.Front() 96 | 97 | for e != nil { 98 | entry := e.Value.(*cacheEntry) 99 | 100 | // since the list is ordered, we can stop the iteration once a fresh element is found 101 | if entry.expiryTime > time.Now().Unix() { 102 | break 103 | } 104 | 105 | next := e.Next() 106 | c.lru.Remove(e) 107 | delete(c.entries, entry.id) 108 | e = next 109 | } 110 | } 111 | 112 | // start a time ticker to remove expired cache entries 113 | func (c *cache) start() { 114 | ticker := time.NewTicker(c.pruneDelay) 115 | 116 | for range ticker.C { 117 | c.prune() 118 | } 119 | } 120 | 121 | // create a new cache and start the retention worker in the background 122 | func newCache(pruneDelay, retention time.Duration) *cache { 123 | c := &cache{ 124 | entries: make(map[string]*prometheus.Gatherer), 125 | lru: list.New(), 126 | retention: retention, 127 | pruneDelay: pruneDelay, 128 | } 129 | 130 | go c.start() 131 | 132 | return c 133 | } 134 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | "time" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | func TestAddGet(t *testing.T) { 25 | // initialize cache 26 | pruneDelay := 1 * time.Minute 27 | retention := 1 * time.Minute 28 | cache := newCache(pruneDelay, retention) 29 | 30 | // create test registry 31 | registry := prometheus.NewRegistry() 32 | 33 | // test adding a cache entry 34 | entryID := "testDomain" 35 | metricName := "metric" 36 | 37 | metric := prometheus.NewGauge(prometheus.GaugeOpts{ 38 | Name: metricName, 39 | }) 40 | registry.MustRegister(metric) 41 | 42 | cache.add(entryID, prometheus.Gatherer(registry)) 43 | 44 | // fetch the cached entry and verify contents 45 | entry := cache.get(entryID) 46 | mfs, _ := entry.Gather() 47 | 48 | // check the content of the cached registry 49 | if len(mfs) != 1 { 50 | t.Errorf("Cached registry contains more metrics than expected.\nExpected : %v\nGot : %v\n", 1, len(mfs)) 51 | } 52 | if mfs[0].GetName() != metricName { 53 | t.Errorf("Cached registry contains wrong metric name.\nExpected : %v\nGot : %v\n", metricName, mfs[0].GetName()) 54 | } 55 | 56 | // add 2nd entry 57 | cache.add(entryID+"_2nd", prometheus.Gatherer(registry)) 58 | if cache.lru.Len() != 2 || len(cache.entries) != 2 { 59 | var dupEntries []cacheEntry 60 | for e := cache.lru.Front(); e != nil; e = e.Next() { 61 | dupEntries = append(dupEntries, *e.Value.(*cacheEntry)) 62 | } 63 | t.Errorf("Cache doesn't contain expected entries count.\nFound entries : %v\n", dupEntries) 64 | } 65 | 66 | // add a duplicate entry 67 | cache.add(entryID+"_2nd", prometheus.Gatherer(registry)) 68 | if cache.lru.Len() != 2 || len(cache.entries) != 2 { 69 | var dupEntries []cacheEntry 70 | for e := cache.lru.Front(); e != nil; e = e.Next() { 71 | dupEntries = append(dupEntries, *e.Value.(*cacheEntry)) 72 | } 73 | t.Errorf("Cache contains duplicate entries.\nFound entries : %v\n", dupEntries) 74 | } 75 | 76 | // fetch non-existing content 77 | nonExistingEntry := cache.get("404") 78 | if nonExistingEntry != nil { 79 | t.Errorf("Cache returns unexpected result.\nFound : %v\n", nonExistingEntry) 80 | } 81 | } 82 | 83 | func TestPrune(t *testing.T) { 84 | // initialize cache 85 | pruneDelay := 1 * time.Second 86 | retention := 2 * time.Second 87 | cache := newCache(pruneDelay, retention) 88 | 89 | // create test registry 90 | registry := prometheus.NewRegistry() 91 | 92 | // test adding a cache entry 93 | entryID := "testDomain" 94 | metricName := "metric" 95 | 96 | metric := prometheus.NewGauge(prometheus.GaugeOpts{ 97 | Name: metricName, 98 | }) 99 | registry.MustRegister(metric) 100 | 101 | cache.add(entryID, prometheus.Gatherer(registry)) 102 | 103 | // wait for the cache to expire 104 | time.Sleep(retention + pruneDelay) 105 | 106 | // check the cache staleness 107 | entry := cache.get(entryID) 108 | if entry != nil { 109 | t.Errorf("Cache contains stale data") 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /examples/grafana_dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [], 3 | "__requires": [ 4 | { 5 | "type": "grafana", 6 | "id": "grafana", 7 | "name": "Grafana", 8 | "version": "6.6.0" 9 | }, 10 | { 11 | "type": "panel", 12 | "id": "graph", 13 | "name": "Graph", 14 | "version": "" 15 | }, 16 | { 17 | "type": "datasource", 18 | "id": "prometheus", 19 | "name": "Prometheus", 20 | "version": "1.0.0" 21 | } 22 | ], 23 | "annotations": { 24 | "list": [ 25 | { 26 | "builtIn": 1, 27 | "datasource": "-- Grafana --", 28 | "enable": true, 29 | "hide": true, 30 | "iconColor": "rgba(0, 211, 255, 1)", 31 | "name": "Annotations & Alerts", 32 | "type": "dashboard" 33 | } 34 | ] 35 | }, 36 | "editable": true, 37 | "gnetId": null, 38 | "graphTooltip": 0, 39 | "id": null, 40 | "iteration": 1584376540985, 41 | "links": [], 42 | "panels": [ 43 | { 44 | "aliasColors": {}, 45 | "bars": false, 46 | "dashLength": 10, 47 | "dashes": false, 48 | "datasource": "$datasource", 49 | "fill": 1, 50 | "fillGradient": 0, 51 | "gridPos": { 52 | "h": 6, 53 | "w": 24, 54 | "x": 0, 55 | "y": 0 56 | }, 57 | "hiddenSeries": false, 58 | "id": 5, 59 | "interval": "", 60 | "legend": { 61 | "alignAsTable": true, 62 | "avg": false, 63 | "current": true, 64 | "max": false, 65 | "min": false, 66 | "rightSide": true, 67 | "show": true, 68 | "sort": "current", 69 | "sortDesc": true, 70 | "total": false, 71 | "values": true 72 | }, 73 | "lines": true, 74 | "linewidth": 1, 75 | "nullPointMode": "connected", 76 | "options": { 77 | "dataLinks": [] 78 | }, 79 | "percentage": false, 80 | "pointradius": 2, 81 | "points": false, 82 | "renderer": "flot", 83 | "seriesOverrides": [], 84 | "spaceLength": 10, 85 | "stack": false, 86 | "steppedLine": false, 87 | "targets": [ 88 | { 89 | "expr": "ssllabs_grade{instance=~\"$target\"}", 90 | "interval": "", 91 | "legendFormat": "{{ instance }} [{{ grade }}]", 92 | "refId": "A" 93 | } 94 | ], 95 | "thresholds": [], 96 | "timeFrom": null, 97 | "timeRegions": [], 98 | "timeShift": null, 99 | "title": "Grade", 100 | "tooltip": { 101 | "shared": true, 102 | "sort": 2, 103 | "value_type": "individual" 104 | }, 105 | "type": "graph", 106 | "xaxis": { 107 | "buckets": null, 108 | "mode": "time", 109 | "name": null, 110 | "show": true, 111 | "values": [] 112 | }, 113 | "yaxes": [ 114 | { 115 | "format": "short", 116 | "label": null, 117 | "logBase": 1, 118 | "max": null, 119 | "min": null, 120 | "show": true 121 | }, 122 | { 123 | "format": "short", 124 | "label": null, 125 | "logBase": 1, 126 | "max": null, 127 | "min": null, 128 | "show": true 129 | } 130 | ], 131 | "yaxis": { 132 | "align": false, 133 | "alignLevel": null 134 | } 135 | }, 136 | { 137 | "aliasColors": {}, 138 | "bars": false, 139 | "dashLength": 10, 140 | "dashes": false, 141 | "datasource": "$datasource", 142 | "fill": 1, 143 | "fillGradient": 0, 144 | "gridPos": { 145 | "h": 6, 146 | "w": 24, 147 | "x": 0, 148 | "y": 6 149 | }, 150 | "hiddenSeries": false, 151 | "id": 2, 152 | "interval": "", 153 | "legend": { 154 | "alignAsTable": true, 155 | "avg": false, 156 | "current": true, 157 | "max": false, 158 | "min": false, 159 | "rightSide": true, 160 | "show": true, 161 | "sort": "current", 162 | "sortDesc": true, 163 | "total": false, 164 | "values": true 165 | }, 166 | "lines": true, 167 | "linewidth": 1, 168 | "nullPointMode": "connected", 169 | "options": { 170 | "dataLinks": [] 171 | }, 172 | "percentage": false, 173 | "pointradius": 2, 174 | "points": false, 175 | "renderer": "flot", 176 | "seriesOverrides": [], 177 | "spaceLength": 10, 178 | "stack": false, 179 | "steppedLine": false, 180 | "targets": [ 181 | { 182 | "expr": "ssllabs_probe_success{instance=~\"$target\"}", 183 | "interval": "", 184 | "legendFormat": "{{ instance }}", 185 | "refId": "A" 186 | } 187 | ], 188 | "thresholds": [], 189 | "timeFrom": null, 190 | "timeRegions": [], 191 | "timeShift": null, 192 | "title": "Probe Success", 193 | "tooltip": { 194 | "shared": true, 195 | "sort": 2, 196 | "value_type": "individual" 197 | }, 198 | "type": "graph", 199 | "xaxis": { 200 | "buckets": null, 201 | "mode": "time", 202 | "name": null, 203 | "show": true, 204 | "values": [] 205 | }, 206 | "yaxes": [ 207 | { 208 | "format": "short", 209 | "label": null, 210 | "logBase": 1, 211 | "max": null, 212 | "min": null, 213 | "show": true 214 | }, 215 | { 216 | "format": "short", 217 | "label": null, 218 | "logBase": 1, 219 | "max": null, 220 | "min": null, 221 | "show": true 222 | } 223 | ], 224 | "yaxis": { 225 | "align": false, 226 | "alignLevel": null 227 | } 228 | }, 229 | { 230 | "aliasColors": {}, 231 | "bars": false, 232 | "dashLength": 10, 233 | "dashes": false, 234 | "datasource": "$datasource", 235 | "fill": 1, 236 | "fillGradient": 0, 237 | "gridPos": { 238 | "h": 6, 239 | "w": 24, 240 | "x": 0, 241 | "y": 12 242 | }, 243 | "hiddenSeries": false, 244 | "id": 4, 245 | "interval": "", 246 | "legend": { 247 | "alignAsTable": true, 248 | "avg": false, 249 | "current": true, 250 | "max": false, 251 | "min": false, 252 | "rightSide": true, 253 | "show": true, 254 | "sort": "current", 255 | "sortDesc": true, 256 | "total": false, 257 | "values": true 258 | }, 259 | "lines": true, 260 | "linewidth": 1, 261 | "nullPointMode": "connected", 262 | "options": { 263 | "dataLinks": [] 264 | }, 265 | "percentage": false, 266 | "pointradius": 2, 267 | "points": false, 268 | "renderer": "flot", 269 | "seriesOverrides": [], 270 | "spaceLength": 10, 271 | "stack": false, 272 | "steppedLine": false, 273 | "targets": [ 274 | { 275 | "expr": "ssllabs_grade_time_seconds{instance=~\"$target\"} * 1000", 276 | "interval": "", 277 | "legendFormat": "{{ instance }}", 278 | "refId": "A" 279 | } 280 | ], 281 | "thresholds": [], 282 | "timeFrom": null, 283 | "timeRegions": [], 284 | "timeShift": null, 285 | "title": "Probe Time", 286 | "tooltip": { 287 | "shared": true, 288 | "sort": 2, 289 | "value_type": "individual" 290 | }, 291 | "type": "graph", 292 | "xaxis": { 293 | "buckets": null, 294 | "mode": "time", 295 | "name": null, 296 | "show": true, 297 | "values": [] 298 | }, 299 | "yaxes": [ 300 | { 301 | "decimals": null, 302 | "format": "dateTimeAsUS", 303 | "label": "", 304 | "logBase": 1, 305 | "max": null, 306 | "min": null, 307 | "show": true 308 | }, 309 | { 310 | "format": "short", 311 | "label": null, 312 | "logBase": 1, 313 | "max": null, 314 | "min": null, 315 | "show": true 316 | } 317 | ], 318 | "yaxis": { 319 | "align": false, 320 | "alignLevel": null 321 | } 322 | }, 323 | { 324 | "aliasColors": {}, 325 | "bars": false, 326 | "dashLength": 10, 327 | "dashes": false, 328 | "datasource": "$datasource", 329 | "fill": 1, 330 | "fillGradient": 0, 331 | "gridPos": { 332 | "h": 6, 333 | "w": 24, 334 | "x": 0, 335 | "y": 18 336 | }, 337 | "hiddenSeries": false, 338 | "id": 3, 339 | "interval": "", 340 | "legend": { 341 | "alignAsTable": true, 342 | "avg": false, 343 | "current": true, 344 | "max": true, 345 | "min": false, 346 | "rightSide": true, 347 | "show": true, 348 | "sort": "current", 349 | "sortDesc": true, 350 | "total": false, 351 | "values": true 352 | }, 353 | "lines": true, 354 | "linewidth": 1, 355 | "nullPointMode": "connected", 356 | "options": { 357 | "dataLinks": [] 358 | }, 359 | "percentage": false, 360 | "pointradius": 2, 361 | "points": false, 362 | "renderer": "flot", 363 | "seriesOverrides": [], 364 | "spaceLength": 10, 365 | "stack": false, 366 | "steppedLine": false, 367 | "targets": [ 368 | { 369 | "expr": "ssllabs_probe_duration_seconds{instance=~\"$target\"}", 370 | "interval": "", 371 | "legendFormat": "{{ instance }}", 372 | "refId": "A" 373 | } 374 | ], 375 | "thresholds": [], 376 | "timeFrom": null, 377 | "timeRegions": [], 378 | "timeShift": null, 379 | "title": "Probe Duration", 380 | "tooltip": { 381 | "shared": true, 382 | "sort": 2, 383 | "value_type": "individual" 384 | }, 385 | "type": "graph", 386 | "xaxis": { 387 | "buckets": null, 388 | "mode": "time", 389 | "name": null, 390 | "show": true, 391 | "values": [] 392 | }, 393 | "yaxes": [ 394 | { 395 | "format": "s", 396 | "label": null, 397 | "logBase": 1, 398 | "max": null, 399 | "min": null, 400 | "show": true 401 | }, 402 | { 403 | "format": "short", 404 | "label": null, 405 | "logBase": 1, 406 | "max": null, 407 | "min": null, 408 | "show": true 409 | } 410 | ], 411 | "yaxis": { 412 | "align": false, 413 | "alignLevel": null 414 | } 415 | } 416 | ], 417 | "refresh": "1m", 418 | "schemaVersion": 22, 419 | "style": "dark", 420 | "tags": [], 421 | "templating": { 422 | "list": [ 423 | { 424 | "current": { 425 | "text": "Prometheus", 426 | "value": "Prometheus" 427 | }, 428 | "hide": 0, 429 | "includeAll": false, 430 | "label": "Datasource", 431 | "multi": false, 432 | "name": "datasource", 433 | "options": [], 434 | "query": "prometheus", 435 | "refresh": 1, 436 | "regex": "", 437 | "skipUrlSync": false, 438 | "type": "datasource" 439 | }, 440 | { 441 | "allValue": null, 442 | "current": {}, 443 | "datasource": "$datasource", 444 | "definition": "label_values(ssllabs_probe_success, instance)", 445 | "hide": 0, 446 | "includeAll": true, 447 | "label": "Target", 448 | "multi": true, 449 | "name": "target", 450 | "options": [], 451 | "query": "label_values(ssllabs_probe_success, instance)", 452 | "refresh": 2, 453 | "regex": "", 454 | "skipUrlSync": false, 455 | "sort": 0, 456 | "tagValuesQuery": "", 457 | "tags": [], 458 | "tagsQuery": "", 459 | "type": "query", 460 | "useTags": false 461 | } 462 | ] 463 | }, 464 | "time": { 465 | "from": "now-1h", 466 | "to": "now" 467 | }, 468 | "timepicker": { 469 | "refresh_intervals": [ 470 | "5s", 471 | "10s", 472 | "30s", 473 | "1m", 474 | "5m", 475 | "15m", 476 | "30m", 477 | "1h", 478 | "2h", 479 | "1d" 480 | ] 481 | }, 482 | "timezone": "", 483 | "title": "SSLLabs", 484 | "uid": "Do0wJrsWk", 485 | "version": 2 486 | } -------------------------------------------------------------------------------- /examples/kubernetes/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: ssllabs-exporter 5 | labels: 6 | app: ssllabs-exporter 7 | spec: 8 | replicas: 1 9 | template: 10 | metadata: 11 | labels: 12 | app: ssllabs-exporter 13 | spec: 14 | containers: 15 | - name: ssllabs-exporter 16 | image: "anasaso/ssllabs_exporter:latest" 17 | securityContext: 18 | allowPrivilegeEscalation: false 19 | ports: 20 | - containerPort: 19115 21 | livenessProbe: 22 | httpGet: 23 | path: / 24 | port: 19115 25 | initialDelaySeconds: 5 26 | periodSeconds: 5 27 | timeoutSeconds: 10 28 | -------------------------------------------------------------------------------- /examples/kubernetes/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: ssllabs-exporter 5 | labels: 6 | app: ssllabs-exporter 7 | spec: 8 | ports: 9 | - name: http 10 | port: 80 11 | protocol: TCP 12 | targetPort: 19115 13 | selector: 14 | app: ssllabs-exporter 15 | type: ClusterIP 16 | -------------------------------------------------------------------------------- /examples/prometheus/kubernetes_service_discovery.yaml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: 'ssllabs-exporter' 3 | scrape_interval: 10m 4 | scrape_timeout: 10m 5 | metrics_path: /probe 6 | kubernetes_sd_configs: 7 | - role: ingress 8 | relabel_configs: 9 | - source_labels: [__address__] 10 | target_label: __param_target 11 | - target_label: __address__ 12 | replacement: ssllabs-exporter # change this to the FQDN of ssllabs-exporter 13 | - source_labels: [__param_target] 14 | target_label: instance 15 | - action: labelmap 16 | regex: __meta_kubernetes_ingress_label_(.+) 17 | - source_labels: [__meta_kubernetes_namespace] 18 | target_label: kubernetes_namespace 19 | - source_labels: [__meta_kubernetes_ingress_name] 20 | target_label: kubernetes_name 21 | -------------------------------------------------------------------------------- /examples/prometheus/static_targets.yaml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: 'ssllabs-exporter' 3 | scrape_interval: 10m 4 | scrape_timeout: 10m 5 | metrics_path: /probe 6 | static_configs: 7 | - targets: 8 | - duckduckgo.com 9 | - stackoverflow.com 10 | - prometheus.io 11 | - a-domain-that-will-fail.somewhere 12 | - github.com 13 | - ssllabs.com 14 | relabel_configs: 15 | - source_labels: [__address__] 16 | target_label: __param_target 17 | - target_label: __address__ 18 | replacement: ssllabs-exporter # change this to the FQDN of ssllabs-exporter 19 | - source_labels: [__param_target] 20 | target_label: instance 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/anas-aso/ssllabs_exporter 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/alecthomas/kingpin/v2 v2.4.0 7 | github.com/essentialkaos/sslscan/v13 v13.2.1 8 | github.com/prometheus/client_golang v1.22.0 9 | github.com/rs/zerolog v1.34.0 10 | ) 11 | 12 | require ( 13 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 14 | github.com/andybalholm/brotli v1.0.5 // indirect 15 | github.com/beorn7/perks v1.0.1 // indirect 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 17 | github.com/klauspost/compress v1.18.0 // indirect 18 | github.com/mattn/go-colorable v0.1.13 // indirect 19 | github.com/mattn/go-isatty v0.0.19 // indirect 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 21 | github.com/prometheus/client_model v0.6.1 // indirect 22 | github.com/prometheus/common v0.62.0 // indirect 23 | github.com/prometheus/procfs v0.15.1 // indirect 24 | github.com/valyala/bytebufferpool v1.0.0 // indirect 25 | github.com/valyala/fasthttp v1.50.0 // indirect 26 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 27 | golang.org/x/sys v0.30.0 // indirect 28 | google.golang.org/protobuf v1.36.5 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /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-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= 6 | github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 7 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/essentialkaos/check v1.4.0 h1:kWdFxu9odCxUqo1NNFNJmguGrDHgwi3A8daXX1nkuKk= 16 | github.com/essentialkaos/check v1.4.0/go.mod h1:LMKPZ2H+9PXe7Y2gEoKyVAwUqXVgx7KtgibfsHJPus0= 17 | github.com/essentialkaos/sslscan/v13 v13.2.1 h1:TWT+isjAtE4hLb4RFXfVbSV7e4kRMkSwOcqK6FU4bp0= 18 | github.com/essentialkaos/sslscan/v13 v13.2.1/go.mod h1:YFx/6iJ97/57mMMVa5+r/JywNTjlo0dGQQIu//E0bnU= 19 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 20 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 21 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 22 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 23 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 24 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 25 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 26 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 27 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 28 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 29 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 30 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 31 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 32 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 33 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 34 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 36 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 37 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 38 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 39 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 40 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 41 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 42 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 43 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 44 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 45 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 46 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 47 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 48 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 49 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 50 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 51 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 52 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 53 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 54 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 55 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 56 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 57 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 58 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 59 | github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M= 60 | github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= 61 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 62 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 63 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 67 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 68 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 69 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 70 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 71 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | -------------------------------------------------------------------------------- /internal/build/build.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | import "runtime" 4 | 5 | // build parameters 6 | var ( 7 | Branch = "dev" 8 | GoVersion = runtime.Version() 9 | Revision = "n/a" 10 | Version = "n/a" 11 | ) 12 | -------------------------------------------------------------------------------- /internal/exporter/exporter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package exporter 16 | 17 | import ( 18 | "context" 19 | "time" 20 | 21 | "github.com/anas-aso/ssllabs_exporter/internal/ssllabs" 22 | "github.com/prometheus/client_golang/prometheus" 23 | log "github.com/rs/zerolog" 24 | ) 25 | 26 | const probeSuccessMetricName = "ssllabs_probe_success" 27 | 28 | // Handle runs SSLLabs assessment on the specified target 29 | // and returns a Prometheus Registry with the results 30 | func Handle(ctx context.Context, logger log.Logger, target string) prometheus.Gatherer { 31 | var ( 32 | registry = prometheus.NewRegistry() 33 | probeDurationGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 34 | Name: "ssllabs_probe_duration_seconds", 35 | Help: "Displays how long the assessment took to complete in seconds", 36 | }) 37 | probeSuccessGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 38 | Name: probeSuccessMetricName, 39 | Help: "Displays whether the assessment succeeded or not", 40 | }) 41 | 42 | probeGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 43 | Name: "ssllabs_grade", 44 | Help: "Displays the returned SSLLabs grade of the target host", 45 | }, []string{"grade"}) 46 | probeTimeGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 47 | Name: "ssllabs_grade_time_seconds", 48 | Help: "Displays the assessment time for the target host", 49 | }) 50 | ) 51 | 52 | registry.MustRegister(probeDurationGauge) 53 | registry.MustRegister(probeSuccessGauge) 54 | registry.MustRegister(probeGaugeVec) 55 | registry.MustRegister(probeTimeGauge) 56 | 57 | start := time.Now() 58 | probeTimeGauge.Set(float64(start.Unix())) 59 | 60 | result, err := ssllabs.Analyze(ctx, logger, target) 61 | 62 | probeDurationGauge.Set(time.Since(start).Seconds()) 63 | 64 | if err != nil { 65 | logger.Error().Err(err).Str("target", target).Msg("assessment failed") 66 | // set grade to -1 if the assessment failed 67 | probeGaugeVec.WithLabelValues("-").Set(-1) 68 | 69 | return registry 70 | } 71 | 72 | probeSuccessGauge.Set(1) 73 | 74 | grade := endpointsLowestGrade(result.Endpoints) 75 | 76 | if grade != "" { 77 | probeGaugeVec.WithLabelValues(grade).Set(1) 78 | } else { 79 | // set grade to 0 if the target does not have an endpoint 80 | probeGaugeVec.WithLabelValues("-").Set(0) 81 | } 82 | 83 | return registry 84 | } 85 | 86 | // Failed checks whether the assessment failed or not 87 | func Failed(registry prometheus.Gatherer) bool { 88 | metrics, err := registry.Gather() 89 | if err != nil { 90 | return false 91 | } 92 | 93 | for _, m := range metrics { 94 | if m.GetName() == probeSuccessMetricName { 95 | result := m.GetMetric()[0].GetGauge().Value 96 | return *result == 0 97 | } 98 | } 99 | 100 | return false 101 | } 102 | -------------------------------------------------------------------------------- /internal/exporter/exporter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package exporter 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | ) 22 | 23 | func TestFailed(t *testing.T) { 24 | var cases = []struct { 25 | name string 26 | value float64 27 | expectedResult bool 28 | }{ 29 | { 30 | name: "failed_assessment", 31 | value: 0, 32 | expectedResult: true, 33 | }, 34 | { 35 | name: "successful_assessment", 36 | value: 1, 37 | expectedResult: false, 38 | }, 39 | { 40 | name: "unregistered_metric", 41 | value: -1, 42 | expectedResult: false, 43 | }, 44 | } 45 | 46 | for _, c := range cases { 47 | // init registry 48 | registry := prometheus.NewRegistry() 49 | probeSuccessGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 50 | Name: probeSuccessMetricName, 51 | Help: "Displays whether the assessment succeeded or not", 52 | }) 53 | 54 | if c.value != -1 { 55 | registry.MustRegister(probeSuccessGauge) 56 | probeSuccessGauge.Set(c.value) 57 | } 58 | 59 | result := Failed(registry) 60 | if result != c.expectedResult { 61 | t.Errorf("Test case : %v failed.\nExpected : %v\nGot : %v\n", c.name, c.expectedResult, result) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/exporter/grades.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package exporter 16 | 17 | import ( 18 | ssllabsApi "github.com/essentialkaos/sslscan/v13" 19 | ) 20 | 21 | // Map grades to numerical values as defined in https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide#methodology-overview 22 | // Since the documented mapping provides a range of values for each grade instead of fixed ones, we take half the documented interval 23 | // to allow mapping case like A+ and A-. 24 | // A special undocumented cases are T and M for which we assign 0. 25 | var gradesMapping = map[string]float64{ 26 | "A": (80 + 100) / 2, 27 | "A+": ((80 + 100) / 2) + 1, 28 | "A-": ((80 + 100) / 2) - 1, 29 | "B": (65 + 80) / 2, 30 | "C": (50 + 65) / 2, 31 | "D": (35 + 50) / 2, 32 | "E": (20 + 35) / 2, 33 | "F": (0 + 20) / 2, 34 | "M": 0, 35 | "T": 0, 36 | "undef": -1, 37 | } 38 | 39 | // convert the returned grade to a number based on https://github.com/ssllabs/research/wiki/SSL-Server-Rating-Guide 40 | func endpointsLowestGrade(ep []*ssllabsApi.EndpointInfo) (result string) { 41 | if len(ep) == 0 { 42 | return 43 | } 44 | 45 | // the target host gets the lowest score of its endpoints 46 | for _, e := range ep { 47 | // skip endpoints without a grade : case of unreachable endpoint(s) 48 | if e.Grade == "" { 49 | continue 50 | } 51 | 52 | // initialize the result with the first defined grade 53 | if result == "" { 54 | result = e.Grade 55 | } 56 | 57 | eGrade, ok := gradesMapping[e.Grade] 58 | if ok { 59 | if gradesMapping[result] > eGrade { 60 | result = e.Grade 61 | } 62 | } else { 63 | return "undef" 64 | } 65 | } 66 | 67 | return 68 | } 69 | -------------------------------------------------------------------------------- /internal/exporter/grades_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package exporter 16 | 17 | import ( 18 | "testing" 19 | 20 | ssllabsApi "github.com/essentialkaos/sslscan/v13" 21 | ) 22 | 23 | func TestEndpointsLowestGrade(t *testing.T) { 24 | var cases = []struct { 25 | name string 26 | data []*ssllabsApi.EndpointInfo 27 | expectedResult string 28 | }{ 29 | { 30 | name: "result_without_endpoints", 31 | data: []*ssllabsApi.EndpointInfo{}, 32 | expectedResult: "", 33 | }, 34 | { 35 | name: "result_with_unreachable_endpoint(s)", 36 | data: []*ssllabsApi.EndpointInfo{ 37 | { 38 | StatusMessage: "Unable to connect to the server", 39 | Grade: "", 40 | }, 41 | }, 42 | expectedResult: "", 43 | }, 44 | { 45 | name: "result_with_single_unreachable_endpoint", 46 | data: []*ssllabsApi.EndpointInfo{ 47 | { 48 | StatusMessage: "Unable to connect to the server", 49 | Grade: "", 50 | }, 51 | { 52 | Grade: "A", 53 | }, 54 | { 55 | Grade: "B", 56 | }, 57 | }, 58 | expectedResult: "B", 59 | }, 60 | { 61 | name: "single_grade", 62 | data: []*ssllabsApi.EndpointInfo{ 63 | { 64 | Grade: "A+", 65 | }, 66 | }, 67 | expectedResult: "A+", 68 | }, 69 | { 70 | name: "multiple_grades", 71 | data: []*ssllabsApi.EndpointInfo{ 72 | { 73 | Grade: "B", 74 | }, 75 | { 76 | Grade: "A", 77 | }, 78 | { 79 | Grade: "A+", 80 | }, 81 | }, 82 | expectedResult: "B", 83 | }, 84 | { 85 | name: "unknown_grade", 86 | data: []*ssllabsApi.EndpointInfo{ 87 | { 88 | Grade: "B", 89 | }, 90 | { 91 | Grade: "A", 92 | }, 93 | { 94 | Grade: "X", 95 | }, 96 | }, 97 | expectedResult: "undef", 98 | }, 99 | } 100 | 101 | for _, c := range cases { 102 | grade := endpointsLowestGrade(c.data) 103 | if grade != c.expectedResult { 104 | t.Errorf("Test case : %v failed.\nExpected : %v\nGot : %v\n", c.name, c.expectedResult, grade) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/ssllabs/analyze.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ssllabs 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "math/rand" 21 | "time" 22 | 23 | ssllabsApi "github.com/essentialkaos/sslscan/v13" 24 | log "github.com/rs/zerolog" 25 | 26 | "github.com/anas-aso/ssllabs_exporter/internal/build" 27 | ) 28 | 29 | var api *ssllabsApi.API 30 | 31 | func init() { 32 | api, _ = ssllabsApi.NewAPI("ssllabs-exporter", build.Version) 33 | if api == nil { 34 | panic("failed to initialize API client. this should never happen!") 35 | } 36 | } 37 | 38 | // Analyze executes the SSL test HTTP requests 39 | func Analyze(ctx context.Context, logger log.Logger, target string) (result *ssllabsApi.AnalyzeInfo, err error) { 40 | logger.Debug().Str("target", target).Msg("start processing") 41 | 42 | // check cached results and return them if they are "fresh enough" 43 | // this is mainly useful if the previous context timed out or 44 | // canceled before we collected the results 45 | analyzeProgress, err := api.Analyze(target, ssllabsApi.AnalyzeParams{}) 46 | if err != nil { 47 | logger.Error().Err(err).Str("target", target).Msg("failed to get cached result") 48 | return 49 | } 50 | 51 | result, err = analyzeProgress.Info(true, false) 52 | if err != nil { 53 | logger.Error().Err(err).Str("target", target).Msg("failed to get cached result") 54 | return 55 | } 56 | 57 | deadline, _ := ctx.Deadline() 58 | // reconstruct the assessment timeout from the context deadline 59 | timeout := deadline.Unix() - time.Now().Unix() 60 | if result.Status == ssllabsApi.STATUS_READY && result.TestTime/1000+timeout >= time.Now().Unix() { 61 | logger.Debug().Str("target", target).Msg("cached result will be used") 62 | return 63 | } 64 | 65 | // trigger a new assessment if there isn't one in progress 66 | if result.Status != ssllabsApi.STATUS_DNS && result.Status != ssllabsApi.STATUS_IN_PROGRESS { 67 | logger.Debug().Str("target", target).Msg("triggering a new assessment") 68 | analyzeProgress, err = api.Analyze(target, ssllabsApi.AnalyzeParams{StartNew: true}) 69 | if err != nil { 70 | logger.Error().Err(err).Str("target", target).Msg("failed to trigger a new assessment") 71 | return 72 | } 73 | } 74 | 75 | result, err = analyzeProgress.Info(true, false) 76 | if err != nil { 77 | logger.Error().Err(err).Str("target", target).Msg("failed to get running assessment info") 78 | return 79 | } 80 | 81 | for { 82 | switch { 83 | case result.Status == ssllabsApi.STATUS_READY: 84 | logger.Debug().Str("target", target).Msg("assessment finished successfully") 85 | return result, nil 86 | case time.Now().After(deadline): 87 | result.Status = StatusDeadlineExceeded 88 | return result, fmt.Errorf("context deadline exceeded") 89 | // fetch updates at random intervals 90 | default: 91 | time.Sleep(time.Duration(10+rand.Intn(10)) * time.Second) 92 | logger.Debug().Str("target", target).Msg("fetching assessment updates") 93 | result, err = analyzeProgress.Info(true, false) 94 | if err != nil { 95 | logger.Error().Err(err).Str("target", target).Msg("failed to fetch updates") 96 | return 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /internal/ssllabs/info.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ssllabs 16 | 17 | import ( 18 | "encoding/json" 19 | "io/ioutil" 20 | "net/http" 21 | "time" 22 | ) 23 | 24 | // APIInfo /info endpoint result 25 | type APIInfo struct { 26 | EngineVersion string `json:"engineVersion"` 27 | CriteriaVersion string `json:"criteriaVersion"` 28 | MaxAssessments int `json:"maxAssessments"` 29 | CurrentAssessments int `json:"currentAssessments"` 30 | } 31 | 32 | // Info calls /info endpoint and returns and Info 33 | func Info() (info APIInfo, err error) { 34 | // TODO: make http timeout configurable 35 | httpClient := http.Client{Timeout: 1 * time.Minute} 36 | response, err := httpClient.Get(API + "/info") 37 | if err != nil { 38 | return 39 | } 40 | 41 | defer response.Body.Close() 42 | 43 | body, err := ioutil.ReadAll(response.Body) 44 | if err != nil { 45 | return 46 | } 47 | 48 | err = json.Unmarshal(body, &info) 49 | if err != nil { 50 | return 51 | } 52 | 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /internal/ssllabs/info_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ssllabs 16 | 17 | import ( 18 | "reflect" 19 | "testing" 20 | ) 21 | 22 | func TestInfo(t *testing.T) { 23 | info, err := Info() 24 | if err != nil { 25 | t.Errorf("\nerror fetching SSLLabs API info: %v", err) 26 | } 27 | 28 | expectedInfo := APIInfo{ 29 | EngineVersion: "2.3.1", 30 | CriteriaVersion: "2009q", 31 | MaxAssessments: 25, 32 | CurrentAssessments: 0, 33 | } 34 | 35 | if !reflect.DeepEqual(expectedInfo, info) { 36 | t.Errorf("\nexpected info: %v\nreturned info: %v", expectedInfo, info) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/ssllabs/ssllabs.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ssllabs 16 | 17 | const ( 18 | // API SSLLabs API URL 19 | API = "https://api.ssllabs.com/api/v3/" 20 | // StatusDNS assessment still in DNS resolution phase 21 | StatusDNS = "DNS" 22 | // StatusError error running the assessment (e.g target behind a firewall) 23 | StatusError = "ERROR" 24 | // StatusInProgress assessment in progress 25 | StatusInProgress = "IN_PROGRESS" 26 | // StatusReady SSLLabs assessment finished successfully 27 | StatusReady = "READY" 28 | // StatusHTTPError error processing the HTTP response from SSLLabs API 29 | StatusHTTPError = "HTTP_ERROR" 30 | // StatusServerError SSLLabs API server error or rate limiting 31 | StatusServerError = "SERVER_ERROR" 32 | // StatusDeadlineExceeded assessment deadline exceeded 33 | StatusDeadlineExceeded = "DEADLINE_EXCEEDED" 34 | // StatusAborted assessment canceled by the client 35 | StatusAborted = "ABORTED" 36 | ) 37 | -------------------------------------------------------------------------------- /ssllabs_exporter.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "os" 23 | "strconv" 24 | "time" 25 | 26 | "github.com/alecthomas/kingpin/v2" 27 | "github.com/prometheus/client_golang/prometheus" 28 | "github.com/prometheus/client_golang/prometheus/promauto" 29 | "github.com/prometheus/client_golang/prometheus/promhttp" 30 | log "github.com/rs/zerolog" 31 | 32 | "github.com/anas-aso/ssllabs_exporter/internal/build" 33 | "github.com/anas-aso/ssllabs_exporter/internal/exporter" 34 | "github.com/anas-aso/ssllabs_exporter/internal/ssllabs" 35 | ) 36 | 37 | const ( 38 | pruneDelay = 1 * time.Minute 39 | ) 40 | 41 | var ( 42 | listenAddress = kingpin.Flag("listen-address", "The address to listen on for HTTP requests.").Default(":19115").String() 43 | probeTimeout = kingpin.Flag("timeout", "Time duration before canceling an ongoing probe such as 30m or 1h5m. This value must be at least 1m. Valid duration units are ns, us (or µs), ms, s, m, h.").Default("10m").String() 44 | logLevel = kingpin.Flag("log-level", "Printed logs level.").Default("debug").Enum("error", "warn", "info", "debug") 45 | cacheRetention = kingpin.Flag("cache-retention", "Time duration to keep entries in cache such as 30m or 1h5m. Valid duration units are ns, us (or µs), ms, s, m, h.").Default("1h").String() 46 | cacheIgnoreFailed = kingpin.Flag("cache-ignore-failed", "Do not cache failed results due to intermittent SSLLabs issues.").Default("False").Bool() 47 | ) 48 | 49 | func probeHandler(w http.ResponseWriter, r *http.Request, logger log.Logger, timeoutSeconds time.Duration, resultsCache *cache) { 50 | target := r.URL.Query().Get("target") 51 | // TODO: add more validation for the target (e.g valid hostname, DNS, etc) 52 | if target == "" { 53 | logger.Error().Msg("Target parameter is missing") 54 | http.Error(w, "Target parameter is missing", http.StatusBadRequest) 55 | return 56 | } 57 | 58 | // check if the results are available in the cache 59 | registry := resultsCache.get(target) 60 | 61 | if registry != nil { 62 | logger.Debug().Str("target", target).Msg("serving results from cache") 63 | } else { 64 | // if the results do not exist in the cache, trigger a new assessment 65 | 66 | timeoutSeconds = getTimeout(r, timeoutSeconds) 67 | 68 | ctx, cancel := context.WithTimeout(r.Context(), timeoutSeconds) 69 | defer cancel() 70 | 71 | r = r.WithContext(ctx) 72 | 73 | registry = exporter.Handle(ctx, logger, target) 74 | 75 | // do not cache failed assessments if configured 76 | if *cacheIgnoreFailed && exporter.Failed(registry) { 77 | return 78 | } 79 | 80 | // add the assessment results to the cache 81 | resultsCache.add(target, registry) 82 | } 83 | 84 | h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) 85 | h.ServeHTTP(w, r) 86 | } 87 | 88 | func main() { 89 | kingpin.Version(build.Version) 90 | kingpin.Parse() 91 | 92 | logger, err := createLogger(*logLevel) 93 | if err != nil { 94 | fmt.Printf("failed to create logger with error: %v", err) 95 | os.Exit(1) 96 | } 97 | 98 | timeoutSeconds, err := validateTimeout(*probeTimeout) 99 | if err != nil { 100 | logger.Error().Err(err).Msg("failed to validate the probe timeout value") 101 | os.Exit(1) 102 | } 103 | 104 | cacheRetentionDuration, err := time.ParseDuration(*cacheRetention) 105 | if err != nil { 106 | logger.Error().Err(err).Msg("failed to parse the cache retention value") 107 | os.Exit(1) 108 | } 109 | resultsCache := newCache(pruneDelay, cacheRetentionDuration) 110 | 111 | logger.Info().Str("version", build.Version).Msg("Starting ssllabs_exporter") 112 | 113 | promauto.NewGaugeFunc( 114 | prometheus.GaugeOpts{ 115 | Name: "ssllabs_exporter", 116 | Help: "SSLLabs exporter build parameters", 117 | ConstLabels: prometheus.Labels{ 118 | "branch": build.Branch, 119 | "goversion": build.GoVersion, 120 | "revision": build.Revision, 121 | "version": build.Version, 122 | }, 123 | }, 124 | func() float64 { return 1 }, 125 | ) 126 | 127 | ssllabsInfo, err := ssllabs.Info() 128 | if err != nil { 129 | logger.Error().Err(err).Msg("Could not fetch SSLLabs API Info") 130 | os.Exit(1) 131 | } 132 | 133 | promauto.NewGaugeFunc( 134 | prometheus.GaugeOpts{ 135 | Name: "ssllabs_api", 136 | Help: "SSLLabs API engine and criteria versions", 137 | ConstLabels: prometheus.Labels{ 138 | "engine": ssllabsInfo.EngineVersion, 139 | "criteria": ssllabsInfo.EngineVersion, 140 | }, 141 | }, 142 | func() float64 { return 1 }, 143 | ) 144 | 145 | http.Handle("/metrics", promhttp.Handler()) 146 | 147 | http.HandleFunc("/probe", func(w http.ResponseWriter, r *http.Request) { 148 | probeHandler(w, r, logger, timeoutSeconds, resultsCache) 149 | }) 150 | 151 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 152 | w.Header().Set("Content-Type", "text/html") 153 | w.Write([]byte(` 154 | SSLLabs Exporter 155 | 156 |

SSLLabs Exporter

157 |

Check SSLLabs grade for prometheus.io

158 |

Exporter Metrics

159 | 160 | `)) 161 | }) 162 | 163 | logger.Info().Str("address", *listenAddress).Msg("Listening on address") 164 | 165 | if err := http.ListenAndServe(*listenAddress, nil); err != nil { 166 | logger.Error().Err(err).Msg("Error starting HTTP server") 167 | os.Exit(1) 168 | } 169 | } 170 | 171 | // get the min of Prometheus scrape timeout (if found) and the flag timeout 172 | func getTimeout(r *http.Request, timeout time.Duration) time.Duration { 173 | if v := r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds"); v != "" { 174 | scrapeTimeout, err := strconv.ParseFloat(v, 64) 175 | if err != nil { 176 | return timeout 177 | } 178 | 179 | scrapeTimeoutSeconds := time.Duration(scrapeTimeout) * time.Second 180 | 181 | if scrapeTimeoutSeconds < timeout { 182 | return scrapeTimeoutSeconds 183 | } 184 | } 185 | 186 | return timeout 187 | } 188 | 189 | // create logger with the provided log level 190 | func createLogger(l string) (logger log.Logger, err error) { 191 | var lvl log.Level 192 | switch l { 193 | case "error": 194 | lvl = log.ErrorLevel 195 | case "warn": 196 | lvl = log.WarnLevel 197 | case "info": 198 | lvl = log.InfoLevel 199 | case "debug": 200 | lvl = log.DebugLevel 201 | default: 202 | return log.Nop(), fmt.Errorf("unrecognized log level: %v", l) 203 | } 204 | 205 | log.MessageFieldName = "msg" 206 | log.TimestampFieldName = "timestamp" 207 | log.TimeFieldFormat = time.RFC3339Nano 208 | 209 | log.SetGlobalLevel(lvl) 210 | 211 | logger = log.New(os.Stdout).With().Timestamp().Logger() 212 | 213 | return logger, nil 214 | } 215 | 216 | // validate the provided probe timeout 217 | func validateTimeout(timeout string) (time.Duration, error) { 218 | timeoutSeconds, err := time.ParseDuration(timeout) 219 | if err != nil { 220 | return 0, err 221 | } 222 | 223 | // A new assessment will always take at least 60 seconds per host 224 | // endpoint. A timeout less than 60 seconds doesn't make sense. 225 | if timeoutSeconds < time.Minute { 226 | return 0, errors.New("probe timeout must be a least 1 minute") 227 | } 228 | 229 | return timeoutSeconds, nil 230 | } 231 | -------------------------------------------------------------------------------- /ssllabs_exporter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 Anas Ait Said Oubrahim 2 | 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "net/http" 20 | "net/http/httptest" 21 | "testing" 22 | "time" 23 | 24 | log "github.com/rs/zerolog" 25 | ) 26 | 27 | func TestProbeHandler(t *testing.T) { 28 | testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 | time.Sleep(10 * time.Second) 30 | })) 31 | defer testServer.Close() 32 | 33 | req, err := http.NewRequest("GET", "?target="+testServer.URL, nil) 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | req.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", "1") 39 | 40 | testRecorder := httptest.NewRecorder() 41 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | probeHandler(w, r, log.Nop(), 1, newCache(1, 1)) 43 | }) 44 | 45 | handler.ServeHTTP(testRecorder, req) 46 | 47 | if status := testRecorder.Code; status != http.StatusOK { 48 | t.Errorf("probe handler returned the wrong status code.\nExpected : %v\nGot : %v\n", status, http.StatusOK) 49 | } 50 | } 51 | 52 | func TestGetTimeout(t *testing.T) { 53 | var cases = []struct { 54 | name string 55 | flagTimeout time.Duration 56 | prometheusTimeout string 57 | expectedResult time.Duration 58 | }{ 59 | { 60 | name: "higher_prometheus_timeout", 61 | flagTimeout: 1 * time.Second, 62 | prometheusTimeout: "10", 63 | expectedResult: 1 * time.Second, 64 | }, 65 | { 66 | name: "lower_prometheus_timeout", 67 | flagTimeout: 10 * time.Second, 68 | prometheusTimeout: "1", 69 | expectedResult: 1 * time.Second, 70 | }, 71 | { 72 | name: "empty_prometheus_timeout", 73 | flagTimeout: 1 * time.Second, 74 | prometheusTimeout: "", 75 | expectedResult: 1 * time.Second, 76 | }, 77 | { 78 | name: "wrong_prometheus_timeout", 79 | flagTimeout: 1 * time.Second, 80 | prometheusTimeout: "not-parsable", 81 | expectedResult: 1 * time.Second, 82 | }, 83 | } 84 | 85 | for _, c := range cases { 86 | request, _ := http.NewRequest("", "", nil) 87 | request.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", c.prometheusTimeout) 88 | timeout := getTimeout(request, c.flagTimeout) 89 | if timeout != c.expectedResult { 90 | t.Errorf("Test case : %v failed.\nExpected : %v\nGot : %v\n", c.name, c.expectedResult, timeout) 91 | } 92 | } 93 | } 94 | 95 | func TestCreateLogger(t *testing.T) { 96 | _, err := createLogger("unexpected") 97 | if err == nil { 98 | t.Errorf("logger created with unexpected level") 99 | } 100 | 101 | for _, lvl := range []string{"error", "warn", "info", "debug"} { 102 | _, err := createLogger(lvl) 103 | if err != nil { 104 | t.Errorf("failed to create logger with level : %v", lvl) 105 | } 106 | } 107 | } 108 | 109 | func TestValidateTimeout(t *testing.T) { 110 | var cases = []struct { 111 | name string 112 | flagTimeout string 113 | expectedTimeout time.Duration 114 | expectedError error 115 | }{ 116 | { 117 | name: "un_parsable_duration", 118 | flagTimeout: "not_a_duration", 119 | expectedTimeout: 0, 120 | expectedError: errors.New(`time: invalid duration "not_a_duration"`), 121 | }, 122 | { 123 | name: "less_than_1m", 124 | flagTimeout: "1s", 125 | expectedTimeout: 0, 126 | expectedError: errors.New("probe timeout must be a least 1 minute"), 127 | }, 128 | { 129 | name: "good_value", 130 | flagTimeout: "5m", 131 | expectedTimeout: 5 * time.Minute, 132 | expectedError: nil, 133 | }, 134 | } 135 | 136 | for _, c := range cases { 137 | timeout, err := validateTimeout(c.flagTimeout) 138 | if err != nil && err.Error() != c.expectedError.Error() || err == nil && c.expectedError != nil || timeout != c.expectedTimeout { 139 | t.Errorf("Test case : %v failed.\nExpected : (%v, %v)\nGot : (%v, %v)\n", c.name, c.expectedTimeout, c.expectedError, timeout, err) 140 | } 141 | } 142 | } 143 | --------------------------------------------------------------------------------