├── .circleci └── config.yml ├── .dockerignore ├── .github ├── ISSUE_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── container_description.yml │ ├── golangci-lint.yml │ └── stale.yml ├── .gitignore ├── .golangci.yml ├── .promu.yml ├── .yamllint ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONFIGURATION.md ├── Dockerfile ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── Makefile.common ├── NOTICE ├── README.md ├── SECURITY.md ├── VERSION ├── blackbox.yml ├── config ├── config.go ├── config_test.go └── testdata │ ├── blackbox-bad.yml │ ├── blackbox-bad2.yml │ ├── blackbox-good.yml │ ├── invalid-dns-class.yml │ ├── invalid-dns-module.yml │ ├── invalid-dns-type.yml │ ├── invalid-http-body-config.yml │ ├── invalid-http-body-match-regexp.yml │ ├── invalid-http-body-not-match-regexp.yml │ ├── invalid-http-compression-mismatch-special-case.yml │ ├── invalid-http-compression-mismatch.yml │ ├── invalid-http-header-match-regexp.yml │ ├── invalid-http-header-match.yml │ ├── invalid-http-request-compression-reject-all-encodings.yml │ ├── invalid-icmp-ttl-overflow.yml │ ├── invalid-icmp-ttl.yml │ └── invalid-tcp-query-response-regexp.yml ├── example.yml ├── go.mod ├── go.sum ├── main.go ├── main_test.go └── prober ├── dns.go ├── dns_test.go ├── grpc.go ├── grpc_test.go ├── handler.go ├── handler_test.go ├── history.go ├── history_test.go ├── http.go ├── http_test.go ├── icmp.go ├── prober.go ├── tcp.go ├── tcp_test.go ├── tls.go ├── utils.go └── utils_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | prometheus: prometheus/prometheus@0.17.1 4 | executors: 5 | # Whenever the Go version is updated here, .promu.yml should also be updated. 6 | golang: 7 | docker: 8 | - image: cimg/go:1.24 9 | jobs: 10 | test: 11 | executor: golang 12 | steps: 13 | - prometheus/setup_environment 14 | - run: make 15 | - prometheus/store_artifact: 16 | file: blackbox_exporter 17 | - run: git diff --exit-code 18 | # IPv6 tests require the machine executor. 19 | # See https://circleci.com/docs/2.0/faq/#can-i-use-ipv6-in-my-tests for details. 20 | test-ipv6: 21 | machine: true 22 | working_directory: /home/circleci/.go_workspace/src/github.com/prometheus/blackbox_exporter 23 | # Whenever the Go version is updated here, .promu.yml should also be updated. 24 | environment: 25 | DOCKER_TEST_IMAGE_NAME: quay.io/prometheus/golang-builder:1.24-base 26 | steps: 27 | - checkout 28 | - run: 29 | name: enable ipv6 30 | command: | 31 | cat \<<'EOF' | sudo tee /etc/docker/daemon.json 32 | { 33 | "ipv6": true, 34 | "fixed-cidr-v6": "2001:db8:1::/64" 35 | } 36 | EOF 37 | sudo service docker restart 38 | - run: docker run --rm -t -v "$(pwd):/app" "${DOCKER_TEST_IMAGE_NAME}" -i github.com/prometheus/blackbox_exporter -T 39 | workflows: 40 | version: 2 41 | blackbox_exporter: 42 | jobs: 43 | - test: 44 | filters: 45 | tags: 46 | only: /.*/ 47 | - test-ipv6: 48 | filters: 49 | tags: 50 | only: /.*/ 51 | - prometheus/build: 52 | name: build 53 | filters: 54 | tags: 55 | only: /.*/ 56 | - prometheus/publish_master: 57 | context: org-context 58 | requires: 59 | - test 60 | - test-ipv6 61 | - build 62 | filters: 63 | branches: 64 | only: master 65 | - prometheus/publish_release: 66 | context: org-context 67 | requires: 68 | - test 69 | - test-ipv6 70 | - build 71 | filters: 72 | tags: 73 | only: /^v[0-9]+(\.[0-9]+){2}(-.+|[^-.]*)$/ 74 | branches: 75 | ignore: /.*/ 76 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .build/ 2 | .tarballs/ 3 | 4 | !.build/linux-amd64/ 5 | !.build/linux-armv7 6 | !.build/linux-arm64 7 | !.build/linux-ppc64le 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | <!-- 2 | Please note: GitHub issues should only be used for feature requests and 3 | bug reports. For general discussions, please refer to one of the community channels 4 | described in https://prometheus.io/community/. 5 | 6 | For bug reports, please fill out the below fields and provide as much detail 7 | as possible about your issue. For feature requests, you may omit the 8 | following template. 9 | --> 10 | ### Host operating system: output of `uname -a` 11 | 12 | ### blackbox_exporter version: output of `blackbox_exporter --version` 13 | <!-- If building from source, run `make` first. --> 14 | 15 | ### What is the blackbox.yml module config. 16 | 17 | ### What is the prometheus.yml scrape config. 18 | 19 | ### What logging output did you get from adding `&debug=true` to the probe URL? 20 | 21 | ### What did you do that produced an error? 22 | 23 | ### What did you expect to see? 24 | 25 | ### What did you see instead? 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/container_description.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Push README to Docker Hub 3 | on: 4 | push: 5 | paths: 6 | - "README.md" 7 | - "README-containers.md" 8 | - ".github/workflows/container_description.yml" 9 | branches: [ main, master ] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | PushDockerHubReadme: 16 | runs-on: ubuntu-latest 17 | name: Push README to Docker Hub 18 | if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. 19 | steps: 20 | - name: git checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | with: 23 | persist-credentials: false 24 | - name: Set docker hub repo name 25 | run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV 26 | - name: Push README to Dockerhub 27 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 28 | env: 29 | DOCKER_USER: ${{ secrets.DOCKER_HUB_LOGIN }} 30 | DOCKER_PASS: ${{ secrets.DOCKER_HUB_PASSWORD }} 31 | with: 32 | destination_container_repo: ${{ env.DOCKER_REPO_NAME }} 33 | provider: dockerhub 34 | short_description: ${{ env.DOCKER_REPO_NAME }} 35 | # Empty string results in README-containers.md being pushed if it 36 | # exists. Otherwise, README.md is pushed. 37 | readme_file: '' 38 | 39 | PushQuayIoReadme: 40 | runs-on: ubuntu-latest 41 | name: Push README to quay.io 42 | if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. 43 | steps: 44 | - name: git checkout 45 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 46 | with: 47 | persist-credentials: false 48 | - name: Set quay.io org name 49 | run: echo "DOCKER_REPO=$(echo quay.io/${GITHUB_REPOSITORY_OWNER} | tr -d '-')" >> $GITHUB_ENV 50 | - name: Set quay.io repo name 51 | run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV 52 | - name: Push README to quay.io 53 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 54 | env: 55 | DOCKER_APIKEY: ${{ secrets.QUAY_IO_API_TOKEN }} 56 | with: 57 | destination_container_repo: ${{ env.DOCKER_REPO_NAME }} 58 | provider: quay 59 | # Empty string results in README-containers.md being pushed if it 60 | # exists. Otherwise, README.md is pushed. 61 | readme_file: '' 62 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This action is synced from https://github.com/prometheus/prometheus 3 | name: golangci-lint 4 | on: 5 | push: 6 | paths: 7 | - "go.sum" 8 | - "go.mod" 9 | - "**.go" 10 | - "scripts/errcheck_excludes.txt" 11 | - ".github/workflows/golangci-lint.yml" 12 | - ".golangci.yml" 13 | pull_request: 14 | 15 | permissions: # added using https://github.com/step-security/secure-repo 16 | contents: read 17 | 18 | jobs: 19 | golangci: 20 | permissions: 21 | contents: read # for actions/checkout to fetch code 22 | pull-requests: read # for golangci/golangci-lint-action to fetch pull requests 23 | name: lint 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | - name: Install Go 31 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 32 | with: 33 | go-version: 1.24.x 34 | - name: Install snmp_exporter/generator dependencies 35 | run: sudo apt-get update && sudo apt-get -y install libsnmp-dev 36 | if: github.repository == 'prometheus/snmp_exporter' 37 | - name: Lint 38 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 39 | with: 40 | args: --verbose 41 | version: v2.2.1 42 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale Check 2 | on: 3 | workflow_dispatch: {} 4 | schedule: 5 | - cron: '16 22 * * *' 6 | permissions: 7 | issues: write 8 | pull-requests: write 9 | jobs: 10 | stale: 11 | if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | # opt out of defaults to avoid marking issues as stale and closing them 18 | # https://github.com/actions/stale#days-before-close 19 | # https://github.com/actions/stale#days-before-stale 20 | days-before-stale: -1 21 | days-before-close: -1 22 | # Setting it to empty string to skip comments. 23 | # https://github.com/actions/stale#stale-pr-message 24 | # https://github.com/actions/stale#stale-issue-message 25 | stale-pr-message: '' 26 | stale-issue-message: '' 27 | operations-per-run: 30 28 | # override days-before-stale, for only marking the pull requests as stale 29 | days-before-pr-stale: 60 30 | stale-pr-label: stale 31 | exempt-pr-labels: keepalive 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | dependencies-stamp 24 | /blackbox_exporter 25 | /.build 26 | /.release 27 | /.tarballs 28 | .deps 29 | *.tar.gz 30 | /vendor 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - misspell 6 | - sloglint 7 | - staticcheck 8 | exclusions: 9 | generated: lax 10 | presets: 11 | - comments 12 | - common-false-positives 13 | - legacy 14 | - std-error-handling 15 | paths: 16 | - third_party$ 17 | - builtin$ 18 | - examples$ 19 | formatters: 20 | exclusions: 21 | generated: lax 22 | paths: 23 | - third_party$ 24 | - builtin$ 25 | - examples$ 26 | -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | go: 2 | # Whenever the Go version is updated here, .circle/config.yml should also be updated. 3 | version: 1.24 4 | repository: 5 | path: github.com/prometheus/blackbox_exporter 6 | build: 7 | ldflags: | 8 | -X github.com/prometheus/common/version.Version={{.Version}} 9 | -X github.com/prometheus/common/version.Revision={{.Revision}} 10 | -X github.com/prometheus/common/version.Branch={{.Branch}} 11 | -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} 12 | -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} 13 | tarball: 14 | files: 15 | - blackbox.yml 16 | - LICENSE 17 | - NOTICE 18 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | ignore: | 4 | **/node_modules 5 | 6 | rules: 7 | braces: 8 | max-spaces-inside: 1 9 | level: error 10 | brackets: 11 | max-spaces-inside: 1 12 | level: error 13 | commas: disable 14 | comments: disable 15 | comments-indentation: disable 16 | document-start: disable 17 | indentation: 18 | spaces: consistent 19 | indent-sequences: consistent 20 | key-duplicates: 21 | ignore: | 22 | config/testdata/section_key_dup.bad.yml 23 | line-length: disable 24 | truthy: 25 | check-keys: false 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master / unreleased 2 | 3 | BREAKING CHANGES: 4 | 5 | Changes: 6 | 7 | * [CHANGE] 8 | * [FEATURE] 9 | * [ENHANCEMENT] 10 | * [BUGFIX] 11 | 12 | ## 0.27.0 / 2025-06-26 13 | 14 | * [FEATURE] Support matching JSON body with CEL expressions #1255 15 | * [BUGFIX] Fix condition when local dns lookup should happen #1272 16 | * [BUGFIX] Stop scrape logger spam #1381 17 | 18 | ## 0.26.0 / 2025-02-26 19 | 20 | * [CHANGE] adopt log/slog, drop go-kit/log #1311 21 | * [FEATURE] Add metric to record tls ciphersuite negotiated during handshake #1203 22 | * [FEATURE] Add a way to export labels with content matched by the probe #1284 23 | * [FEATURE] Reports Certificate Serial number #1333 24 | * [ENHANCEMENT] Enable misspell linter #1248 25 | * [ENHANCEMENT] Fix incorrect parameters name in documentation #1126 26 | * [ENHANCEMENT] Add stale workflow to start sync with stale.yaml in prometheus #1170 27 | * [ENHANCEMENT] Update CONFIGURATION.md to clarify that valid_status_codes expects a list #1335 28 | * [ENHANCEMENT] Skip failing IPv6 tests in test CI Pipeline #1342 29 | * [ENHANCEMENT] Add RabbitMQ probe example #1349 30 | * [BUGFIX] Only register grpc TLS metrics on successful handshake #1338 31 | 32 | ## 0.25.0 / 2024-04-09 33 | 34 | * [FEATURE] Allow to get Probe logs by target #1063 35 | * [FEATURE] Log errors from probe #1091 36 | * [BUGFIX] Prevent logging confusing error message #1059 37 | * [BUGFIX] Explicit registration of internal exporter metrics 1060 38 | 39 | ## 0.24.0 / 2023-05-16 40 | 41 | * [CHANGE] Make Proxy Connect Headers consistent with Prometheus #1008 42 | * [FEATURE] Add hostname parameter for TCP probe #981 43 | * [FEATURE] Add support for HTTP request body as file #987 44 | 45 | ## 0.23.0 / 2022-12-02 46 | 47 | * [SECURITY] Update Exporter Toolkit (CVE-2022-46146) #979 48 | * [FEATURE] Support multiple Listen Addresses and systemd socket activation #979 49 | * [FEATURE] Add leaf certificate details in a new `probe_ssl_last_chain_info` metric. #943 50 | * [FEATURE] DNS: Add `Add probe_dns_query_succeeded` metric. #990 51 | 52 | ## 0.22.0 / 2022-08-02 53 | 54 | * [FEATURE] HTTP: Add `skip_resolve_phase_with_proxy` option. #944 55 | * [ENHANCEMENT] OAuth: Use Blackbox Exporter user agent when doing OAuth2 56 | authenticated requests. #948 57 | * [ENHANCEMENT] Print usage and help to stdout instead of stderr. #928 58 | 59 | 60 | ## 0.21.1 / 2022-06-17 61 | 62 | * [BUGFIX] Fix a data race in HTTP probes. #929 63 | 64 | ## 0.21.0 / 2022-05-30 65 | 66 | This Prometheus release is built with go1.18, which contains two noticeable 67 | changes related to TLS and HTTP: 68 | 69 | 1. [TLS 1.0 and 1.1 disabled by default client-side](https://go.dev/doc/go1.18#tls10). 70 | Blackbox Exporter users can override this with the `min_version` parameter of 71 | [tls_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#tls_config). 72 | 2. [Certificates signed with the SHA-1 hash function are rejected](https://go.dev/doc/go1.18#sha1). 73 | This doesn't apply to self-signed root certificates. 74 | 75 | * [BUGFIX] Prevent setting negative timeouts when using a small scrape interval. #869 76 | 77 | ## 0.20.0 / 2022-03-16 78 | 79 | * [FEATURE] Add support for grpc health check. #835 80 | * [FEATURE] Add hostname parameter. #823 81 | * [ENHANCEMENT] Add body_size_limit option to http module. #836 82 | * [ENHANCEMENT] Change default user agent. #557 83 | * [ENHANCEMENT] Add control of recursion desired flag for DNS probes. #859 84 | * [ENHANCEMENT] Delay init of http phase values. #865 85 | * [BUGFIX] Fix IP hash. #863 86 | 87 | ## 0.19.0 / 2021-05-10 88 | 89 | In the HTTP probe, `no_follow_redirects` has been changed to `follow_redirects`. 90 | This release accepts both, with a precedence to the `no_follow_redirects` parameter. 91 | In the next release, `no_follow_redirects` will be removed. 92 | 93 | * [CHANGE] HTTP probe: `no_follow_redirects` has been renamed to `follow_redirects`. #784 94 | * [FEATURE] Add support for decompression of HTTP responses. #764 95 | * [FEATURE] Enable TLS and basic authentication. #730 96 | * [FEATURE] HTTP probe: *experimental* OAuth2 support. #784 97 | * [ENHANCEMENT] Add a health endpoint. #752 98 | * [ENHANCEMENT] Add a metric for unknown probes. #716 99 | * [ENHANCEMENT] Use preferred protocol first when resolving hostname. #728 100 | * [ENHANCEMENT] Validating the configuration tries to compile regexes. #729 101 | * [BUGFIX] HTTP probe: Fix error checking. #723 102 | * [BUGFIX] HTTP probe: Fix how the TLS phase is calculated. #758 103 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Prometheus Community Code of Conduct 2 | 3 | Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /CONFIGURATION.md: -------------------------------------------------------------------------------- 1 | # Blackbox exporter configuration 2 | 3 | The file is written in [YAML format](http://en.wikipedia.org/wiki/YAML), defined by the scheme described below. 4 | Brackets indicate that a parameter is optional. 5 | For non-list parameters the value is set to the specified default. 6 | 7 | Generic placeholders are defined as follows: 8 | 9 | * `<boolean>`: a boolean that can take the values `true` or `false` 10 | * `<int>`: a regular integer 11 | * `<duration>`: a duration matching the regular expression `[0-9]+(ms|[smhdwy])` 12 | * `<filename>`: a valid path in the current working directory 13 | * `<string>`: a regular string 14 | * `<secret>`: a regular string that is a secret, such as a password 15 | * `<regex>`: a regular expression 16 | 17 | The other placeholders are specified separately. 18 | 19 | See [example.yml](example.yml) for configuration examples. 20 | 21 | ```yml 22 | 23 | modules: 24 | [ <string>: <module> ... ] 25 | 26 | ``` 27 | 28 | 29 | ### `<module>` 30 | ```yml 31 | 32 | # The protocol over which the probe will take place (http, tcp, dns, icmp, grpc). 33 | prober: <prober_string> 34 | 35 | # How long the probe will wait before giving up. 36 | [ timeout: <duration> ] 37 | 38 | # The specific probe configuration - at most one of these should be specified. 39 | [ http: <http_probe> ] 40 | [ tcp: <tcp_probe> ] 41 | [ dns: <dns_probe> ] 42 | [ icmp: <icmp_probe> ] 43 | [ grpc: <grpc_probe> ] 44 | 45 | ``` 46 | 47 | ### `<http_probe>` 48 | ```yml 49 | 50 | # Accepted status codes for this probe. List between square brackets. Defaults to 2xx. 51 | [ valid_status_codes: [<int>, ...] | default = 2xx ] 52 | 53 | # Accepted HTTP versions for this probe. 54 | [ valid_http_versions: <string>, ... ] 55 | 56 | # The HTTP method the probe will use. 57 | [ method: <string> | default = "GET" ] 58 | 59 | # The HTTP headers set for the probe. 60 | headers: 61 | [ <string>: <string> ... ] 62 | 63 | # The maximum uncompressed body length in bytes that will be processed. A value of 0 means no limit. 64 | # 65 | # If the response includes a Content-Length header, it is NOT validated against this value. This 66 | # setting is only meant to limit the amount of data that you are willing to read from the server. 67 | # 68 | # Example: 10MB 69 | [ body_size_limit: <size> | default = 0 ] 70 | 71 | # The compression algorithm to use to decompress the response (gzip, br, deflate, identity). 72 | # 73 | # If an "Accept-Encoding" header is specified, it MUST be such that the compression algorithm 74 | # indicated using this option is acceptable. For example, you can use `compression: gzip` and 75 | # `Accept-Encoding: br, gzip` or `Accept-Encoding: br;q=1.0, gzip;q=0.9`. The fact that gzip is 76 | # acceptable with a lower quality than br does not invalidate the configuration, as you might 77 | # be testing that the server does not return br-encoded content even if it's requested. On the 78 | # other hand, `compression: gzip` and `Accept-Encoding: br, identity` is NOT a valid 79 | # configuration, because you are asking for gzip to NOT be returned, and trying to decompress 80 | # whatever the server returns is likely going to fail. 81 | [ compression: <string> | default = "" ] 82 | 83 | # Whether or not the probe will follow any redirects. 84 | [ follow_redirects: <boolean> | default = true ] 85 | 86 | # Probe fails if SSL is present. 87 | [ fail_if_ssl: <boolean> | default = false ] 88 | 89 | # Probe fails if SSL is not present. 90 | [ fail_if_not_ssl: <boolean> | default = false ] 91 | 92 | # Probe fails if response body JSON matches the CEL expression or if response is not JSON. See: https://github.com/google/cel-spec: 93 | fail_if_body_json_matches_cel: <string> 94 | 95 | # Probe fails if response body JSON does not match CEL expression or if response is not JSON. See: https://github.com/google/cel-spec: 96 | fail_if_body_json_not_matches_cel: <string> 97 | 98 | # Probe fails if response body matches regex. 99 | fail_if_body_matches_regexp: 100 | [ - <regex>, ... ] 101 | 102 | # Probe fails if response body does not match regex. 103 | fail_if_body_not_matches_regexp: 104 | [ - <regex>, ... ] 105 | 106 | # Probe fails if response header matches regex. For headers with multiple values, fails if *at least one* matches. 107 | fail_if_header_matches: 108 | [ - <http_header_match_spec>, ... ] 109 | 110 | # Probe fails if response header does not match regex. For headers with multiple values, fails if *none* match. 111 | fail_if_header_not_matches: 112 | [ - <http_header_match_spec>, ... ] 113 | 114 | # Configuration for TLS protocol of HTTP probe. 115 | tls_config: 116 | [ <tls_config> ] 117 | 118 | # The HTTP basic authentication credentials. 119 | basic_auth: 120 | [ username: <string> ] 121 | [ password: <secret> ] 122 | [ password_file: <filename> ] 123 | 124 | # Sets the `Authorization` header on every request with 125 | # the configured credentials. 126 | authorization: 127 | # Sets the authentication type of the request. 128 | [ type: <string> | default: Bearer ] 129 | # Sets the credentials of the request. It is mutually exclusive with 130 | # `credentials_file`. 131 | [ credentials: <secret> ] 132 | # Sets the credentials of the request with the credentials read from the 133 | # configured file. It is mutually exclusive with `credentials`. 134 | [ credentials_file: <filename> ] 135 | 136 | # HTTP proxy server to use to connect to the targets. 137 | [ proxy_url: <string> ] 138 | # Comma-separated string that can contain IPs, CIDR notation, domain names 139 | # that should be excluded from proxying. IP and domain names can 140 | # contain port numbers. 141 | [ no_proxy: <string> ] 142 | # Use proxy URL indicated by environment variables (HTTP_PROXY, https_proxy, HTTPs_PROXY, https_proxy, and no_proxy) 143 | [ proxy_from_environment: <bool> | default: false ] 144 | # Specifies headers to send to proxies during CONNECT requests. 145 | [ proxy_connect_header: 146 | [ <string>: [<secret>, ...] ] ] 147 | 148 | # Skip DNS resolution and URL change when an HTTP proxy (proxy_url or proxy_from_environment) is set. 149 | [ skip_resolve_phase_with_proxy: <boolean> | default = false ] 150 | 151 | # OAuth 2.0 configuration to use to connect to the targets. 152 | oauth2: 153 | [ <oauth2> ] 154 | 155 | # Whether to enable HTTP2. 156 | [ enable_http2: <bool> | default: true ] 157 | 158 | # The IP protocol of the HTTP probe (ip4, ip6). 159 | [ preferred_ip_protocol: <string> | default = "ip6" ] 160 | [ ip_protocol_fallback: <boolean> | default = true ] 161 | 162 | # The body of the HTTP request used in probe. 163 | [ body: <string> ] 164 | 165 | # Read the HTTP request body from from a file. 166 | # It is mutually exclusive with `body`. 167 | [ body_file: <filename> ] 168 | 169 | ``` 170 | 171 | #### `<http_header_match_spec>` 172 | 173 | ```yml 174 | header: <string>, 175 | regexp: <regex>, 176 | [ allow_missing: <boolean> | default = false ] 177 | ``` 178 | 179 | ### `<tcp_probe>` 180 | 181 | ```yml 182 | 183 | # The IP protocol of the TCP probe (ip4, ip6). 184 | [ preferred_ip_protocol: <string> | default = "ip6" ] 185 | [ ip_protocol_fallback: <boolean | default = true> ] 186 | 187 | # The source IP address. 188 | [ source_ip_address: <string> ] 189 | 190 | # The query sent in the TCP probe and the expected associated response. 191 | # "expect" matches a regular expression; 192 | # "labels" can define labels which will be exported on metric "probe_expect_info"; 193 | # "send" sends some content; 194 | # "send" and "labels.value" can contain values matched by "expect" (such as "${1}"); 195 | # "starttls" upgrades TCP connection to TLS. 196 | query_response: 197 | [ - [ [ expect: <string> ], 198 | [ labels: 199 | - [ name: <string> 200 | value: <string> 201 | ], ... 202 | ], 203 | [ send: <string> ], 204 | [ starttls: <boolean | default = false> ] 205 | ], ... 206 | ] 207 | 208 | # Whether or not TLS is used when the connection is initiated. 209 | [ tls: <boolean | default = false> ] 210 | 211 | # Configuration for TLS protocol of TCP probe. 212 | tls_config: 213 | [ <tls_config> ] 214 | 215 | ``` 216 | 217 | ### `<dns_probe>` 218 | 219 | ```yml 220 | 221 | # The IP protocol of the DNS probe (ip4, ip6). 222 | [ preferred_ip_protocol: <string> | default = "ip6" ] 223 | [ ip_protocol_fallback: <boolean | default = true> ] 224 | 225 | # The source IP address. 226 | [ source_ip_address: <string> ] 227 | 228 | [ transport_protocol: <string> | default = "udp" ] # udp, tcp 229 | 230 | # Whether to use DNS over TLS. This only works with TCP. 231 | [ dns_over_tls: <boolean | default = false> ] 232 | 233 | # Configuration for TLS protocol of DNS over TLS probe. 234 | tls_config: 235 | [ <tls_config> ] 236 | 237 | query_name: <string> 238 | 239 | [ query_type: <string> | default = "ANY" ] 240 | [ query_class: <string> | default = "IN" ] 241 | 242 | # Set the recursion desired (RD) flag in the request. 243 | [ recursion_desired: <boolean> | default = true ] 244 | 245 | # List of valid response codes. 246 | valid_rcodes: 247 | [ - <string> ... | default = "NOERROR" ] 248 | 249 | validate_answer_rrs: 250 | 251 | fail_if_matches_regexp: 252 | [ - <regex>, ... ] 253 | 254 | fail_if_all_match_regexp: 255 | [ - <regex>, ... ] 256 | 257 | fail_if_not_matches_regexp: 258 | [ - <regex>, ... ] 259 | 260 | fail_if_none_matches_regexp: 261 | [ - <regex>, ... ] 262 | 263 | validate_authority_rrs: 264 | 265 | fail_if_matches_regexp: 266 | [ - <regex>, ... ] 267 | 268 | fail_if_all_match_regexp: 269 | [ - <regex>, ... ] 270 | 271 | fail_if_not_matches_regexp: 272 | [ - <regex>, ... ] 273 | 274 | fail_if_none_matches_regexp: 275 | [ - <regex>, ... ] 276 | 277 | validate_additional_rrs: 278 | 279 | fail_if_matches_regexp: 280 | [ - <regex>, ... ] 281 | 282 | fail_if_all_match_regexp: 283 | [ - <regex>, ... ] 284 | 285 | fail_if_not_matches_regexp: 286 | [ - <regex>, ... ] 287 | 288 | fail_if_none_matches_regexp: 289 | [ - <regex>, ... ] 290 | 291 | ``` 292 | 293 | ### `<icmp_probe>` 294 | 295 | ```yml 296 | 297 | # The IP protocol of the ICMP probe (ip4, ip6). 298 | [ preferred_ip_protocol: <string> | default = "ip6" ] 299 | [ ip_protocol_fallback: <boolean | default = true> ] 300 | 301 | # The source IP address. 302 | [ source_ip_address: <string> ] 303 | 304 | # Set the DF-bit in the IP-header. Only works with ip4, on *nix systems and 305 | # requires raw sockets (i.e. root or CAP_NET_RAW on Linux). 306 | [ dont_fragment: <boolean> | default = false ] 307 | 308 | # The size of the payload. 309 | [ payload_size: <int> ] 310 | 311 | # TTL of outbound packets. Value must be in the range [0, 255]. Can be used 312 | # to test reachability of a target within a given number of hops, for example, 313 | # to determine when network routing has changed. 314 | [ ttl: <int> ] 315 | 316 | ``` 317 | 318 | ### `<grpc_probe>` 319 | 320 | ```yml 321 | # The service name to query for health status. 322 | [ service: <string> ] 323 | 324 | # The IP protocol of the gRPC probe (ip4, ip6). 325 | [ preferred_ip_protocol: <string> ] 326 | [ ip_protocol_fallback: <boolean> | default = true ] 327 | 328 | # Whether to connect to the endpoint with TLS. 329 | [ tls: <boolean | default = false> ] 330 | 331 | # Configuration for TLS protocol of gRPC probe. 332 | tls_config: 333 | [ <tls_config> ] 334 | ``` 335 | 336 | ### `<tls_config>` 337 | 338 | ```yml 339 | 340 | # Disable target certificate validation. 341 | [ insecure_skip_verify: <boolean> | default = false ] 342 | 343 | # The CA cert to use for the targets. 344 | [ ca_file: <filename> ] 345 | 346 | # The client cert file for the targets. 347 | [ cert_file: <filename> ] 348 | 349 | # The client key file for the targets. 350 | [ key_file: <filename> ] 351 | 352 | # Used to verify the hostname for the targets. 353 | [ server_name: <string> ] 354 | 355 | # Minimum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 356 | # 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3). 357 | # If unset, Prometheus will use Go default minimum version, which is TLS 1.2. 358 | # See MinVersion in https://pkg.go.dev/crypto/tls#Config. 359 | [ min_version: <string> ] 360 | 361 | # Maximum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS 362 | # 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3). 363 | # Can be used to test for the presence of insecure TLS versions. 364 | # If unset, Prometheus will use Go default maximum version, which is TLS 1.3. 365 | # See MaxVersion in https://pkg.go.dev/crypto/tls#Config. 366 | [ max_version: <string> ] 367 | ``` 368 | 369 | #### `<oauth2>` 370 | 371 | OAuth 2.0 authentication using the client credentials grant type. Blackbox 372 | exporter fetches an access token from the specified endpoint with the given 373 | client access and secret keys. 374 | 375 | NOTE: This is *experimental* in the blackbox exporter and might not be 376 | reflected properly in the probe metrics at the moment. 377 | 378 | ```yml 379 | client_id: <string> 380 | [ client_secret: <secret> ] 381 | 382 | # Read the client secret from a file. 383 | # It is mutually exclusive with `client_secret`. 384 | [ client_secret_file: <filename> ] 385 | 386 | # Scopes for the token request. 387 | scopes: 388 | [ - <string> ... ] 389 | 390 | # The URL to fetch the token from. 391 | token_url: <string> 392 | 393 | # Optional parameters to append to the token URL. 394 | endpoint_params: 395 | [ <string>: <string> ... ] 396 | ``` 397 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH="amd64" 2 | ARG OS="linux" 3 | FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest 4 | LABEL maintainer="The Prometheus Authors <prometheus-developers@googlegroups.com>" 5 | 6 | ARG ARCH="amd64" 7 | ARG OS="linux" 8 | COPY .build/${OS}-${ARCH}/blackbox_exporter /bin/blackbox_exporter 9 | COPY blackbox.yml /etc/blackbox_exporter/config.yml 10 | 11 | EXPOSE 9115 12 | ENTRYPOINT [ "/bin/blackbox_exporter" ] 13 | CMD [ "--config.file=/etc/blackbox_exporter/config.yml" ] 14 | -------------------------------------------------------------------------------- /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. 202 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | * Julien Pivotto <roidelapluie@prometheus.io> @roidelapluie 2 | * Marcelo Magallon <marcelo.magallon@grafana.com> @mem 3 | * Suraj Nath <suraj.sidh@grafana.com> @electron0zero 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2016 The Prometheus Authors 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | # Needs to be defined before including Makefile.common to auto-generate targets 15 | DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le 16 | 17 | include Makefile.common 18 | 19 | DOCKER_IMAGE_NAME ?= blackbox-exporter 20 | -------------------------------------------------------------------------------- /Makefile.common: -------------------------------------------------------------------------------- 1 | # Copyright 2018 The Prometheus Authors 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | 14 | 15 | # A common Makefile that includes rules to be reused in different prometheus projects. 16 | # !!! Open PRs only against the prometheus/prometheus/Makefile.common repository! 17 | 18 | # Example usage : 19 | # Create the main Makefile in the root project directory. 20 | # include Makefile.common 21 | # customTarget: 22 | # @echo ">> Running customTarget" 23 | # 24 | 25 | # Ensure GOBIN is not set during build so that promu is installed to the correct path 26 | unexport GOBIN 27 | 28 | GO ?= go 29 | GOFMT ?= $(GO)fmt 30 | FIRST_GOPATH := $(firstword $(subst :, ,$(shell $(GO) env GOPATH))) 31 | GOOPTS ?= 32 | GOHOSTOS ?= $(shell $(GO) env GOHOSTOS) 33 | GOHOSTARCH ?= $(shell $(GO) env GOHOSTARCH) 34 | 35 | GO_VERSION ?= $(shell $(GO) version) 36 | GO_VERSION_NUMBER ?= $(word 3, $(GO_VERSION)) 37 | PRE_GO_111 ?= $(shell echo $(GO_VERSION_NUMBER) | grep -E 'go1\.(10|[0-9])\.') 38 | 39 | PROMU := $(FIRST_GOPATH)/bin/promu 40 | pkgs = ./... 41 | 42 | ifeq (arm, $(GOHOSTARCH)) 43 | GOHOSTARM ?= $(shell GOARM= $(GO) env GOARM) 44 | GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH)v$(GOHOSTARM) 45 | else 46 | GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH) 47 | endif 48 | 49 | GOTEST := $(GO) test 50 | GOTEST_DIR := 51 | ifneq ($(CIRCLE_JOB),) 52 | ifneq ($(shell command -v gotestsum 2> /dev/null),) 53 | GOTEST_DIR := test-results 54 | GOTEST := gotestsum --junitfile $(GOTEST_DIR)/unit-tests.xml -- 55 | endif 56 | endif 57 | 58 | PROMU_VERSION ?= 0.17.0 59 | PROMU_URL := https://github.com/prometheus/promu/releases/download/v$(PROMU_VERSION)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM).tar.gz 60 | 61 | SKIP_GOLANGCI_LINT := 62 | GOLANGCI_LINT := 63 | GOLANGCI_LINT_OPTS ?= 64 | GOLANGCI_LINT_VERSION ?= v2.2.1 65 | GOLANGCI_FMT_OPTS ?= 66 | # golangci-lint only supports linux, darwin and windows platforms on i386/amd64/arm64. 67 | # windows isn't included here because of the path separator being different. 68 | ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux darwin)) 69 | ifeq ($(GOHOSTARCH),$(filter $(GOHOSTARCH),amd64 i386 arm64)) 70 | # If we're in CI and there is an Actions file, that means the linter 71 | # is being run in Actions, so we don't need to run it here. 72 | ifneq (,$(SKIP_GOLANGCI_LINT)) 73 | GOLANGCI_LINT := 74 | else ifeq (,$(CIRCLE_JOB)) 75 | GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint 76 | else ifeq (,$(wildcard .github/workflows/golangci-lint.yml)) 77 | GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint 78 | endif 79 | endif 80 | endif 81 | 82 | PREFIX ?= $(shell pwd) 83 | BIN_DIR ?= $(shell pwd) 84 | DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) 85 | DOCKERFILE_PATH ?= ./Dockerfile 86 | DOCKERBUILD_CONTEXT ?= ./ 87 | DOCKER_REPO ?= prom 88 | 89 | DOCKER_ARCHS ?= amd64 90 | 91 | BUILD_DOCKER_ARCHS = $(addprefix common-docker-,$(DOCKER_ARCHS)) 92 | PUBLISH_DOCKER_ARCHS = $(addprefix common-docker-publish-,$(DOCKER_ARCHS)) 93 | TAG_DOCKER_ARCHS = $(addprefix common-docker-tag-latest-,$(DOCKER_ARCHS)) 94 | 95 | SANITIZED_DOCKER_IMAGE_TAG := $(subst +,-,$(DOCKER_IMAGE_TAG)) 96 | 97 | ifeq ($(GOHOSTARCH),amd64) 98 | ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux freebsd darwin windows)) 99 | # Only supported on amd64 100 | test-flags := -race 101 | endif 102 | endif 103 | 104 | # This rule is used to forward a target like "build" to "common-build". This 105 | # allows a new "build" target to be defined in a Makefile which includes this 106 | # one and override "common-build" without override warnings. 107 | %: common-% ; 108 | 109 | .PHONY: common-all 110 | common-all: precheck style check_license lint yamllint unused build test 111 | 112 | .PHONY: common-style 113 | common-style: 114 | @echo ">> checking code style" 115 | @fmtRes=$($(GOFMT) -d $(find . -path ./vendor -prune -o -name '*.go' -print)); \ 116 | if [ -n "${fmtRes}" ]; then \ 117 | echo "gofmt checking failed!"; echo "${fmtRes}"; echo; \ 118 | echo "Please ensure you are using $($(GO) version) for formatting code."; \ 119 | exit 1; \ 120 | fi 121 | 122 | .PHONY: common-check_license 123 | common-check_license: 124 | @echo ">> checking license header" 125 | @licRes=$(for file in $(find . -type f -iname '*.go' ! -path './vendor/*') ; do \ 126 | awk 'NR<=3' $file | grep -Eq "(Copyright|generated|GENERATED)" || echo $file; \ 127 | done); \ 128 | if [ -n "${licRes}" ]; then \ 129 | echo "license header checking failed:"; echo "${licRes}"; \ 130 | exit 1; \ 131 | fi 132 | 133 | .PHONY: common-deps 134 | common-deps: 135 | @echo ">> getting dependencies" 136 | $(GO) mod download 137 | 138 | .PHONY: update-go-deps 139 | update-go-deps: 140 | @echo ">> updating Go dependencies" 141 | @for m in $($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ 142 | $(GO) get $m; \ 143 | done 144 | $(GO) mod tidy 145 | 146 | .PHONY: common-test-short 147 | common-test-short: $(GOTEST_DIR) 148 | @echo ">> running short tests" 149 | $(GOTEST) -short $(GOOPTS) $(pkgs) 150 | 151 | .PHONY: common-test 152 | common-test: $(GOTEST_DIR) 153 | @echo ">> running all tests" 154 | $(GOTEST) $(test-flags) $(GOOPTS) $(pkgs) 155 | 156 | $(GOTEST_DIR): 157 | @mkdir -p $@ 158 | 159 | .PHONY: common-format 160 | common-format: $(GOLANGCI_LINT) 161 | @echo ">> formatting code" 162 | $(GO) fmt $(pkgs) 163 | ifdef GOLANGCI_LINT 164 | @echo ">> formatting code with golangci-lint" 165 | $(GOLANGCI_LINT) fmt $(GOLANGCI_FMT_OPTS) 166 | endif 167 | 168 | .PHONY: common-vet 169 | common-vet: 170 | @echo ">> vetting code" 171 | $(GO) vet $(GOOPTS) $(pkgs) 172 | 173 | .PHONY: common-lint 174 | common-lint: $(GOLANGCI_LINT) 175 | ifdef GOLANGCI_LINT 176 | @echo ">> running golangci-lint" 177 | $(GOLANGCI_LINT) run $(GOLANGCI_LINT_OPTS) $(pkgs) 178 | endif 179 | 180 | .PHONY: common-lint-fix 181 | common-lint-fix: $(GOLANGCI_LINT) 182 | ifdef GOLANGCI_LINT 183 | @echo ">> running golangci-lint fix" 184 | $(GOLANGCI_LINT) run --fix $(GOLANGCI_LINT_OPTS) $(pkgs) 185 | endif 186 | 187 | .PHONY: common-yamllint 188 | common-yamllint: 189 | @echo ">> running yamllint on all YAML files in the repository" 190 | ifeq (, $(shell command -v yamllint 2> /dev/null)) 191 | @echo "yamllint not installed so skipping" 192 | else 193 | yamllint . 194 | endif 195 | 196 | # For backward-compatibility. 197 | .PHONY: common-staticcheck 198 | common-staticcheck: lint 199 | 200 | .PHONY: common-unused 201 | common-unused: 202 | @echo ">> running check for unused/missing packages in go.mod" 203 | $(GO) mod tidy 204 | @git diff --exit-code -- go.sum go.mod 205 | 206 | .PHONY: common-build 207 | common-build: promu 208 | @echo ">> building binaries" 209 | $(PROMU) build --prefix $(PREFIX) $(PROMU_BINARIES) 210 | 211 | .PHONY: common-tarball 212 | common-tarball: promu 213 | @echo ">> building release tarball" 214 | $(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) 215 | 216 | .PHONY: common-docker-repo-name 217 | common-docker-repo-name: 218 | @echo "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)" 219 | 220 | .PHONY: common-docker $(BUILD_DOCKER_ARCHS) 221 | common-docker: $(BUILD_DOCKER_ARCHS) 222 | $(BUILD_DOCKER_ARCHS): common-docker-%: 223 | docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ 224 | -f $(DOCKERFILE_PATH) \ 225 | --build-arg ARCH="$*" \ 226 | --build-arg OS="linux" \ 227 | $(DOCKERBUILD_CONTEXT) 228 | 229 | .PHONY: common-docker-publish $(PUBLISH_DOCKER_ARCHS) 230 | common-docker-publish: $(PUBLISH_DOCKER_ARCHS) 231 | $(PUBLISH_DOCKER_ARCHS): common-docker-publish-%: 232 | docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" 233 | 234 | DOCKER_MAJOR_VERSION_TAG = $(firstword $(subst ., ,$(shell cat VERSION))) 235 | .PHONY: common-docker-tag-latest $(TAG_DOCKER_ARCHS) 236 | common-docker-tag-latest: $(TAG_DOCKER_ARCHS) 237 | $(TAG_DOCKER_ARCHS): common-docker-tag-latest-%: 238 | docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest" 239 | docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)" 240 | 241 | .PHONY: common-docker-manifest 242 | common-docker-manifest: 243 | DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):$(SANITIZED_DOCKER_IMAGE_TAG)) 244 | DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" 245 | 246 | .PHONY: promu 247 | promu: $(PROMU) 248 | 249 | $(PROMU): 250 | $(eval PROMU_TMP := $(shell mktemp -d)) 251 | curl -s -L $(PROMU_URL) | tar -xvzf - -C $(PROMU_TMP) 252 | mkdir -p $(FIRST_GOPATH)/bin 253 | cp $(PROMU_TMP)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM)/promu $(FIRST_GOPATH)/bin/promu 254 | rm -r $(PROMU_TMP) 255 | 256 | .PHONY: common-proto 257 | common-proto: 258 | @echo ">> generating code from proto files" 259 | @./scripts/genproto.sh 260 | 261 | ifdef GOLANGCI_LINT 262 | $(GOLANGCI_LINT): 263 | mkdir -p $(FIRST_GOPATH)/bin 264 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh \ 265 | | sed -e '/install -d/d' \ 266 | | sh -s -- -b $(FIRST_GOPATH)/bin $(GOLANGCI_LINT_VERSION) 267 | endif 268 | 269 | .PHONY: precheck 270 | precheck:: 271 | 272 | define PRECHECK_COMMAND_template = 273 | precheck:: $(1)_precheck 274 | 275 | PRECHECK_COMMAND_$(1) ?= $(1) $(strip $(PRECHECK_OPTIONS_$(1))) 276 | .PHONY: $(1)_precheck 277 | $(1)_precheck: 278 | @if ! $(PRECHECK_COMMAND_$(1)) 1>/dev/null 2>&1; then \ 279 | echo "Execution of '$(PRECHECK_COMMAND_$(1))' command failed. Is $(1) installed?"; \ 280 | exit 1; \ 281 | fi 282 | endef 283 | 284 | govulncheck: install-govulncheck 285 | govulncheck ./... 286 | 287 | install-govulncheck: 288 | command -v govulncheck > /dev/null || go install golang.org/x/vuln/cmd/govulncheck@latest 289 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | The Blackbox exporter for blackbox probing metrics 2 | Copyright 2012-2016 The Prometheus Authors 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blackbox exporter 2 | 3 | [][circleci] 4 | [][quay] 5 | [][hub] 6 | 7 | The blackbox exporter allows blackbox probing of endpoints over 8 | HTTP, HTTPS, DNS, TCP, ICMP and gRPC. 9 | 10 | ## Running this software 11 | 12 | ### From binaries 13 | 14 | Download the most suitable binary from [the releases tab](https://github.com/prometheus/blackbox_exporter/releases) 15 | 16 | Then: 17 | 18 | ./blackbox_exporter <flags> 19 | 20 | 21 | ### Using the docker image 22 | 23 | *Note: You may want to [enable ipv6 in your docker configuration](https://docs.docker.com/v17.09/engine/userguide/networking/default_network/ipv6/)* 24 | 25 | docker run --rm \ 26 | -p 9115/tcp \ 27 | --name blackbox_exporter \ 28 | -v $(pwd):/config \ 29 | quay.io/prometheus/blackbox-exporter:latest --config.file=/config/blackbox.yml 30 | 31 | ### Checking the results 32 | 33 | Visiting [http://localhost:9115/probe?target=google.com&module=http_2xx](http://localhost:9115/probe?target=google.com&module=http_2xx) 34 | will return metrics for a HTTP probe against google.com. The `probe_success` 35 | metric indicates if the probe succeeded. Adding a `debug=true` parameter 36 | will return debug information for that probe. 37 | 38 | Metrics concerning the operation of the exporter itself are available at the 39 | endpoint <http://localhost:9115/metrics>. 40 | 41 | ### TLS and basic authentication 42 | 43 | The Blackbox Exporter supports TLS and basic authentication. This enables better 44 | control of the various HTTP endpoints. 45 | 46 | To use TLS and/or basic authentication, you need to pass a configuration file 47 | using the `--web.config.file` parameter. The format of the file is described 48 | [in the exporter-toolkit repository](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md). 49 | 50 | Note that the TLS and basic authentication settings affect all HTTP endpoints: 51 | /metrics for scraping, /probe for probing, and the web UI. 52 | 53 | ### Controlling log level for probe logs 54 | 55 | It is possible to control the level at which probe logs related to a scrape are output as. 56 | 57 | Probe logs default to `debug` level, and can be controlled by the `--log.prober` flag. 58 | This means that probe scrape logs will not be output unless the level configured for the probe logger via `--log.prober` is >= the level configured for the blackbox_exporter via `--log.level`. 59 | 60 | Sample output demonstrating the use and effect of these flags can be seen below. 61 | 62 | > _Note_ 63 | > 64 | > All log samples below used the following basic `blackbox.yml` configuration file and contain the probe logs of a single scrape generated by `curl` 65 | 66 | ```bash 67 | # blackbox.yml 68 | modules: 69 | http_2xx: 70 | prober: http 71 | 72 | # generate probe 73 | curl "http://localhost:9115/probe?target=prometheus.io&module=http_2xx" 74 | ``` 75 | 76 | <details> 77 | <summary>Example output with `--log.level=info` and `--log.prober=debug` (default)</summary> 78 | 79 | ```bash 80 | ./blackbox_exporter --config.file ./blackbox.yml --log.level=info --log.prober=debug 81 | time=2025-05-21T04:10:54.131Z level=INFO source=main.go:88 msg="Starting blackbox_exporter" version="(version=0.26.0, branch=fix/scrape-logger-spam, revision=7df3031feecba82f1a534336979b4e5920f79b72)" 82 | time=2025-05-21T04:10:54.131Z level=INFO source=main.go:89 msg="(go=go1.24.1, platform=linux/amd64, user=tjhop@contraband, date=20250521-04:00:25, tags=unknown)" 83 | time=2025-05-21T04:10:54.132Z level=INFO source=main.go:101 msg="Loaded config file" 84 | time=2025-05-21T04:10:54.133Z level=INFO source=tls_config.go:347 msg="Listening on" address=[::]:9115 85 | time=2025-05-21T04:10:54.133Z level=INFO source=tls_config.go:350 msg="TLS is disabled." http2=false address=[::]:9115 86 | ^Ctime=2025-05-21T04:11:03.619Z level=INFO source=main.go:283 msg="Received SIGTERM, exiting gracefully..." 87 | ``` 88 | </details> 89 | 90 | <details> 91 | <summary>Example output with `--log.level=info` and `--log.prober=info`</summary> 92 | 93 | ```bash 94 | ./blackbox_exporter --config.file ./blackbox.yml --log.level=info --log.prober=info 95 | time=2025-05-21T04:12:09.884Z level=INFO source=main.go:88 msg="Starting blackbox_exporter" version="(version=0.26.0, branch=fix/scrape-logger-spam, revision=7df3031feecba82f1a534336979b4e5920f79b72)" 96 | time=2025-05-21T04:12:09.884Z level=INFO source=main.go:89 msg="(go=go1.24.1, platform=linux/amd64, user=tjhop@contraband, date=20250521-04:00:25, tags=unknown)" 97 | time=2025-05-21T04:12:09.884Z level=INFO source=main.go:101 msg="Loaded config file" 98 | time=2025-05-21T04:12:09.885Z level=INFO source=tls_config.go:347 msg="Listening on" address=[::]:9115 99 | time=2025-05-21T04:12:09.885Z level=INFO source=tls_config.go:350 msg="TLS is disabled." http2=false address=[::]:9115 100 | time=2025-05-21T04:12:13.827Z level=INFO source=handler.go:194 msg="Beginning probe" module=http_2xx target=prometheus.io probe=http timeout_seconds=119.5 101 | time=2025-05-21T04:12:13.827Z level=INFO source=handler.go:194 msg="Resolving target address" module=http_2xx target=prometheus.io target=prometheus.io ip_protocol=ip4 102 | time=2025-05-21T04:12:13.829Z level=INFO source=handler.go:194 msg="Resolved target address" module=http_2xx target=prometheus.io target=prometheus.io ip=172.67.201.240 103 | time=2025-05-21T04:12:13.829Z level=INFO source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=http://172.67.201.240 host=prometheus.io 104 | time=2025-05-21T04:12:13.860Z level=INFO source=handler.go:194 msg="Received redirect" module=http_2xx target=prometheus.io location=https://prometheus.io/ 105 | time=2025-05-21T04:12:13.860Z level=INFO source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=https://prometheus.io/ host="" 106 | time=2025-05-21T04:12:13.860Z level=INFO source=handler.go:194 msg="Address does not match first address, not sending TLS ServerName" module=http_2xx target=prometheus.io first=172.67.201.240 address=prometheus.io 107 | time=2025-05-21T04:12:13.974Z level=INFO source=handler.go:194 msg="Received HTTP response" module=http_2xx target=prometheus.io status_code=200 108 | time=2025-05-21T04:12:13.974Z level=INFO source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=0 start=2025-05-21T00:12:13.829-04:00 dnsDone=2025-05-21T00:12:13.829-04:00 connectDone=2025-05-21T00:12:13.839-04:00 gotConn=2025-05-21T00:12:13.839-04:00 responseStart=2025-05-21T00:12:13.860-04:00 tlsStart=0001-01-01T00:00:00.000Z tlsDone=0001-01-01T00:00:00.000Z end=0001-01-01T00:00:00.000Z 109 | time=2025-05-21T04:12:13.974Z level=INFO source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=1 start=2025-05-21T00:12:13.860-04:00 dnsDone=2025-05-21T00:12:13.861-04:00 connectDone=2025-05-21T00:12:13.869-04:00 gotConn=2025-05-21T00:12:13.925-04:00 responseStart=2025-05-21T00:12:13.974-04:00 tlsStart=2025-05-21T00:12:13.869-04:00 tlsDone=2025-05-21T00:12:13.925-04:00 end=2025-05-21T00:12:13.974-04:00 110 | time=2025-05-21T04:12:13.974Z level=INFO source=handler.go:194 msg="Probe succeeded" module=http_2xx target=prometheus.io duration_seconds=0.14708839 111 | ^Ctime=2025-05-21T04:12:17.818Z level=INFO source=main.go:283 msg="Received SIGTERM, exiting gracefully..." 112 | ``` 113 | </details> 114 | 115 | <details> 116 | <summary>Example output with `--log.level=debug` and `--log.prober=info`</summary> 117 | 118 | ```bash 119 | ./blackbox_exporter --config.file ./blackbox.yml --log.level=debug --log.prober=info 120 | time=2025-05-21T04:13:18.497Z level=INFO source=main.go:88 msg="Starting blackbox_exporter" version="(version=0.26.0, branch=fix/scrape-logger-spam, revision=7df3031feecba82f1a534336979b4e5920f79b72)" 121 | time=2025-05-21T04:13:18.497Z level=INFO source=main.go:89 msg="(go=go1.24.1, platform=linux/amd64, user=tjhop@contraband, date=20250521-04:00:25, tags=unknown)" 122 | time=2025-05-21T04:13:18.497Z level=INFO source=main.go:101 msg="Loaded config file" 123 | time=2025-05-21T04:13:18.498Z level=DEBUG source=main.go:116 msg=http://contraband:9115 124 | time=2025-05-21T04:13:18.498Z level=DEBUG source=main.go:130 msg=/ 125 | time=2025-05-21T04:13:18.498Z level=INFO source=tls_config.go:347 msg="Listening on" address=[::]:9115 126 | time=2025-05-21T04:13:18.498Z level=INFO source=tls_config.go:350 msg="TLS is disabled." http2=false address=[::]:9115 127 | time=2025-05-21T04:13:23.169Z level=INFO source=handler.go:194 msg="Beginning probe" module=http_2xx target=prometheus.io probe=http timeout_seconds=119.5 128 | time=2025-05-21T04:13:23.169Z level=INFO source=handler.go:194 msg="Resolving target address" module=http_2xx target=prometheus.io target=prometheus.io ip_protocol=ip4 129 | time=2025-05-21T04:13:23.170Z level=INFO source=handler.go:194 msg="Resolved target address" module=http_2xx target=prometheus.io target=prometheus.io ip=104.21.60.220 130 | time=2025-05-21T04:13:23.170Z level=INFO source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=http://104.21.60.220 host=prometheus.io 131 | time=2025-05-21T04:13:23.202Z level=INFO source=handler.go:194 msg="Received redirect" module=http_2xx target=prometheus.io location=https://prometheus.io/ 132 | time=2025-05-21T04:13:23.202Z level=INFO source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=https://prometheus.io/ host="" 133 | time=2025-05-21T04:13:23.202Z level=INFO source=handler.go:194 msg="Address does not match first address, not sending TLS ServerName" module=http_2xx target=prometheus.io first=104.21.60.220 address=prometheus.io 134 | time=2025-05-21T04:13:23.316Z level=INFO source=handler.go:194 msg="Received HTTP response" module=http_2xx target=prometheus.io status_code=200 135 | time=2025-05-21T04:13:23.319Z level=INFO source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=0 start=2025-05-21T00:13:23.171-04:00 dnsDone=2025-05-21T00:13:23.171-04:00 connectDone=2025-05-21T00:13:23.181-04:00 gotConn=2025-05-21T00:13:23.181-04:00 responseStart=2025-05-21T00:13:23.201-04:00 tlsStart=0001-01-01T00:00:00.000Z tlsDone=0001-01-01T00:00:00.000Z end=0001-01-01T00:00:00.000Z 136 | time=2025-05-21T04:13:23.319Z level=INFO source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=1 start=2025-05-21T00:13:23.202-04:00 dnsDone=2025-05-21T00:13:23.203-04:00 connectDone=2025-05-21T00:13:23.212-04:00 gotConn=2025-05-21T00:13:23.268-04:00 responseStart=2025-05-21T00:13:23.316-04:00 tlsStart=2025-05-21T00:13:23.212-04:00 tlsDone=2025-05-21T00:13:23.268-04:00 end=2025-05-21T00:13:23.319-04:00 137 | time=2025-05-21T04:13:23.319Z level=INFO source=handler.go:194 msg="Probe succeeded" module=http_2xx target=prometheus.io duration_seconds=0.150580389 138 | ^Ctime=2025-05-21T04:13:27.945Z level=INFO source=main.go:283 msg="Received SIGTERM, exiting gracefully..." 139 | ``` 140 | </details> 141 | 142 | 143 | <details> 144 | <summary>Example output with `--log.level=debug` and `--log.prober=debug`</summary> 145 | 146 | ```bash 147 | ./blackbox_exporter --config.file ./blackbox.yml --log.level=debug --log.prober=debug 148 | time=2025-05-21T04:14:55.621Z level=INFO source=main.go:88 msg="Starting blackbox_exporter" version="(version=0.26.0, branch=fix/scrape-logger-spam, revision=7df3031feecba82f1a534336979b4e5920f79b72)" 149 | time=2025-05-21T04:14:55.621Z level=INFO source=main.go:89 msg="(go=go1.24.1, platform=linux/amd64, user=tjhop@contraband, date=20250521-04:00:25, tags=unknown)" 150 | time=2025-05-21T04:14:55.622Z level=INFO source=main.go:101 msg="Loaded config file" 151 | time=2025-05-21T04:14:55.622Z level=DEBUG source=main.go:116 msg=http://contraband:9115 152 | time=2025-05-21T04:14:55.622Z level=DEBUG source=main.go:130 msg=/ 153 | time=2025-05-21T04:14:55.623Z level=INFO source=tls_config.go:347 msg="Listening on" address=[::]:9115 154 | time=2025-05-21T04:14:55.623Z level=INFO source=tls_config.go:350 msg="TLS is disabled." http2=false address=[::]:9115 155 | time=2025-05-21T04:15:03.048Z level=DEBUG source=handler.go:194 msg="Beginning probe" module=http_2xx target=prometheus.io probe=http timeout_seconds=119.5 156 | time=2025-05-21T04:15:03.049Z level=DEBUG source=handler.go:194 msg="Resolving target address" module=http_2xx target=prometheus.io target=prometheus.io ip_protocol=ip4 157 | time=2025-05-21T04:15:03.050Z level=DEBUG source=handler.go:194 msg="Resolved target address" module=http_2xx target=prometheus.io target=prometheus.io ip=172.67.201.240 158 | time=2025-05-21T04:15:03.050Z level=DEBUG source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=http://172.67.201.240 host=prometheus.io 159 | time=2025-05-21T04:15:03.089Z level=DEBUG source=handler.go:194 msg="Received redirect" module=http_2xx target=prometheus.io location=https://prometheus.io/ 160 | time=2025-05-21T04:15:03.089Z level=DEBUG source=handler.go:194 msg="Making HTTP request" module=http_2xx target=prometheus.io url=https://prometheus.io/ host="" 161 | time=2025-05-21T04:15:03.089Z level=DEBUG source=handler.go:194 msg="Address does not match first address, not sending TLS ServerName" module=http_2xx target=prometheus.io first=172.67.201.240 address=prometheus.io 162 | time=2025-05-21T04:15:03.211Z level=DEBUG source=handler.go:194 msg="Received HTTP response" module=http_2xx target=prometheus.io status_code=200 163 | time=2025-05-21T04:15:03.212Z level=DEBUG source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=0 start=2025-05-21T00:15:03.050-04:00 dnsDone=2025-05-21T00:15:03.050-04:00 connectDone=2025-05-21T00:15:03.061-04:00 gotConn=2025-05-21T00:15:03.061-04:00 responseStart=2025-05-21T00:15:03.089-04:00 tlsStart=0001-01-01T00:00:00.000Z tlsDone=0001-01-01T00:00:00.000Z end=0001-01-01T00:00:00.000Z 164 | time=2025-05-21T04:15:03.212Z level=DEBUG source=handler.go:194 msg="Response timings for roundtrip" module=http_2xx target=prometheus.io roundtrip=1 start=2025-05-21T00:15:03.089-04:00 dnsDone=2025-05-21T00:15:03.090-04:00 connectDone=2025-05-21T00:15:03.102-04:00 gotConn=2025-05-21T00:15:03.163-04:00 responseStart=2025-05-21T00:15:03.211-04:00 tlsStart=2025-05-21T00:15:03.102-04:00 tlsDone=2025-05-21T00:15:03.163-04:00 end=2025-05-21T00:15:03.212-04:00 165 | time=2025-05-21T04:15:03.212Z level=DEBUG source=handler.go:194 msg="Probe succeeded" module=http_2xx target=prometheus.io duration_seconds=0.163695815 166 | ^Ctime=2025-05-21T04:15:07.862Z level=INFO source=main.go:283 msg="Received SIGTERM, exiting gracefully..." 167 | ``` 168 | </details> 169 | 170 | ## Building the software 171 | 172 | ### Local Build 173 | 174 | make 175 | 176 | 177 | ### Building with Docker 178 | 179 | After a successful local build: 180 | 181 | docker build -t blackbox_exporter . 182 | 183 | ## [Configuration](CONFIGURATION.md) 184 | 185 | Blackbox exporter is configured via a [configuration file](CONFIGURATION.md) and command-line flags (such as what configuration file to load, what port to listen on, and the logging format and level). 186 | 187 | Blackbox exporter can reload its configuration file at runtime. If the new configuration is not well-formed, the changes will not be applied. 188 | A configuration reload is triggered by sending a `SIGHUP` to the Blackbox exporter process or by sending a HTTP POST request to the `/-/reload` endpoint. 189 | 190 | To view all available command-line flags, run `./blackbox_exporter -h`. 191 | 192 | To specify which [configuration file](CONFIGURATION.md) to load, use the `--config.file` flag. 193 | 194 | Additionally, an [example configuration](example.yml) is also available. 195 | 196 | HTTP, HTTPS (via the `http` prober), DNS, TCP socket, ICMP and gRPC (see permissions section) are currently supported. 197 | Additional modules can be defined to meet your needs. 198 | 199 | The timeout of each probe is automatically determined from the `scrape_timeout` in the [Prometheus config](https://prometheus.io/docs/operating/configuration/#configuration-file), slightly reduced to allow for network delays. 200 | This can be further limited by the `timeout` in the Blackbox exporter config file. If neither is specified, it defaults to 120 seconds. 201 | 202 | ## Prometheus Configuration 203 | 204 | Blackbox exporter implements the multi-target exporter pattern, so we advice 205 | to read the guide [Understanding and using the multi-target exporter pattern 206 | ](https://prometheus.io/docs/guides/multi-target-exporter/) to get the general 207 | idea about the configuration. 208 | 209 | The blackbox exporter needs to be passed the target as a parameter, this can be 210 | done with relabelling. 211 | 212 | Example config: 213 | ```yml 214 | scrape_configs: 215 | - job_name: 'blackbox' 216 | metrics_path: /probe 217 | params: 218 | module: [http_2xx] # Look for a HTTP 200 response. 219 | static_configs: 220 | - targets: 221 | - http://prometheus.io # Target to probe with http. 222 | - https://prometheus.io # Target to probe with https. 223 | - http://example.com:8080 # Target to probe with http on port 8080. 224 | relabel_configs: 225 | - source_labels: [__address__] 226 | target_label: __param_target 227 | - source_labels: [__param_target] 228 | target_label: instance 229 | - target_label: __address__ 230 | replacement: 127.0.0.1:9115 # The blackbox exporter's real hostname:port. 231 | - job_name: 'blackbox_exporter' # collect blackbox exporter's operational metrics. 232 | static_configs: 233 | - targets: ['127.0.0.1:9115'] 234 | ``` 235 | 236 | HTTP probes can accept an additional `hostname` parameter that will set `Host` header and TLS SNI. This can be especially useful with `dns_sd_config`: 237 | ```yaml 238 | scrape_configs: 239 | - job_name: blackbox_all 240 | metrics_path: /probe 241 | params: 242 | module: [ http_2xx ] # Look for a HTTP 200 response. 243 | dns_sd_configs: 244 | - names: 245 | - example.com 246 | - prometheus.io 247 | type: A 248 | port: 443 249 | relabel_configs: 250 | - source_labels: [__address__] 251 | target_label: __param_target 252 | replacement: https://$1/ # Make probe URL be like https://1.2.3.4:443/ 253 | - source_labels: [__param_target] 254 | target_label: instance 255 | - target_label: __address__ 256 | replacement: 127.0.0.1:9115 # The blackbox exporter's real hostname:port. 257 | - source_labels: [__meta_dns_name] 258 | target_label: __param_hostname # Make domain name become 'Host' header for probe requests 259 | - source_labels: [__meta_dns_name] 260 | target_label: vhost # and store it in 'vhost' label 261 | ``` 262 | 263 | ## Permissions 264 | 265 | The ICMP probe requires elevated privileges to function: 266 | 267 | * *Windows*: Administrator privileges are required. 268 | * *Linux*: either a user with a group within `net.ipv4.ping_group_range`, the 269 | `CAP_NET_RAW` capability or the root user is required. 270 | * Your distribution may configure `net.ipv4.ping_group_range` by default in 271 | `/etc/sysctl.conf` or similar. If not you can set 272 | `net.ipv4.ping_group_range = 0 2147483647` to allow any user the ability 273 | to use ping. 274 | * Alternatively the capability can be set by executing `setcap cap_net_raw+ep 275 | blackbox_exporter` 276 | * *BSD*: root user is required. 277 | * *OS X*: No additional privileges are needed. 278 | 279 | [circleci]: https://circleci.com/gh/prometheus/blackbox_exporter 280 | [hub]: https://hub.docker.com/r/prom/blackbox-exporter/ 281 | [quay]: https://quay.io/repository/prometheus/blackbox-exporter 282 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a security issue 2 | 3 | The Prometheus security policy, including how to report vulnerabilities, can be 4 | found here: 5 | 6 | <https://prometheus.io/docs/operating/security/> 7 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.27.0 2 | -------------------------------------------------------------------------------- /blackbox.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_2xx: 3 | prober: http 4 | http: 5 | preferred_ip_protocol: "ip4" 6 | http_post_2xx: 7 | prober: http 8 | http: 9 | method: POST 10 | tcp_connect: 11 | prober: tcp 12 | pop3s_banner: 13 | prober: tcp 14 | tcp: 15 | query_response: 16 | - expect: "^+OK" 17 | tls: true 18 | tls_config: 19 | insecure_skip_verify: false 20 | grpc: 21 | prober: grpc 22 | grpc: 23 | tls: true 24 | preferred_ip_protocol: "ip4" 25 | grpc_plain: 26 | prober: grpc 27 | grpc: 28 | tls: false 29 | service: "service1" 30 | ssh_banner: 31 | prober: tcp 32 | tcp: 33 | query_response: 34 | - expect: "^SSH-2.0-" 35 | - send: "SSH-2.0-blackbox-ssh-check" 36 | ssh_banner_extract: 37 | prober: tcp 38 | timeout: 5s 39 | tcp: 40 | query_response: 41 | - expect: "^SSH-2.0-([^ -]+)(?: (.*))?quot; 42 | labels: 43 | - name: ssh_version 44 | value: "${1}" 45 | - name: ssh_comments 46 | value: "${2}" 47 | irc_banner: 48 | prober: tcp 49 | tcp: 50 | query_response: 51 | - send: "NICK prober" 52 | - send: "USER prober prober prober :prober" 53 | - expect: "PING :([^ ]+)" 54 | send: "PONG ${1}" 55 | - expect: "^:[^ ]+ 001" 56 | icmp: 57 | prober: icmp 58 | icmp_ttl5: 59 | prober: icmp 60 | timeout: 5s 61 | icmp: 62 | ttl: 5 63 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package config 15 | 16 | import ( 17 | "strings" 18 | "testing" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | yaml "gopkg.in/yaml.v3" 22 | ) 23 | 24 | func TestLoadConfig(t *testing.T) { 25 | sc := NewSafeConfig(prometheus.NewRegistry()) 26 | 27 | err := sc.ReloadConfig("testdata/blackbox-good.yml", nil) 28 | if err != nil { 29 | t.Errorf("Error loading config %v: %v", "blackbox.yml", err) 30 | } 31 | } 32 | 33 | func TestLoadBadConfigs(t *testing.T) { 34 | sc := NewSafeConfig(prometheus.NewRegistry()) 35 | tests := []struct { 36 | input string 37 | want string 38 | }{ 39 | { 40 | input: "testdata/blackbox-bad.yml", 41 | want: "error parsing config file: yaml: unmarshal errors:\n line 50: field invalid_extra_field not found in type config.plain", 42 | }, 43 | { 44 | input: "testdata/blackbox-bad2.yml", 45 | want: "error parsing config file: at most one of bearer_token & bearer_token_file must be configured", 46 | }, 47 | { 48 | input: "testdata/invalid-dns-module.yml", 49 | want: "error parsing config file: query name must be set for DNS module", 50 | }, 51 | { 52 | input: "testdata/invalid-dns-class.yml", 53 | want: "error parsing config file: query class 'X' is not valid", 54 | }, 55 | { 56 | input: "testdata/invalid-dns-type.yml", 57 | want: "error parsing config file: query type 'X' is not valid", 58 | }, 59 | { 60 | input: "testdata/invalid-http-header-match.yml", 61 | want: "error parsing config file: regexp must be set for HTTP header matchers", 62 | }, 63 | { 64 | input: "testdata/invalid-http-body-match-regexp.yml", 65 | want: `error parsing config file: "Could not compile regular expression" regexp=":["`, 66 | }, 67 | { 68 | input: "testdata/invalid-http-body-not-match-regexp.yml", 69 | want: `error parsing config file: "Could not compile regular expression" regexp=":["`, 70 | }, 71 | { 72 | input: "testdata/invalid-http-header-match-regexp.yml", 73 | want: `error parsing config file: "Could not compile regular expression" regexp=":["`, 74 | }, 75 | { 76 | input: "testdata/invalid-http-compression-mismatch.yml", 77 | want: `error parsing config file: invalid configuration "Accept-Encoding: deflate", "compression: gzip"`, 78 | }, 79 | { 80 | input: "testdata/invalid-http-compression-mismatch-special-case.yml", 81 | want: `error parsing config file: invalid configuration "accEpt-enCoding: deflate", "compression: gzip"`, 82 | }, 83 | { 84 | input: "testdata/invalid-http-request-compression-reject-all-encodings.yml", 85 | want: `error parsing config file: invalid configuration "Accept-Encoding: *;q=0.0", "compression: gzip"`, 86 | }, 87 | { 88 | input: "testdata/invalid-icmp-ttl.yml", 89 | want: "error parsing config file: \"ttl\" cannot be negative", 90 | }, 91 | { 92 | input: "testdata/invalid-icmp-ttl-overflow.yml", 93 | want: "error parsing config file: \"ttl\" cannot exceed 255", 94 | }, 95 | { 96 | input: "testdata/invalid-tcp-query-response-regexp.yml", 97 | want: `error parsing config file: "Could not compile regular expression" regexp=":["`, 98 | }, 99 | { 100 | input: "testdata/invalid-http-body-config.yml", 101 | want: `error parsing config file: setting body and body_file both are not allowed`, 102 | }, 103 | } 104 | for _, test := range tests { 105 | t.Run(test.input, func(t *testing.T) { 106 | got := sc.ReloadConfig(test.input, nil) 107 | if got == nil || got.Error() != test.want { 108 | t.Fatalf("ReloadConfig(%q) = %v; want %q", test.input, got, test.want) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | func TestHideConfigSecrets(t *testing.T) { 115 | sc := NewSafeConfig(prometheus.NewRegistry()) 116 | 117 | err := sc.ReloadConfig("testdata/blackbox-good.yml", nil) 118 | if err != nil { 119 | t.Errorf("Error loading config %v: %v", "testdata/blackbox-good.yml", err) 120 | } 121 | 122 | // String method must not reveal authentication credentials. 123 | sc.RLock() 124 | c, err := yaml.Marshal(sc.C) 125 | sc.RUnlock() 126 | if err != nil { 127 | t.Errorf("Error marshalling config: %v", err) 128 | } 129 | if strings.Contains(string(c), "mysecret") { 130 | t.Fatal("config's String method reveals authentication credentials.") 131 | } 132 | } 133 | 134 | func TestIsEncodingAcceptable(t *testing.T) { 135 | testcases := map[string]struct { 136 | input string 137 | acceptEncoding string 138 | expected bool 139 | }{ 140 | "empty compression": { 141 | input: "", 142 | acceptEncoding: "gzip", 143 | expected: true, 144 | }, 145 | "trivial": { 146 | input: "gzip", 147 | acceptEncoding: "gzip", 148 | expected: true, 149 | }, 150 | "trivial, quality": { 151 | input: "gzip", 152 | acceptEncoding: "gzip;q=1.0", 153 | expected: true, 154 | }, 155 | "first": { 156 | input: "gzip", 157 | acceptEncoding: "gzip, compress", 158 | expected: true, 159 | }, 160 | "second": { 161 | input: "gzip", 162 | acceptEncoding: "compress, gzip", 163 | expected: true, 164 | }, 165 | "missing": { 166 | input: "br", 167 | acceptEncoding: "gzip, compress", 168 | expected: false, 169 | }, 170 | "*": { 171 | input: "br", 172 | acceptEncoding: "gzip, compress, *", 173 | expected: true, 174 | }, 175 | "* with quality": { 176 | input: "br", 177 | acceptEncoding: "gzip, compress, *;q=0.1", 178 | expected: true, 179 | }, 180 | "rejected": { 181 | input: "br", 182 | acceptEncoding: "gzip, compress, br;q=0.0", 183 | expected: false, 184 | }, 185 | "rejected *": { 186 | input: "br", 187 | acceptEncoding: "gzip, compress, *;q=0.0", 188 | expected: false, 189 | }, 190 | "complex": { 191 | input: "br", 192 | acceptEncoding: "gzip;q=1.0, compress;q=0.5, br;q=0.1, *;q=0.0", 193 | expected: true, 194 | }, 195 | "complex out of order": { 196 | input: "br", 197 | acceptEncoding: "*;q=0.0, compress;q=0.5, br;q=0.1, gzip;q=1.0", 198 | expected: true, 199 | }, 200 | "complex with extra blanks": { 201 | input: "br", 202 | acceptEncoding: " gzip;q=1.0, compress; q=0.5, br;q=0.1, *; q=0.0 ", 203 | expected: true, 204 | }, 205 | } 206 | 207 | for name, tc := range testcases { 208 | t.Run(name, func(t *testing.T) { 209 | actual := isCompressionAcceptEncodingValid(tc.input, tc.acceptEncoding) 210 | if actual != tc.expected { 211 | t.Errorf("Unexpected result: input=%q acceptEncoding=%q expected=%t actual=%t", tc.input, tc.acceptEncoding, tc.expected, actual) 212 | } 213 | }) 214 | } 215 | } 216 | 217 | func TestNewCELProgram(t *testing.T) { 218 | tests := []struct { 219 | name string 220 | expr string 221 | wantErr bool 222 | }{ 223 | { 224 | name: "valid expression", 225 | expr: "body.foo == 'bar'", 226 | wantErr: false, 227 | }, 228 | { 229 | name: "invalid expression", 230 | expr: "foo.bar", 231 | wantErr: true, 232 | }, 233 | { 234 | name: "empty expression", 235 | expr: "", 236 | wantErr: true, 237 | }, 238 | } 239 | for _, tt := range tests { 240 | t.Run(tt.name, func(t *testing.T) { 241 | _, err := NewCELProgram(tt.expr) 242 | if (err != nil) != tt.wantErr { 243 | t.Errorf("NewCELProgram() error = %v, wantErr %v", err, tt.wantErr) 244 | return 245 | } 246 | }) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /config/testdata/blackbox-bad.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_2xx: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | http_post_2xx: 7 | prober: http 8 | timeout: 5s 9 | http: 10 | method: POST 11 | tcp_connect: 12 | prober: tcp 13 | timeout: 5s 14 | pop3s_banner: 15 | prober: tcp 16 | tcp: 17 | query_response: 18 | - expect: "^+OK" 19 | tls: true 20 | tls_config: 21 | insecure_skip_verify: false 22 | ssh_banner: 23 | prober: tcp 24 | timeout: 5s 25 | tcp: 26 | query_response: 27 | - expect: "^SSH-2.0-" 28 | irc_banner: 29 | prober: tcp 30 | timeout: 5s 31 | tcp: 32 | query_response: 33 | - send: "NICK prober" 34 | - send: "USER prober prober prober :prober" 35 | - expect: "PING :([^ ]+)" 36 | send: "PONG ${1}" 37 | - expect: "^:[^ ]+ 001" 38 | icmp_test: 39 | prober: icmp 40 | timeout: 5s 41 | icmp: 42 | preferred_ip_protocol: ip4 43 | dns_test: 44 | prober: dns 45 | timeout: 5s 46 | dns: 47 | preferred_ip_protocol: ip6 48 | validate_answer_rrs: 49 | fail_if_matches_regexp: [test] 50 | invalid_extra_field: value 51 | -------------------------------------------------------------------------------- /config/testdata/blackbox-bad2.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_post_2xx: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | method: POST 7 | bearer_token: foo 8 | bearer_token_file: foo 9 | basic_auth: 10 | username: "username" 11 | password: "mysecret" 12 | -------------------------------------------------------------------------------- /config/testdata/blackbox-good.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_2xx: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | http_post_2xx: 7 | prober: http 8 | timeout: 5s 9 | http: 10 | method: POST 11 | basic_auth: 12 | username: "username" 13 | password: "mysecret" 14 | body_size_limit: 1MB 15 | tcp_connect: 16 | prober: tcp 17 | timeout: 5s 18 | pop3s_banner: 19 | prober: tcp 20 | tcp: 21 | query_response: 22 | - expect: "^+OK" 23 | tls: true 24 | tls_config: 25 | insecure_skip_verify: false 26 | ssh_banner: 27 | prober: tcp 28 | timeout: 5s 29 | tcp: 30 | query_response: 31 | - expect: "^SSH-2.0-" 32 | smtp_starttls: 33 | prober: tcp 34 | timeout: 5s 35 | tcp: 36 | query_response: 37 | - expect: "^220 " 38 | - send: "EHLO prober\r" 39 | - expect: "^250-STARTTLS" 40 | - send: "STARTTLS\r" 41 | - expect: "^220" 42 | - starttls: true 43 | - send: "EHLO prober\r" 44 | - expect: "^250-AUTH" 45 | - send: "QUIT\r" 46 | irc_banner: 47 | prober: tcp 48 | timeout: 5s 49 | tcp: 50 | query_response: 51 | - send: "NICK prober" 52 | - send: "USER prober prober prober :prober" 53 | - expect: "PING :([^ ]+)" 54 | send: "PONG ${1}" 55 | - expect: "^:[^ ]+ 001" 56 | icmp_test: 57 | prober: icmp 58 | timeout: 5s 59 | icmp: 60 | preferred_ip_protocol: ip4 61 | dns_test: 62 | prober: dns 63 | timeout: 5s 64 | dns: 65 | query_name: example.com 66 | preferred_ip_protocol: ip4 67 | ip_protocol_fallback: false 68 | validate_answer_rrs: 69 | fail_if_matches_regexp: [test] 70 | http_header_match_origin: 71 | prober: http 72 | timeout: 5s 73 | http: 74 | method: GET 75 | headers: 76 | Origin: example.com 77 | fail_if_header_not_matches: 78 | - header: Access-Control-Allow-Origin 79 | regexp: '(\*|example\.com)' 80 | allow_missing: false 81 | -------------------------------------------------------------------------------- /config/testdata/invalid-dns-class.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | dns_test: 3 | prober: dns 4 | timeout: 5s 5 | dns: 6 | query_name: example.com 7 | query_class: X 8 | query_type: A 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-dns-module.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | dns_test: 3 | prober: dns 4 | timeout: 5s 5 | dns: 6 | preferred_ip_protocol: ip6 7 | validate_answer_rrs: 8 | fail_if_matches_regexp: [test] 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-dns-type.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | dns_test: 3 | prober: dns 4 | timeout: 5s 5 | dns: 6 | query_name: example.com 7 | query_class: CH 8 | query_type: X 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-body-config.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_test: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | body: "Test body" 7 | body_file: "test_body.txt" 8 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-body-match-regexp.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | fail_if_body_matches_regexp: 7 | - ':[' 8 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-body-not-match-regexp.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | fail_if_body_not_matches_regexp: 7 | - ':[' 8 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-compression-mismatch-special-case.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | compression: gzip 7 | headers: 8 | "accEpt-enCoding": "deflate" 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-compression-mismatch.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | compression: gzip 7 | headers: 8 | "Accept-Encoding": "deflate" 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-header-match-regexp.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | fail_if_header_not_matches: 7 | - header: Access-Control-Allow-Origin 8 | allow_missing: false 9 | regexp: ':[' 10 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-header-match.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | fail_if_header_not_matches: 7 | - header: Access-Control-Allow-Origin 8 | allow_missing: false 9 | -------------------------------------------------------------------------------- /config/testdata/invalid-http-request-compression-reject-all-encodings.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_headers: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | # this configuration is invalid because it's requesting a 7 | # compressed encoding, but it's rejecting every possible encoding 8 | compression: gzip 9 | headers: 10 | "Accept-Encoding": "*;q=0.0" 11 | -------------------------------------------------------------------------------- /config/testdata/invalid-icmp-ttl-overflow.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | icmp_test: 3 | prober: icmp 4 | icmp: 5 | ttl: 256 6 | -------------------------------------------------------------------------------- /config/testdata/invalid-icmp-ttl.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | icmp_test: 3 | prober: icmp 4 | icmp: 5 | ttl: -1 6 | -------------------------------------------------------------------------------- /config/testdata/invalid-tcp-query-response-regexp.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | tcp_test: 3 | prober: tcp 4 | timeout: 5s 5 | tcp: 6 | query_response: 7 | - expect: ":[" 8 | - send: ". STARTTLS" 9 | -------------------------------------------------------------------------------- /example.yml: -------------------------------------------------------------------------------- 1 | modules: 2 | http_2xx_example: 3 | prober: http 4 | timeout: 5s 5 | http: 6 | valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] 7 | valid_status_codes: [] # Defaults to 2xx 8 | method: GET 9 | headers: 10 | Host: vhost.example.com 11 | Accept-Language: en-US 12 | Origin: example.com 13 | follow_redirects: true 14 | fail_if_ssl: false 15 | fail_if_not_ssl: false 16 | fail_if_body_matches_regexp: 17 | - "Could not connect to database" 18 | fail_if_body_not_matches_regexp: 19 | - "Download the latest version here" 20 | fail_if_header_matches: # Verifies that no cookies are set 21 | - header: Set-Cookie 22 | allow_missing: true 23 | regexp: '.*' 24 | fail_if_header_not_matches: 25 | - header: Access-Control-Allow-Origin 26 | regexp: '(\*|example\.com)' 27 | tls_config: 28 | insecure_skip_verify: false 29 | preferred_ip_protocol: "ip4" # defaults to "ip6" 30 | ip_protocol_fallback: false # no fallback to "ip6" 31 | http_with_proxy: 32 | prober: http 33 | http: 34 | proxy_url: "http://127.0.0.1:3128" 35 | skip_resolve_phase_with_proxy: true 36 | http_with_proxy_and_headers: 37 | prober: http 38 | http: 39 | proxy_url: "http://127.0.0.1:3128" 40 | proxy_connect_header: 41 | Proxy-Authorization: 42 | - Bearer token 43 | http_post_2xx: 44 | prober: http 45 | timeout: 5s 46 | http: 47 | method: POST 48 | headers: 49 | Content-Type: application/json 50 | body: '{}' 51 | http_post_body_file: 52 | prober: http 53 | timeout: 5s 54 | http: 55 | method: POST 56 | body_file: "/files/body.txt" 57 | http_basic_auth_example: 58 | prober: http 59 | timeout: 5s 60 | http: 61 | method: POST 62 | headers: 63 | Host: "login.example.com" 64 | basic_auth: 65 | username: "username" 66 | password: "mysecret" 67 | http_json_cel_match: 68 | prober: http 69 | timeout: 5s 70 | http: 71 | method: GET 72 | fail_if_body_json_not_matches_cel: "body.foo == 'bar' && body.baz.startsWith('q')" # { "foo": "bar", "baz": "qux" } 73 | http_2xx_oauth_client_credentials: 74 | prober: http 75 | timeout: 5s 76 | http: 77 | valid_http_versions: ["HTTP/1.1", "HTTP/2"] 78 | follow_redirects: true 79 | preferred_ip_protocol: "ip4" 80 | valid_status_codes: 81 | - 200 82 | - 201 83 | oauth2: 84 | client_id: "client_id" 85 | client_secret: "client_secret" 86 | token_url: "https://api.example.com/token" 87 | endpoint_params: 88 | grant_type: "client_credentials" 89 | http_custom_ca_example: 90 | prober: http 91 | http: 92 | method: GET 93 | tls_config: 94 | ca_file: "/certs/my_cert.crt" 95 | http_gzip: 96 | prober: http 97 | http: 98 | method: GET 99 | compression: gzip 100 | http_gzip_with_accept_encoding: 101 | prober: http 102 | http: 103 | method: GET 104 | compression: gzip 105 | headers: 106 | Accept-Encoding: gzip 107 | tls_connect: 108 | prober: tcp 109 | timeout: 5s 110 | tcp: 111 | tls: true 112 | tcp_connect_example: 113 | prober: tcp 114 | timeout: 5s 115 | imap_starttls: 116 | prober: tcp 117 | timeout: 5s 118 | tcp: 119 | query_response: 120 | - expect: "OK.*STARTTLS" 121 | - send: ". STARTTLS" 122 | - expect: "OK" 123 | - starttls: true 124 | - send: ". capability" 125 | - expect: "CAPABILITY IMAP4rev1" 126 | smtp_starttls: 127 | prober: tcp 128 | timeout: 5s 129 | tcp: 130 | query_response: 131 | - expect: "^220 ([^ ]+) ESMTP (.+)quot; 132 | - send: "EHLO prober\r" 133 | - expect: "^250-STARTTLS" 134 | - send: "STARTTLS\r" 135 | - expect: "^220" 136 | - starttls: true 137 | - send: "EHLO prober\r" 138 | - expect: "^250-AUTH" 139 | - send: "QUIT\r" 140 | irc_banner_example: 141 | prober: tcp 142 | timeout: 5s 143 | tcp: 144 | query_response: 145 | - send: "NICK prober" 146 | - send: "USER prober prober prober :prober" 147 | - expect: "PING :([^ ]+)" 148 | send: "PONG ${1}" 149 | - expect: "^:[^ ]+ 001" 150 | rabbitmq: 151 | prober: tcp 152 | timeout: 30s 153 | tcp: 154 | query_response: 155 | - send: "HELO\r" 156 | - send: "\r" 157 | - send: "\r" 158 | - send: "\r" 159 | - expect: "AMQP" 160 | tls: true 161 | tls_config: 162 | insecure_skip_verify: false 163 | ca_file: "/etc/blackbox_exporter/CA_cert.crt" 164 | rabbitmq_insecure: 165 | prober: tcp 166 | timeout: 30s 167 | tcp: 168 | query_response: 169 | - send: "HELO\r" 170 | - send: "\r" 171 | - send: "\r" 172 | - send: "\r" 173 | - expect: "AMQP" 174 | tls: true 175 | tls_config: 176 | insecure_skip_verify: true 177 | ca_file: "/etc/blackbox_exporter/CA_cert.crt" 178 | icmp_example: 179 | prober: icmp 180 | timeout: 5s 181 | icmp: 182 | preferred_ip_protocol: "ip4" 183 | source_ip_address: "127.0.0.1" 184 | dns_udp_example: 185 | prober: dns 186 | timeout: 5s 187 | dns: 188 | query_name: "www.prometheus.io" 189 | query_type: "A" 190 | valid_rcodes: 191 | - NOERROR 192 | validate_answer_rrs: 193 | fail_if_matches_regexp: 194 | - ".*127.0.0.1" 195 | fail_if_all_match_regexp: 196 | - ".*127.0.0.1" 197 | fail_if_not_matches_regexp: 198 | - "www.prometheus.io.\t300\tIN\tA\t127.0.0.1" 199 | fail_if_none_matches_regexp: 200 | - "127.0.0.1" 201 | validate_authority_rrs: 202 | fail_if_matches_regexp: 203 | - ".*127.0.0.1" 204 | validate_additional_rrs: 205 | fail_if_matches_regexp: 206 | - ".*127.0.0.1" 207 | dns_soa: 208 | prober: dns 209 | dns: 210 | query_name: "prometheus.io" 211 | query_type: "SOA" 212 | dns_tcp_example: 213 | prober: dns 214 | dns: 215 | transport_protocol: "tcp" # defaults to "udp" 216 | preferred_ip_protocol: "ip4" # defaults to "ip6" 217 | query_name: "www.prometheus.io" 218 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/prometheus/blackbox_exporter 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/alecthomas/kingpin/v2 v2.4.0 7 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 8 | github.com/andybalholm/brotli v1.2.0 9 | github.com/google/cel-go v0.25.0 10 | github.com/miekg/dns v1.1.66 11 | github.com/prometheus/client_golang v1.22.0 12 | github.com/prometheus/client_model v0.6.2 13 | github.com/prometheus/common v0.65.0 14 | github.com/prometheus/exporter-toolkit v0.14.0 15 | golang.org/x/net v0.41.0 16 | google.golang.org/grpc v1.73.0 17 | gopkg.in/yaml.v2 v2.4.0 18 | gopkg.in/yaml.v3 v3.0.1 19 | ) 20 | 21 | require ( 22 | cel.dev/expr v0.23.1 // indirect 23 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 27 | github.com/jpillora/backoff v1.0.0 // indirect 28 | github.com/mdlayher/socket v0.4.1 // indirect 29 | github.com/mdlayher/vsock v1.2.1 // indirect 30 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 31 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 32 | github.com/prometheus/procfs v0.15.1 // indirect 33 | github.com/stoewer/go-strcase v1.2.0 // indirect 34 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 35 | golang.org/x/crypto v0.39.0 // indirect 36 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 37 | golang.org/x/mod v0.25.0 // indirect 38 | golang.org/x/oauth2 v0.30.0 // indirect 39 | golang.org/x/sync v0.15.0 // indirect 40 | golang.org/x/sys v0.33.0 // indirect 41 | golang.org/x/text v0.26.0 // indirect 42 | golang.org/x/tools v0.33.0 // indirect 43 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect 44 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 45 | google.golang.org/protobuf v1.36.6 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= 2 | cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= 3 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 4 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 5 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs= 6 | github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 7 | github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= 8 | github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= 9 | github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= 10 | github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 11 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 12 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 13 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 16 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 21 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 22 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 23 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 24 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 25 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 26 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 27 | github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY= 28 | github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI= 29 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 34 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 35 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 36 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 37 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 38 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 39 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 40 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 41 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 42 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 43 | github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= 44 | github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= 45 | github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= 46 | github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= 47 | github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= 48 | github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= 49 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 50 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 51 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 52 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 53 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 56 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 57 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 58 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 59 | github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= 60 | github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 61 | github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= 62 | github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= 63 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 64 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 65 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 66 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 67 | github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= 68 | github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 71 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 72 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 73 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 74 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 75 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 76 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 77 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 78 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 79 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 80 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 81 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 82 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 83 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 84 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 85 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 86 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 87 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 88 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 89 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 90 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 91 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 92 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 93 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 94 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 95 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 96 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 97 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 98 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 99 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 100 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 101 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 102 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 103 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 104 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 105 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 106 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 107 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 108 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= 109 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= 110 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= 111 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 112 | google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 113 | google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 114 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 115 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 116 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 117 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 118 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 119 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 120 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 121 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 122 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 123 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 124 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "errors" 18 | "fmt" 19 | "html" 20 | "net" 21 | "net/http" 22 | _ "net/http/pprof" 23 | "net/url" 24 | "os" 25 | "os/signal" 26 | "path" 27 | "strconv" 28 | "strings" 29 | "syscall" 30 | 31 | "github.com/alecthomas/kingpin/v2" 32 | "github.com/prometheus/client_golang/prometheus" 33 | versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" 34 | "github.com/prometheus/client_golang/prometheus/promauto" 35 | "github.com/prometheus/client_golang/prometheus/promhttp" 36 | "github.com/prometheus/common/promslog" 37 | "github.com/prometheus/common/promslog/flag" 38 | "github.com/prometheus/common/version" 39 | "github.com/prometheus/exporter-toolkit/web" 40 | webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" 41 | "gopkg.in/yaml.v3" 42 | 43 | "github.com/prometheus/blackbox_exporter/config" 44 | "github.com/prometheus/blackbox_exporter/prober" 45 | ) 46 | 47 | var ( 48 | sc = config.NewSafeConfig(prometheus.DefaultRegisterer) 49 | 50 | configFile = kingpin.Flag("config.file", "Blackbox exporter configuration file.").Default("blackbox.yml").String() 51 | timeoutOffset = kingpin.Flag("timeout-offset", "Offset to subtract from timeout in seconds.").Default("0.5").Float64() 52 | configCheck = kingpin.Flag("config.check", "If true validate the config file and then exit.").Default().Bool() 53 | logLevelProber = kingpin.Flag("log.prober", "Log level for probe request logs. One of: [debug, info, warn, error]. Defaults to debug. Please see the section `Controlling log level for probe logs` in the project README for more information.").Default("debug").String() 54 | historyLimit = kingpin.Flag("history.limit", "The maximum amount of items to keep in the history.").Default("100").Uint() 55 | externalURL = kingpin.Flag("web.external-url", "The URL under which Blackbox exporter is externally reachable (for example, if Blackbox exporter is served via a reverse proxy). Used for generating relative and absolute links back to Blackbox exporter itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Blackbox exporter. If omitted, relevant URL components will be derived automatically.").PlaceHolder("<url>").String() 56 | routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").PlaceHolder("<path>").String() 57 | toolkitFlags = webflag.AddFlags(kingpin.CommandLine, ":9115") 58 | 59 | moduleUnknownCounter = promauto.NewCounter(prometheus.CounterOpts{ 60 | Name: "blackbox_module_unknown_total", 61 | Help: "Count of unknown modules requested by probes", 62 | }) 63 | ) 64 | 65 | func init() { 66 | prometheus.MustRegister(versioncollector.NewCollector("blackbox_exporter")) 67 | } 68 | 69 | func main() { 70 | os.Exit(run()) 71 | } 72 | 73 | func run() int { 74 | kingpin.CommandLine.UsageWriter(os.Stdout) 75 | promslogConfig := &promslog.Config{} 76 | flag.AddFlags(kingpin.CommandLine, promslogConfig) 77 | kingpin.Version(version.Print("blackbox_exporter")) 78 | kingpin.HelpFlag.Short('h') 79 | kingpin.Parse() 80 | logger := promslog.New(promslogConfig) 81 | rh := &prober.ResultHistory{MaxResults: *historyLimit} 82 | 83 | probeLogLevel := promslog.NewLevel() 84 | if err := probeLogLevel.Set(*logLevelProber); err != nil { 85 | logger.Warn("Error setting log prober level, log prober level unchanged", "err", err, "current_level", probeLogLevel.String()) 86 | } 87 | 88 | logger.Info("Starting blackbox_exporter", "version", version.Info()) 89 | logger.Info(version.BuildContext()) 90 | 91 | if err := sc.ReloadConfig(*configFile, logger); err != nil { 92 | logger.Error("Error loading config", "err", err) 93 | return 1 94 | } 95 | 96 | if *configCheck { 97 | logger.Info("Config file is ok exiting...") 98 | return 0 99 | } 100 | 101 | logger.Info("Loaded config file") 102 | 103 | // Infer or set Blackbox exporter externalURL 104 | listenAddrs := toolkitFlags.WebListenAddresses 105 | if *externalURL == "" && *toolkitFlags.WebSystemdSocket { 106 | logger.Error("Cannot automatically infer external URL with systemd socket listener. Please provide --web.external-url") 107 | return 1 108 | } else if *externalURL == "" && len(*listenAddrs) > 1 { 109 | logger.Info("Inferring external URL from first provided listen address") 110 | } 111 | beURL, err := computeExternalURL(*externalURL, (*listenAddrs)[0]) 112 | if err != nil { 113 | logger.Error("failed to determine external URL", "err", err) 114 | return 1 115 | } 116 | logger.Debug(beURL.String()) 117 | 118 | // Default -web.route-prefix to path of -web.external-url. 119 | if *routePrefix == "" { 120 | *routePrefix = beURL.Path 121 | } 122 | 123 | // routePrefix must always be at least '/'. 124 | *routePrefix = "/" + strings.Trim(*routePrefix, "/") 125 | // routePrefix requires path to have trailing "/" in order 126 | // for browsers to interpret the path-relative path correctly, instead of stripping it. 127 | if *routePrefix != "/" { 128 | *routePrefix = *routePrefix + "/" 129 | } 130 | logger.Debug(*routePrefix) 131 | 132 | hup := make(chan os.Signal, 1) 133 | reloadCh := make(chan chan error) 134 | signal.Notify(hup, syscall.SIGHUP) 135 | go func() { 136 | for { 137 | select { 138 | case <-hup: 139 | if err := sc.ReloadConfig(*configFile, logger); err != nil { 140 | logger.Error("Error reloading config", "err", err) 141 | continue 142 | } 143 | logger.Info("Reloaded config file") 144 | case rc := <-reloadCh: 145 | if err := sc.ReloadConfig(*configFile, logger); err != nil { 146 | logger.Error("Error reloading config", "err", err) 147 | rc <- err 148 | } else { 149 | logger.Info("Reloaded config file") 150 | rc <- nil 151 | } 152 | } 153 | } 154 | }() 155 | 156 | // Match Prometheus behavior and redirect over externalURL for root path only 157 | // if routePrefix is different than "/" 158 | if *routePrefix != "/" { 159 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 160 | if r.URL.Path != "/" { 161 | http.NotFound(w, r) 162 | return 163 | } 164 | http.Redirect(w, r, beURL.String(), http.StatusFound) 165 | }) 166 | } 167 | 168 | http.HandleFunc(path.Join(*routePrefix, "/-/reload"), 169 | func(w http.ResponseWriter, r *http.Request) { 170 | if r.Method != "POST" { 171 | w.WriteHeader(http.StatusMethodNotAllowed) 172 | fmt.Fprintf(w, "This endpoint requires a POST request.\n") 173 | return 174 | } 175 | 176 | rc := make(chan error) 177 | reloadCh <- rc 178 | if err := <-rc; err != nil { 179 | http.Error(w, fmt.Sprintf("failed to reload config: %s", err), http.StatusInternalServerError) 180 | } 181 | }) 182 | http.Handle(path.Join(*routePrefix, "/metrics"), promhttp.Handler()) 183 | http.HandleFunc(path.Join(*routePrefix, "/-/healthy"), func(w http.ResponseWriter, r *http.Request) { 184 | w.WriteHeader(http.StatusOK) 185 | w.Write([]byte("Healthy")) 186 | }) 187 | http.HandleFunc(path.Join(*routePrefix, "/probe"), func(w http.ResponseWriter, r *http.Request) { 188 | sc.Lock() 189 | conf := sc.C 190 | sc.Unlock() 191 | prober.Handler(w, r, conf, logger, rh, *timeoutOffset, nil, moduleUnknownCounter, promslogConfig.Level, probeLogLevel) 192 | }) 193 | http.HandleFunc(*routePrefix, func(w http.ResponseWriter, r *http.Request) { 194 | w.Header().Set("Content-Type", "text/html") 195 | w.Write([]byte(`<html> 196 | <head><title>Blackbox Exporter</title></head> 197 | <body> 198 | <h1>Blackbox Exporter</h1> 199 | <p><a href="probe?target=prometheus.io&module=http_2xx">Probe prometheus.io for http_2xx</a></p> 200 | <p><a href="probe?target=prometheus.io&module=http_2xx&debug=true">Debug probe prometheus.io for http_2xx</a></p> 201 | <p><a href="metrics">Metrics</a></p> 202 | <p><a href="config">Configuration</a></p> 203 | <h2>Recent Probes</h2> 204 | <table border='1'><tr><th>Module</th><th>Target</th><th>Result</th><th>Debug</th>`)) 205 | 206 | results := rh.List() 207 | 208 | for i := len(results) - 1; i >= 0; i-- { 209 | r := results[i] 210 | success := "Success" 211 | if !r.Success { 212 | success = "<strong>Failure</strong>" 213 | } 214 | fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%s</td><td><a href='logs?id=%d'>Logs</a></td></td>", 215 | html.EscapeString(r.ModuleName), html.EscapeString(r.Target), success, r.Id) 216 | } 217 | 218 | w.Write([]byte(`</table></body> 219 | </html>`)) 220 | }) 221 | 222 | http.HandleFunc(path.Join(*routePrefix, "/logs"), func(w http.ResponseWriter, r *http.Request) { 223 | id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64) 224 | if err != nil { 225 | id = -1 226 | } 227 | target := r.URL.Query().Get("target") 228 | if err == nil && target != "" { 229 | http.Error(w, "Probe id and target can't be defined at the same time", http.StatusBadRequest) 230 | return 231 | } 232 | if id == -1 && target == "" { 233 | http.Error(w, "Probe id or target must be defined as http query parameters", http.StatusBadRequest) 234 | return 235 | } 236 | result := new(prober.Result) 237 | if target != "" { 238 | result = rh.GetByTarget(target) 239 | if result == nil { 240 | http.Error(w, "Probe target not found", http.StatusNotFound) 241 | return 242 | } 243 | } else { 244 | result = rh.GetById(id) 245 | if result == nil { 246 | http.Error(w, "Probe id not found", http.StatusNotFound) 247 | return 248 | } 249 | } 250 | 251 | w.Header().Set("Content-Type", "text/plain") 252 | w.Write([]byte(result.DebugOutput)) 253 | }) 254 | 255 | http.HandleFunc(path.Join(*routePrefix, "/config"), func(w http.ResponseWriter, r *http.Request) { 256 | sc.RLock() 257 | c, err := yaml.Marshal(sc.C) 258 | sc.RUnlock() 259 | if err != nil { 260 | logger.Warn("Error marshalling configuration", "err", err) 261 | http.Error(w, err.Error(), http.StatusInternalServerError) 262 | return 263 | } 264 | w.Header().Set("Content-Type", "text/plain") 265 | w.Write(c) 266 | }) 267 | 268 | srv := &http.Server{} 269 | srvc := make(chan struct{}) 270 | term := make(chan os.Signal, 1) 271 | signal.Notify(term, os.Interrupt, syscall.SIGTERM) 272 | 273 | go func() { 274 | if err := web.ListenAndServe(srv, toolkitFlags, logger); err != nil { 275 | logger.Error("Error starting HTTP server", "err", err) 276 | close(srvc) 277 | } 278 | }() 279 | 280 | for { 281 | select { 282 | case <-term: 283 | logger.Info("Received SIGTERM, exiting gracefully...") 284 | return 0 285 | case <-srvc: 286 | return 1 287 | } 288 | } 289 | 290 | } 291 | 292 | func startsOrEndsWithQuote(s string) bool { 293 | return strings.HasPrefix(s, "\"") || strings.HasPrefix(s, "'") || 294 | strings.HasSuffix(s, "\"") || strings.HasSuffix(s, "'") 295 | } 296 | 297 | // computeExternalURL computes a sanitized external URL from a raw input. It infers unset 298 | // URL parts from the OS and the given listen address. 299 | func computeExternalURL(u, listenAddr string) (*url.URL, error) { 300 | if u == "" { 301 | hostname, err := os.Hostname() 302 | if err != nil { 303 | return nil, err 304 | } 305 | _, port, err := net.SplitHostPort(listenAddr) 306 | if err != nil { 307 | return nil, err 308 | } 309 | u = fmt.Sprintf("http://%s:%s/", hostname, port) 310 | } 311 | 312 | if startsOrEndsWithQuote(u) { 313 | return nil, errors.New("URL must not begin or end with quotes") 314 | } 315 | 316 | eu, err := url.Parse(u) 317 | if err != nil { 318 | return nil, err 319 | } 320 | 321 | ppref := strings.TrimRight(eu.Path, "/") 322 | if ppref != "" && !strings.HasPrefix(ppref, "/") { 323 | ppref = "/" + ppref 324 | } 325 | eu.Path = ppref 326 | 327 | return eu, nil 328 | } 329 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import "testing" 17 | 18 | func TestComputeExternalURL(t *testing.T) { 19 | tests := []struct { 20 | input string 21 | valid bool 22 | }{ 23 | { 24 | input: "", 25 | valid: true, 26 | }, 27 | { 28 | input: "http://proxy.com/prometheus", 29 | valid: true, 30 | }, 31 | { 32 | input: "'https://url/prometheus'", 33 | valid: false, 34 | }, 35 | { 36 | input: "'relative/path/with/quotes'", 37 | valid: false, 38 | }, 39 | { 40 | input: "http://alertmanager.company.com", 41 | valid: true, 42 | }, 43 | { 44 | input: "https://double--dash.de", 45 | valid: true, 46 | }, 47 | { 48 | input: "'http://starts/with/quote", 49 | valid: false, 50 | }, 51 | { 52 | input: "ends/with/quote\"", 53 | valid: false, 54 | }, 55 | } 56 | 57 | for _, test := range tests { 58 | _, err := computeExternalURL(test.input, "0.0.0.0:9090") 59 | if test.valid { 60 | if err != nil { 61 | t.Errorf("unexpected error %v", err) 62 | } 63 | } else { 64 | if err == nil { 65 | t.Errorf("expected error computing %s got none", test.input) 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /prober/dns.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | "net" 20 | "regexp" 21 | "time" 22 | 23 | "github.com/miekg/dns" 24 | "github.com/prometheus/client_golang/prometheus" 25 | pconfig "github.com/prometheus/common/config" 26 | 27 | "github.com/prometheus/blackbox_exporter/config" 28 | ) 29 | 30 | // validRRs checks a slice of RRs received from the server against a DNSRRValidator. 31 | func validRRs(rrs *[]dns.RR, v *config.DNSRRValidator, logger *slog.Logger) bool { 32 | var anyMatch = false 33 | var allMatch = true 34 | // Fail the probe if there are no RRs of a given type, but a regexp match is required 35 | // (i.e. FailIfNotMatchesRegexp or FailIfNoneMatchesRegexp is set). 36 | if len(*rrs) == 0 && len(v.FailIfNotMatchesRegexp) > 0 { 37 | logger.Error("fail_if_not_matches_regexp specified but no RRs returned") 38 | return false 39 | } 40 | if len(*rrs) == 0 && len(v.FailIfNoneMatchesRegexp) > 0 { 41 | logger.Error("fail_if_none_matches_regexp specified but no RRs returned") 42 | return false 43 | } 44 | for _, rr := range *rrs { 45 | logger.Info("Validating RR", "rr", rr) 46 | for _, re := range v.FailIfMatchesRegexp { 47 | match, err := regexp.MatchString(re, rr.String()) 48 | if err != nil { 49 | logger.Error("Error matching regexp", "regexp", re, "err", err) 50 | return false 51 | } 52 | if match { 53 | logger.Error("At least one RR matched regexp", "regexp", re, "rr", rr) 54 | return false 55 | } 56 | } 57 | for _, re := range v.FailIfAllMatchRegexp { 58 | match, err := regexp.MatchString(re, rr.String()) 59 | if err != nil { 60 | logger.Error("Error matching regexp", "regexp", re, "err", err) 61 | return false 62 | } 63 | if !match { 64 | allMatch = false 65 | } 66 | } 67 | for _, re := range v.FailIfNotMatchesRegexp { 68 | match, err := regexp.MatchString(re, rr.String()) 69 | if err != nil { 70 | logger.Error("Error matching regexp", "regexp", re, "err", err) 71 | return false 72 | } 73 | if !match { 74 | logger.Error("At least one RR did not match regexp", "regexp", re, "rr", rr) 75 | return false 76 | } 77 | } 78 | for _, re := range v.FailIfNoneMatchesRegexp { 79 | match, err := regexp.MatchString(re, rr.String()) 80 | if err != nil { 81 | logger.Error("Error matching regexp", "regexp", re, "err", err) 82 | return false 83 | } 84 | if match { 85 | anyMatch = true 86 | } 87 | } 88 | } 89 | if len(v.FailIfAllMatchRegexp) > 0 && !allMatch { 90 | logger.Error("Not all RRs matched regexp") 91 | return false 92 | } 93 | if len(v.FailIfNoneMatchesRegexp) > 0 && !anyMatch { 94 | logger.Error("None of the RRs did matched any regexp") 95 | return false 96 | } 97 | return true 98 | } 99 | 100 | // validRcode checks rcode in the response against a list of valid rcodes. 101 | func validRcode(rcode int, valid []string, logger *slog.Logger) bool { 102 | var validRcodes []int 103 | // If no list of valid rcodes is specified, only NOERROR is considered valid. 104 | if valid == nil { 105 | validRcodes = append(validRcodes, dns.StringToRcode["NOERROR"]) 106 | } else { 107 | for _, rcode := range valid { 108 | rc, ok := dns.StringToRcode[rcode] 109 | if !ok { 110 | logger.Error("Invalid rcode", "rcode", rcode, "known_rcode", dns.RcodeToString) 111 | return false 112 | } 113 | validRcodes = append(validRcodes, rc) 114 | } 115 | } 116 | for _, rc := range validRcodes { 117 | if rcode == rc { 118 | logger.Info("Rcode is valid", "rcode", rcode, "string_rcode", dns.RcodeToString[rcode]) 119 | return true 120 | } 121 | } 122 | logger.Error("Rcode is not one of the valid rcodes", "rcode", rcode, "string_rcode", dns.RcodeToString[rcode], "valid_rcodes", validRcodes) 123 | return false 124 | } 125 | 126 | func ProbeDNS(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger *slog.Logger) bool { 127 | var dialProtocol string 128 | probeDNSDurationGaugeVec := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 129 | Name: "probe_dns_duration_seconds", 130 | Help: "Duration of DNS request by phase", 131 | }, []string{"phase"}) 132 | probeDNSAnswerRRSGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 133 | Name: "probe_dns_answer_rrs", 134 | Help: "Returns number of entries in the answer resource record list", 135 | }) 136 | probeDNSAuthorityRRSGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 137 | Name: "probe_dns_authority_rrs", 138 | Help: "Returns number of entries in the authority resource record list", 139 | }) 140 | probeDNSAdditionalRRSGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 141 | Name: "probe_dns_additional_rrs", 142 | Help: "Returns number of entries in the additional resource record list", 143 | }) 144 | probeDNSQuerySucceeded := prometheus.NewGauge(prometheus.GaugeOpts{ 145 | Name: "probe_dns_query_succeeded", 146 | Help: "Displays whether or not the query was executed successfully", 147 | }) 148 | 149 | for _, lv := range []string{"resolve", "connect", "request"} { 150 | probeDNSDurationGaugeVec.WithLabelValues(lv) 151 | } 152 | 153 | registry.MustRegister(probeDNSDurationGaugeVec) 154 | registry.MustRegister(probeDNSAnswerRRSGauge) 155 | registry.MustRegister(probeDNSAuthorityRRSGauge) 156 | registry.MustRegister(probeDNSAdditionalRRSGauge) 157 | registry.MustRegister(probeDNSQuerySucceeded) 158 | 159 | qc := uint16(dns.ClassINET) 160 | if module.DNS.QueryClass != "" { 161 | var ok bool 162 | qc, ok = dns.StringToClass[module.DNS.QueryClass] 163 | if !ok { 164 | logger.Error("Invalid query class", "Class seen", module.DNS.QueryClass, "Existing classes", dns.ClassToString) 165 | return false 166 | } 167 | } 168 | 169 | qt := dns.TypeANY 170 | if module.DNS.QueryType != "" { 171 | var ok bool 172 | qt, ok = dns.StringToType[module.DNS.QueryType] 173 | if !ok { 174 | logger.Error("Invalid query type", "Type seen", module.DNS.QueryType, "Existing types", dns.TypeToString) 175 | return false 176 | } 177 | } 178 | var probeDNSSOAGauge prometheus.Gauge 179 | 180 | var ip *net.IPAddr 181 | if module.DNS.TransportProtocol == "" { 182 | module.DNS.TransportProtocol = "udp" 183 | } 184 | if module.DNS.TransportProtocol != "udp" && module.DNS.TransportProtocol != "tcp" { 185 | logger.Error("Configuration error: Expected transport protocol udp or tcp", "protocol", module.DNS.TransportProtocol) 186 | return false 187 | } 188 | 189 | targetAddr, port, err := net.SplitHostPort(target) 190 | if err != nil { 191 | // Target only contains host so fallback to default port and set targetAddr as target. 192 | if module.DNS.DNSOverTLS { 193 | port = "853" 194 | } else { 195 | port = "53" 196 | } 197 | targetAddr = target 198 | } 199 | ip, lookupTime, err := chooseProtocol(ctx, module.DNS.IPProtocol, module.DNS.IPProtocolFallback, targetAddr, registry, logger) 200 | if err != nil { 201 | logger.Error("Error resolving address", "err", err) 202 | return false 203 | } 204 | probeDNSDurationGaugeVec.WithLabelValues("resolve").Add(lookupTime) 205 | targetIP := net.JoinHostPort(ip.String(), port) 206 | 207 | if ip.IP.To4() == nil { 208 | dialProtocol = module.DNS.TransportProtocol + "6" 209 | } else { 210 | dialProtocol = module.DNS.TransportProtocol + "4" 211 | } 212 | 213 | if module.DNS.DNSOverTLS { 214 | if module.DNS.TransportProtocol == "tcp" { 215 | dialProtocol += "-tls" 216 | } else { 217 | logger.Error("Configuration error: Expected transport protocol tcp for DoT", "protocol", module.DNS.TransportProtocol) 218 | return false 219 | } 220 | } 221 | 222 | client := new(dns.Client) 223 | client.Net = dialProtocol 224 | 225 | if module.DNS.DNSOverTLS { 226 | tlsConfig, err := pconfig.NewTLSConfig(&module.DNS.TLSConfig) 227 | if err != nil { 228 | logger.Error("Failed to create TLS configuration", "err", err) 229 | return false 230 | } 231 | if tlsConfig.ServerName == "" { 232 | // Use target-hostname as default for TLS-servername. 233 | tlsConfig.ServerName = targetAddr 234 | } 235 | 236 | client.TLSConfig = tlsConfig 237 | } 238 | 239 | // Use configured SourceIPAddress. 240 | if len(module.DNS.SourceIPAddress) > 0 { 241 | srcIP := net.ParseIP(module.DNS.SourceIPAddress) 242 | if srcIP == nil { 243 | logger.Error("Error parsing source ip address", "srcIP", module.DNS.SourceIPAddress) 244 | return false 245 | } 246 | logger.Info("Using local address", "srcIP", srcIP) 247 | client.Dialer = &net.Dialer{} 248 | if module.DNS.TransportProtocol == "tcp" { 249 | client.Dialer.LocalAddr = &net.TCPAddr{IP: srcIP} 250 | } else { 251 | client.Dialer.LocalAddr = &net.UDPAddr{IP: srcIP} 252 | } 253 | } 254 | 255 | msg := new(dns.Msg) 256 | msg.Id = dns.Id() 257 | msg.RecursionDesired = module.DNS.Recursion 258 | msg.Question = make([]dns.Question, 1) 259 | msg.Question[0] = dns.Question{dns.Fqdn(module.DNS.QueryName), qt, qc} 260 | 261 | logger.Info("Making DNS query", "target", targetIP, "dial_protocol", dialProtocol, "query", module.DNS.QueryName, "type", qt, "class", qc) 262 | timeoutDeadline, _ := ctx.Deadline() 263 | client.Timeout = time.Until(timeoutDeadline) 264 | requestStart := time.Now() 265 | response, rtt, err := client.Exchange(msg, targetIP) 266 | // The rtt value returned from client.Exchange includes only the time to 267 | // exchange messages with the server _after_ the connection is created. 268 | // We compute the connection time as the total time for the operation 269 | // minus the time for the actual request rtt. 270 | probeDNSDurationGaugeVec.WithLabelValues("connect").Set((time.Since(requestStart) - rtt).Seconds()) 271 | probeDNSDurationGaugeVec.WithLabelValues("request").Set(rtt.Seconds()) 272 | if err != nil { 273 | logger.Error("Error while sending a DNS query", "err", err) 274 | return false 275 | } 276 | logger.Info("Got response", "response", response) 277 | 278 | probeDNSAnswerRRSGauge.Set(float64(len(response.Answer))) 279 | probeDNSAuthorityRRSGauge.Set(float64(len(response.Ns))) 280 | probeDNSAdditionalRRSGauge.Set(float64(len(response.Extra))) 281 | probeDNSQuerySucceeded.Set(1) 282 | 283 | if qt == dns.TypeSOA { 284 | probeDNSSOAGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 285 | Name: "probe_dns_serial", 286 | Help: "Returns the serial number of the zone", 287 | }) 288 | registry.MustRegister(probeDNSSOAGauge) 289 | 290 | for _, a := range response.Answer { 291 | if soa, ok := a.(*dns.SOA); ok { 292 | probeDNSSOAGauge.Set(float64(soa.Serial)) 293 | } 294 | } 295 | } 296 | 297 | if !validRcode(response.Rcode, module.DNS.ValidRcodes, logger) { 298 | return false 299 | } 300 | logger.Info("Validating Answer RRs") 301 | if !validRRs(&response.Answer, &module.DNS.ValidateAnswer, logger) { 302 | logger.Error("Answer RRs validation failed") 303 | return false 304 | } 305 | logger.Info("Validating Authority RRs") 306 | if !validRRs(&response.Ns, &module.DNS.ValidateAuthority, logger) { 307 | logger.Error("Authority RRs validation failed") 308 | return false 309 | } 310 | logger.Info("Validating Additional RRs") 311 | if !validRRs(&response.Extra, &module.DNS.ValidateAdditional, logger) { 312 | logger.Error("Additional RRs validation failed") 313 | return false 314 | } 315 | return true 316 | } 317 | -------------------------------------------------------------------------------- /prober/grpc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | "net" 20 | "net/url" 21 | "strings" 22 | "time" 23 | 24 | "github.com/prometheus/blackbox_exporter/config" 25 | "github.com/prometheus/client_golang/prometheus" 26 | pconfig "github.com/prometheus/common/config" 27 | "google.golang.org/grpc" 28 | "google.golang.org/grpc/codes" 29 | "google.golang.org/grpc/credentials" 30 | "google.golang.org/grpc/credentials/insecure" 31 | "google.golang.org/grpc/health/grpc_health_v1" 32 | "google.golang.org/grpc/peer" 33 | "google.golang.org/grpc/status" 34 | ) 35 | 36 | type GRPCHealthCheck interface { 37 | Check(c context.Context, service string) (bool, codes.Code, *peer.Peer, string, error) 38 | } 39 | 40 | type gRPCHealthCheckClient struct { 41 | client grpc_health_v1.HealthClient 42 | conn *grpc.ClientConn 43 | } 44 | 45 | func NewGrpcHealthCheckClient(conn *grpc.ClientConn) GRPCHealthCheck { 46 | client := new(gRPCHealthCheckClient) 47 | client.client = grpc_health_v1.NewHealthClient(conn) 48 | client.conn = conn 49 | return client 50 | } 51 | 52 | func (c *gRPCHealthCheckClient) Close() error { 53 | return c.conn.Close() 54 | } 55 | 56 | func (c *gRPCHealthCheckClient) Check(ctx context.Context, service string) (bool, codes.Code, *peer.Peer, string, error) { 57 | var res *grpc_health_v1.HealthCheckResponse 58 | var err error 59 | req := grpc_health_v1.HealthCheckRequest{ 60 | Service: service, 61 | } 62 | 63 | serverPeer := new(peer.Peer) 64 | res, err = c.client.Check(ctx, &req, grpc.Peer(serverPeer)) 65 | if err == nil { 66 | if res.GetStatus() == grpc_health_v1.HealthCheckResponse_SERVING { 67 | return true, codes.OK, serverPeer, res.Status.String(), nil 68 | } 69 | return false, codes.OK, serverPeer, res.Status.String(), nil 70 | } 71 | 72 | returnStatus, _ := status.FromError(err) 73 | 74 | return false, returnStatus.Code(), nil, "", err 75 | } 76 | 77 | func ProbeGRPC(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger *slog.Logger) (success bool) { 78 | 79 | var ( 80 | durationGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 81 | Name: "probe_grpc_duration_seconds", 82 | Help: "Duration of gRPC request by phase", 83 | }, []string{"phase"}) 84 | 85 | isSSLGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 86 | Name: "probe_grpc_ssl", 87 | Help: "Indicates if SSL was used for the connection", 88 | }) 89 | 90 | statusCodeGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 91 | Name: "probe_grpc_status_code", 92 | Help: "Response gRPC status code", 93 | }) 94 | 95 | healthCheckResponseGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 96 | Name: "probe_grpc_healthcheck_response", 97 | Help: "Response HealthCheck response", 98 | }, []string{"serving_status"}) 99 | 100 | probeSSLEarliestCertExpiryGauge = prometheus.NewGauge(sslEarliestCertExpiryGaugeOpts) 101 | 102 | probeTLSVersion = prometheus.NewGaugeVec( 103 | probeTLSInfoGaugeOpts, 104 | []string{"version"}, 105 | ) 106 | 107 | probeSSLLastInformation = prometheus.NewGaugeVec( 108 | prometheus.GaugeOpts{ 109 | Name: "probe_ssl_last_chain_info", 110 | Help: "Contains SSL leaf certificate information", 111 | }, 112 | []string{"fingerprint_sha256", "subject", "issuer", "subjectalternative", "serialnumber"}, 113 | ) 114 | ) 115 | 116 | for _, lv := range []string{"resolve"} { 117 | durationGaugeVec.WithLabelValues(lv) 118 | } 119 | 120 | registry.MustRegister(durationGaugeVec) 121 | registry.MustRegister(isSSLGauge) 122 | registry.MustRegister(statusCodeGauge) 123 | registry.MustRegister(healthCheckResponseGaugeVec) 124 | 125 | if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { 126 | target = "http://" + target 127 | } 128 | 129 | targetURL, err := url.Parse(target) 130 | if err != nil { 131 | logger.Error("Could not parse target URL", "err", err) 132 | return false 133 | } 134 | 135 | targetHost, targetPort, err := net.SplitHostPort(targetURL.Host) 136 | // If split fails, assuming it's a hostname without port part. 137 | if err != nil { 138 | targetHost = targetURL.Host 139 | } 140 | 141 | tlsConfig, err := pconfig.NewTLSConfig(&module.GRPC.TLSConfig) 142 | if err != nil { 143 | logger.Error("Error creating TLS configuration", "err", err) 144 | return false 145 | } 146 | 147 | ip, lookupTime, err := chooseProtocol(ctx, module.GRPC.PreferredIPProtocol, module.GRPC.IPProtocolFallback, targetHost, registry, logger) 148 | if err != nil { 149 | logger.Error("Error resolving address", "err", err) 150 | return false 151 | } 152 | durationGaugeVec.WithLabelValues("resolve").Add(lookupTime) 153 | checkStart := time.Now() 154 | if len(tlsConfig.ServerName) == 0 { 155 | // If there is no `server_name` in tls_config, use 156 | // the hostname of the target. 157 | tlsConfig.ServerName = targetHost 158 | } 159 | 160 | if targetPort == "" { 161 | targetURL.Host = "[" + ip.String() + "]" 162 | } else { 163 | targetURL.Host = net.JoinHostPort(ip.String(), targetPort) 164 | } 165 | 166 | var opts []grpc.DialOption 167 | target = targetHost + ":" + targetPort 168 | if !module.GRPC.TLS { 169 | logger.Debug("Dialing GRPC without TLS") 170 | opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) 171 | if len(targetPort) == 0 { 172 | target = targetHost + ":80" 173 | } 174 | } else { 175 | creds := credentials.NewTLS(tlsConfig) 176 | opts = append(opts, grpc.WithTransportCredentials(creds)) 177 | if len(targetPort) == 0 { 178 | target = targetHost + ":443" 179 | } 180 | } 181 | 182 | conn, err := grpc.NewClient(target, opts...) 183 | 184 | if err != nil { 185 | logger.Error("did not connect", "err", err) 186 | } 187 | 188 | client := NewGrpcHealthCheckClient(conn) 189 | defer conn.Close() 190 | ok, statusCode, serverPeer, servingStatus, err := client.Check(context.Background(), module.GRPC.Service) 191 | durationGaugeVec.WithLabelValues("check").Add(time.Since(checkStart).Seconds()) 192 | 193 | for servingStatusName := range grpc_health_v1.HealthCheckResponse_ServingStatus_value { 194 | healthCheckResponseGaugeVec.WithLabelValues(servingStatusName).Set(float64(0)) 195 | } 196 | if servingStatus != "" { 197 | healthCheckResponseGaugeVec.WithLabelValues(servingStatus).Set(float64(1)) 198 | } 199 | 200 | if serverPeer != nil { 201 | tlsInfo, tlsOk := serverPeer.AuthInfo.(credentials.TLSInfo) 202 | if tlsOk { 203 | registry.MustRegister(probeSSLEarliestCertExpiryGauge, probeTLSVersion, probeSSLLastInformation) 204 | isSSLGauge.Set(float64(1)) 205 | probeSSLEarliestCertExpiryGauge.Set(float64(getEarliestCertExpiry(&tlsInfo.State).Unix())) 206 | probeTLSVersion.WithLabelValues(getTLSVersion(&tlsInfo.State)).Set(1) 207 | probeSSLLastInformation.WithLabelValues(getFingerprint(&tlsInfo.State), getSubject(&tlsInfo.State), getIssuer(&tlsInfo.State), getDNSNames(&tlsInfo.State), getSerialNumber(&tlsInfo.State)).Set(1) 208 | } else { 209 | isSSLGauge.Set(float64(0)) 210 | } 211 | } 212 | statusCodeGauge.Set(float64(statusCode)) 213 | 214 | if !ok || err != nil { 215 | logger.Error("can't connect grpc server:", "err", err) 216 | success = false 217 | } else { 218 | logger.Debug("connect the grpc server successfully") 219 | success = true 220 | } 221 | 222 | return 223 | } 224 | -------------------------------------------------------------------------------- /prober/grpc_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "crypto/tls" 19 | "crypto/x509" 20 | "encoding/pem" 21 | "fmt" 22 | "net" 23 | "os" 24 | "testing" 25 | "time" 26 | 27 | "github.com/prometheus/blackbox_exporter/config" 28 | "github.com/prometheus/client_golang/prometheus" 29 | pconfig "github.com/prometheus/common/config" 30 | "github.com/prometheus/common/promslog" 31 | "google.golang.org/grpc" 32 | "google.golang.org/grpc/credentials" 33 | "google.golang.org/grpc/health" 34 | "google.golang.org/grpc/health/grpc_health_v1" 35 | ) 36 | 37 | func TestGRPCConnection(t *testing.T) { 38 | if os.Getenv("CI") == "true" { 39 | t.Skip("skipping; CI is failing on ipv6 dns requests") 40 | } 41 | 42 | ln, err := net.Listen("tcp", "localhost:0") 43 | if err != nil { 44 | t.Fatalf("Error listening on socket: %s", err) 45 | } 46 | defer ln.Close() 47 | 48 | _, port, err := net.SplitHostPort(ln.Addr().String()) 49 | if err != nil { 50 | t.Fatalf("Error retrieving port for socket: %s", err) 51 | } 52 | s := grpc.NewServer() 53 | healthServer := health.NewServer() 54 | healthServer.SetServingStatus("service", grpc_health_v1.HealthCheckResponse_SERVING) 55 | grpc_health_v1.RegisterHealthServer(s, healthServer) 56 | 57 | go func() { 58 | if err := s.Serve(ln); err != nil { 59 | t.Errorf("failed to serve: %v", err) 60 | return 61 | } 62 | }() 63 | defer s.GracefulStop() 64 | 65 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 | defer cancel() 67 | registry := prometheus.NewRegistry() 68 | 69 | result := ProbeGRPC(testCTX, "localhost:"+port, 70 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 71 | IPProtocolFallback: false, 72 | }, 73 | }, registry, promslog.NewNopLogger()) 74 | 75 | if !result { 76 | t.Fatalf("GRPC probe failed") 77 | } 78 | 79 | mfs, err := registry.Gather() 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | expectedMetrics := map[string]map[string]map[string]struct{}{ 85 | "probe_grpc_healthcheck_response": { 86 | "serving_status": { 87 | "UNKNOWN": {}, 88 | "SERVING": {}, 89 | "NOT_SERVING": {}, 90 | "SERVICE_UNKNOWN": {}, 91 | }, 92 | }, 93 | } 94 | 95 | checkMetrics(expectedMetrics, mfs, t) 96 | 97 | expectedResults := map[string]float64{ 98 | "probe_grpc_ssl": 0, 99 | "probe_grpc_status_code": 0, 100 | } 101 | 102 | checkRegistryResults(expectedResults, mfs, t) 103 | } 104 | 105 | func TestMultipleGRPCservices(t *testing.T) { 106 | if os.Getenv("CI") == "true" { 107 | t.Skip("skipping; CI is failing on ipv6 dns requests") 108 | } 109 | 110 | ln, err := net.Listen("tcp", "localhost:0") 111 | if err != nil { 112 | t.Fatalf("Error listening on socket: %s", err) 113 | } 114 | defer ln.Close() 115 | 116 | _, port, err := net.SplitHostPort(ln.Addr().String()) 117 | if err != nil { 118 | t.Fatalf("Error retrieving port for socket: %s", err) 119 | } 120 | s := grpc.NewServer() 121 | healthServer := health.NewServer() 122 | healthServer.SetServingStatus("service1", grpc_health_v1.HealthCheckResponse_SERVING) 123 | healthServer.SetServingStatus("service2", grpc_health_v1.HealthCheckResponse_NOT_SERVING) 124 | grpc_health_v1.RegisterHealthServer(s, healthServer) 125 | 126 | go func() { 127 | if err := s.Serve(ln); err != nil { 128 | t.Errorf("failed to serve: %v", err) 129 | return 130 | } 131 | }() 132 | defer s.GracefulStop() 133 | 134 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 135 | defer cancel() 136 | registryService1 := prometheus.NewRegistry() 137 | 138 | resultService1 := ProbeGRPC(testCTX, "localhost:"+port, 139 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 140 | IPProtocolFallback: false, 141 | Service: "service1", 142 | }, 143 | }, registryService1, promslog.NewNopLogger()) 144 | 145 | if !resultService1 { 146 | t.Fatalf("GRPC probe failed for service1") 147 | } 148 | 149 | registryService2 := prometheus.NewRegistry() 150 | resultService2 := ProbeGRPC(testCTX, "localhost:"+port, 151 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 152 | IPProtocolFallback: false, 153 | Service: "service2", 154 | }, 155 | }, registryService2, promslog.NewNopLogger()) 156 | 157 | if resultService2 { 158 | t.Fatalf("GRPC probe succeed for service2") 159 | } 160 | 161 | registryService3 := prometheus.NewRegistry() 162 | resultService3 := ProbeGRPC(testCTX, "localhost:"+port, 163 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 164 | IPProtocolFallback: false, 165 | Service: "service3", 166 | }, 167 | }, registryService3, promslog.NewNopLogger()) 168 | 169 | if resultService3 { 170 | t.Fatalf("GRPC probe succeed for service3") 171 | } 172 | } 173 | 174 | func TestGRPCTLSConnection(t *testing.T) { 175 | if os.Getenv("CI") == "true" { 176 | t.Skip("skipping; CI is failing on ipv6 dns requests") 177 | } 178 | 179 | certExpiry := time.Now().AddDate(0, 0, 1) 180 | testCertTmpl := generateCertificateTemplate(certExpiry, false) 181 | testCertTmpl.IsCA = true 182 | _, testcertPem, testKey := generateSelfSignedCertificate(testCertTmpl) 183 | 184 | // CAFile must be passed via filesystem, use a tempfile. 185 | tmpCaFile, err := os.CreateTemp("", "cafile.pem") 186 | if err != nil { 187 | t.Fatalf("Error creating CA tempfile: %s", err) 188 | } 189 | if _, err = tmpCaFile.Write(testcertPem); err != nil { 190 | t.Fatalf("Error writing CA tempfile: %s", err) 191 | } 192 | if err = tmpCaFile.Close(); err != nil { 193 | t.Fatalf("Error closing CA tempfile: %s", err) 194 | } 195 | defer os.Remove(tmpCaFile.Name()) 196 | 197 | testKeyPem := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(testKey)}) 198 | testcert, err := tls.X509KeyPair(testcertPem, testKeyPem) 199 | if err != nil { 200 | panic(fmt.Sprintf("Failed to decode TLS testing keypair: %s\n", err)) 201 | } 202 | 203 | tlsConfig := &tls.Config{ 204 | Certificates: []tls.Certificate{testcert}, 205 | MinVersion: tls.VersionTLS12, 206 | MaxVersion: tls.VersionTLS12, 207 | } 208 | 209 | ln, err := net.Listen("tcp", "localhost:0") 210 | if err != nil { 211 | t.Fatalf("Error listening on socket: %s", err) 212 | } 213 | defer ln.Close() 214 | 215 | _, port, err := net.SplitHostPort(ln.Addr().String()) 216 | if err != nil { 217 | t.Fatalf("Error retrieving port for socket: %s", err) 218 | } 219 | 220 | s := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) 221 | healthServer := health.NewServer() 222 | healthServer.SetServingStatus("service", grpc_health_v1.HealthCheckResponse_SERVING) 223 | grpc_health_v1.RegisterHealthServer(s, healthServer) 224 | 225 | go func() { 226 | if err := s.Serve(ln); err != nil { 227 | t.Errorf("failed to serve: %v", err) 228 | return 229 | } 230 | }() 231 | defer s.GracefulStop() 232 | 233 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 234 | defer cancel() 235 | registry := prometheus.NewRegistry() 236 | 237 | result := ProbeGRPC(testCTX, "localhost:"+port, 238 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 239 | TLS: true, 240 | TLSConfig: pconfig.TLSConfig{InsecureSkipVerify: true}, 241 | IPProtocolFallback: false, 242 | }, 243 | }, registry, promslog.NewNopLogger()) 244 | 245 | if !result { 246 | t.Fatalf("GRPC probe failed") 247 | } 248 | 249 | mfs, err := registry.Gather() 250 | if err != nil { 251 | t.Fatal(err) 252 | } 253 | 254 | expectedLabels := map[string]map[string]string{ 255 | "probe_tls_version_info": { 256 | "version": "TLS 1.2", 257 | }, 258 | } 259 | checkRegistryLabels(expectedLabels, mfs, t) 260 | 261 | expectedResults := map[string]float64{ 262 | "probe_grpc_ssl": 1, 263 | "probe_grpc_status_code": 0, 264 | } 265 | 266 | checkRegistryResults(expectedResults, mfs, t) 267 | } 268 | 269 | func TestNoTLSConnection(t *testing.T) { 270 | if os.Getenv("CI") == "true" { 271 | t.Skip("skipping; CI is failing on ipv6 dns requests") 272 | } 273 | 274 | ln, err := net.Listen("tcp", "localhost:0") 275 | if err != nil { 276 | t.Fatalf("Error listening on socket: %s", err) 277 | } 278 | defer ln.Close() 279 | 280 | _, port, err := net.SplitHostPort(ln.Addr().String()) 281 | if err != nil { 282 | t.Fatalf("Error retrieving port for socket: %s", err) 283 | } 284 | s := grpc.NewServer() 285 | healthServer := health.NewServer() 286 | healthServer.SetServingStatus("service", grpc_health_v1.HealthCheckResponse_SERVING) 287 | grpc_health_v1.RegisterHealthServer(s, healthServer) 288 | 289 | go func() { 290 | if err := s.Serve(ln); err != nil { 291 | t.Errorf("failed to serve: %v", err) 292 | return 293 | } 294 | }() 295 | defer s.GracefulStop() 296 | 297 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 298 | defer cancel() 299 | registry := prometheus.NewRegistry() 300 | 301 | result := ProbeGRPC(testCTX, "localhost:"+port, 302 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 303 | TLS: true, 304 | TLSConfig: pconfig.TLSConfig{InsecureSkipVerify: true}, 305 | IPProtocolFallback: false, 306 | }, 307 | }, registry, promslog.NewNopLogger()) 308 | 309 | if result { 310 | t.Fatalf("GRPC probe succeed") 311 | } 312 | 313 | mfs, err := registry.Gather() 314 | if err != nil { 315 | t.Fatal(err) 316 | } 317 | 318 | expectedResults := map[string]float64{ 319 | "probe_grpc_ssl": 0, 320 | "probe_grpc_status_code": 14, // UNAVAILABLE 321 | } 322 | 323 | checkRegistryResults(expectedResults, mfs, t) 324 | 325 | } 326 | 327 | func TestGRPCServiceNotFound(t *testing.T) { 328 | if os.Getenv("CI") == "true" { 329 | t.Skip("skipping; CI is failing on ipv6 dns requests") 330 | } 331 | 332 | ln, err := net.Listen("tcp", "localhost:0") 333 | if err != nil { 334 | t.Fatalf("Error listening on socket: %s", err) 335 | } 336 | defer ln.Close() 337 | 338 | _, port, err := net.SplitHostPort(ln.Addr().String()) 339 | if err != nil { 340 | t.Fatalf("Error retrieving port for socket: %s", err) 341 | } 342 | s := grpc.NewServer() 343 | healthServer := health.NewServer() 344 | healthServer.SetServingStatus("service", grpc_health_v1.HealthCheckResponse_SERVING) 345 | grpc_health_v1.RegisterHealthServer(s, healthServer) 346 | 347 | go func() { 348 | if err := s.Serve(ln); err != nil { 349 | t.Errorf("failed to serve: %v", err) 350 | return 351 | } 352 | }() 353 | defer s.GracefulStop() 354 | 355 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 356 | defer cancel() 357 | registry := prometheus.NewRegistry() 358 | 359 | result := ProbeGRPC(testCTX, "localhost:"+port, 360 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 361 | IPProtocolFallback: false, 362 | Service: "NonExistingService", 363 | }, 364 | }, registry, promslog.NewNopLogger()) 365 | 366 | if result { 367 | t.Fatalf("GRPC probe succeed") 368 | } 369 | 370 | mfs, err := registry.Gather() 371 | if err != nil { 372 | t.Fatal(err) 373 | } 374 | 375 | expectedResults := map[string]float64{ 376 | "probe_grpc_ssl": 0, 377 | "probe_grpc_status_code": 5, // NOT_FOUND 378 | } 379 | 380 | checkRegistryResults(expectedResults, mfs, t) 381 | } 382 | 383 | func TestGRPCHealthCheckUnimplemented(t *testing.T) { 384 | if os.Getenv("CI") == "true" { 385 | t.Skip("skipping; CI is failing on ipv6 dns requests") 386 | } 387 | 388 | ln, err := net.Listen("tcp", "localhost:0") 389 | if err != nil { 390 | t.Fatalf("Error listening on socket: %s", err) 391 | } 392 | defer ln.Close() 393 | 394 | _, port, err := net.SplitHostPort(ln.Addr().String()) 395 | if err != nil { 396 | t.Fatalf("Error retrieving port for socket: %s", err) 397 | } 398 | s := grpc.NewServer() 399 | 400 | go func() { 401 | if err := s.Serve(ln); err != nil { 402 | t.Errorf("failed to serve: %v", err) 403 | return 404 | } 405 | }() 406 | defer s.GracefulStop() 407 | 408 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 409 | defer cancel() 410 | registry := prometheus.NewRegistry() 411 | 412 | result := ProbeGRPC(testCTX, "localhost:"+port, 413 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 414 | IPProtocolFallback: false, 415 | Service: "NonExistingService", 416 | }, 417 | }, registry, promslog.NewNopLogger()) 418 | 419 | if result { 420 | t.Fatalf("GRPC probe succeed") 421 | } 422 | 423 | mfs, err := registry.Gather() 424 | if err != nil { 425 | t.Fatal(err) 426 | } 427 | 428 | expectedResults := map[string]float64{ 429 | "probe_grpc_ssl": 0, 430 | "probe_grpc_status_code": 12, // UNIMPLEMENTED 431 | } 432 | 433 | checkRegistryResults(expectedResults, mfs, t) 434 | } 435 | 436 | func TestGRPCAbsentFailedTLS(t *testing.T) { 437 | testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) 438 | defer cancel() 439 | registry := prometheus.NewRegistry() 440 | 441 | // probe and invalid port to trigger TCP/TLS error 442 | result := ProbeGRPC(testCTX, "localhost:0", 443 | config.Module{Timeout: time.Second, GRPC: config.GRPCProbe{ 444 | IPProtocolFallback: false, 445 | Service: "NonExistingService", 446 | }, 447 | }, registry, promslog.NewNopLogger()) 448 | 449 | if result { 450 | t.Fatalf("GRPC probe succeeded, should have failed") 451 | } 452 | 453 | mfs, err := registry.Gather() 454 | if err != nil { 455 | t.Fatal(err) 456 | } 457 | 458 | absentMetrics := []string{ 459 | "probe_ssl_earliest_cert_expiry", 460 | "probe_tls_version_info", 461 | "probe_ssl_last_chain_info", 462 | } 463 | 464 | checkAbsentMetrics(absentMetrics, mfs, t) 465 | } 466 | -------------------------------------------------------------------------------- /prober/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "bytes" 18 | "context" 19 | "fmt" 20 | "log/slog" 21 | "net/http" 22 | "net/textproto" 23 | "net/url" 24 | "strconv" 25 | "time" 26 | 27 | "github.com/prometheus/blackbox_exporter/config" 28 | "github.com/prometheus/client_golang/prometheus" 29 | "github.com/prometheus/client_golang/prometheus/promhttp" 30 | "github.com/prometheus/common/expfmt" 31 | "github.com/prometheus/common/promslog" 32 | "gopkg.in/yaml.v2" 33 | ) 34 | 35 | var ( 36 | Probers = map[string]ProbeFn{ 37 | "http": ProbeHTTP, 38 | "tcp": ProbeTCP, 39 | "icmp": ProbeICMP, 40 | "dns": ProbeDNS, 41 | "grpc": ProbeGRPC, 42 | } 43 | ) 44 | 45 | func Handler(w http.ResponseWriter, r *http.Request, c *config.Config, logger *slog.Logger, rh *ResultHistory, timeoutOffset float64, params url.Values, 46 | moduleUnknownCounter prometheus.Counter, 47 | logLevel, logLevelProber *promslog.Level) { 48 | 49 | if params == nil { 50 | params = r.URL.Query() 51 | } 52 | moduleName := params.Get("module") 53 | if moduleName == "" { 54 | moduleName = "http_2xx" 55 | } 56 | module, ok := c.Modules[moduleName] 57 | if !ok { 58 | http.Error(w, fmt.Sprintf("Unknown module %q", moduleName), http.StatusBadRequest) 59 | logger.Debug("Unknown module", "module", moduleName) 60 | if moduleUnknownCounter != nil { 61 | moduleUnknownCounter.Add(1) 62 | } 63 | return 64 | } 65 | 66 | timeoutSeconds, err := getTimeout(r, module, timeoutOffset) 67 | if err != nil { 68 | http.Error(w, fmt.Sprintf("Failed to parse timeout from Prometheus header: %s", err), http.StatusInternalServerError) 69 | return 70 | } 71 | 72 | ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds*float64(time.Second))) 73 | defer cancel() 74 | r = r.WithContext(ctx) 75 | 76 | probeSuccessGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 77 | Name: "probe_success", 78 | Help: "Displays whether or not the probe was a success", 79 | }) 80 | probeDurationGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 81 | Name: "probe_duration_seconds", 82 | Help: "Returns how long the probe took to complete in seconds", 83 | }) 84 | 85 | target := params.Get("target") 86 | if target == "" { 87 | http.Error(w, "Target parameter is missing", http.StatusBadRequest) 88 | return 89 | } 90 | 91 | prober, ok := Probers[module.Prober] 92 | if !ok { 93 | http.Error(w, fmt.Sprintf("Unknown prober %q", module.Prober), http.StatusBadRequest) 94 | return 95 | } 96 | 97 | hostname := params.Get("hostname") 98 | if module.Prober == "http" && hostname != "" { 99 | err = setHTTPHost(hostname, &module) 100 | if err != nil { 101 | http.Error(w, err.Error(), http.StatusBadRequest) 102 | return 103 | } 104 | } 105 | 106 | if module.Prober == "tcp" && hostname != "" { 107 | if module.TCP.TLSConfig.ServerName == "" { 108 | module.TCP.TLSConfig.ServerName = hostname 109 | } 110 | } 111 | 112 | sl := newScrapeLogger(logger, moduleName, target, logLevel, logLevelProber) 113 | slLogger := slog.New(sl) 114 | 115 | slLogger.Info("Beginning probe", "probe", module.Prober, "timeout_seconds", timeoutSeconds) 116 | 117 | start := time.Now() 118 | registry := prometheus.NewRegistry() 119 | registry.MustRegister(probeSuccessGauge) 120 | registry.MustRegister(probeDurationGauge) 121 | success := prober(ctx, target, module, registry, slLogger) 122 | duration := time.Since(start).Seconds() 123 | probeDurationGauge.Set(duration) 124 | if success { 125 | probeSuccessGauge.Set(1) 126 | slLogger.Info("Probe succeeded", "duration_seconds", duration) 127 | } else { 128 | slLogger.Error("Probe failed", "duration_seconds", duration) 129 | } 130 | 131 | debugOutput := DebugOutput(&module, &sl.buffer, registry) 132 | rh.Add(moduleName, target, debugOutput, success) 133 | 134 | if r.URL.Query().Get("debug") == "true" { 135 | w.Header().Set("Content-Type", "text/plain") 136 | w.Write([]byte(debugOutput)) 137 | return 138 | } 139 | 140 | h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) 141 | h.ServeHTTP(w, r) 142 | } 143 | 144 | func setHTTPHost(hostname string, module *config.Module) error { 145 | // By creating a new hashmap and copying values there we 146 | // ensure that the initial configuration remain intact. 147 | headers := make(map[string]string) 148 | if module.HTTP.Headers != nil { 149 | for name, value := range module.HTTP.Headers { 150 | if textproto.CanonicalMIMEHeaderKey(name) == "Host" && value != hostname { 151 | return fmt.Errorf("host header defined both in module configuration (%s) and with URL-parameter 'hostname' (%s)", value, hostname) 152 | } 153 | headers[name] = value 154 | } 155 | } 156 | headers["Host"] = hostname 157 | module.HTTP.Headers = headers 158 | return nil 159 | } 160 | 161 | type scrapeLogger struct { 162 | next *slog.Logger 163 | buffer bytes.Buffer 164 | bufferLogger *slog.Logger 165 | logLevelProber *promslog.Level 166 | } 167 | 168 | // Enabled returns true if both A) the scrapeLogger's internal `next` logger 169 | // and B) the scrapeLogger's internal `bufferLogger` are enabled at the 170 | // provided context/log level, and returns false otherwise. It implements 171 | // slog.Handler. 172 | func (sl *scrapeLogger) Enabled(ctx context.Context, level slog.Level) bool { 173 | nextEnabled := sl.next.Enabled(ctx, level) 174 | bufEnabled := sl.bufferLogger.Enabled(ctx, level) 175 | 176 | return nextEnabled && bufEnabled 177 | } 178 | 179 | // Handle writes the provided log record to the internal logger, and then to 180 | // the internal bufferLogger for use with serving debug output. It implements 181 | // slog.Handler. 182 | func (sl *scrapeLogger) Handle(ctx context.Context, r slog.Record) error { 183 | level := getSlogLevel(sl.logLevelProber.String()) 184 | 185 | // Collect attributes from record so we can log them directly. We 186 | // hijack log calls to the scrapeLogger and override the level from the 187 | // original log call with the level set via the `--log.prober` flag. 188 | attrs := make([]slog.Attr, r.NumAttrs()) 189 | r.Attrs(func(a slog.Attr) bool { 190 | attrs = append(attrs, a) 191 | return true 192 | }) 193 | 194 | sl.next.LogAttrs(ctx, level, r.Message, attrs...) 195 | sl.bufferLogger.LogAttrs(ctx, level, r.Message, attrs...) 196 | 197 | return nil 198 | } 199 | 200 | // WithAttrs adds the provided attributes to the scrapeLogger's internal logger and 201 | // bufferLogger. It implements slog.Handler. 202 | func (sl *scrapeLogger) WithAttrs(attrs []slog.Attr) slog.Handler { 203 | return &scrapeLogger{ 204 | next: slog.New(sl.next.Handler().WithAttrs(attrs)), 205 | buffer: sl.buffer, 206 | bufferLogger: slog.New(sl.bufferLogger.Handler().WithAttrs(attrs)), 207 | logLevelProber: sl.logLevelProber, 208 | } 209 | } 210 | 211 | // WithGroup adds the provided group name to the scrapeLogger's internal logger 212 | // and bufferLogger. It implements slog.Handler. 213 | func (sl *scrapeLogger) WithGroup(name string) slog.Handler { 214 | return &scrapeLogger{ 215 | next: slog.New(sl.next.Handler().WithGroup(name)), 216 | buffer: sl.buffer, 217 | bufferLogger: slog.New(sl.bufferLogger.Handler().WithGroup(name)), 218 | logLevelProber: sl.logLevelProber, 219 | } 220 | } 221 | 222 | func newScrapeLogger(logger *slog.Logger, module string, target string, logLevel, logLevelProber *promslog.Level) *scrapeLogger { 223 | if logLevelProber == nil { 224 | logLevelProber = promslog.NewLevel() 225 | } 226 | sl := &scrapeLogger{ 227 | next: logger.With("module", module, "target", target), 228 | buffer: bytes.Buffer{}, 229 | logLevelProber: logLevelProber, 230 | } 231 | bl := promslog.New(&promslog.Config{Writer: &sl.buffer, Level: logLevel}) 232 | sl.bufferLogger = bl.With("module", module, "target", target) 233 | return sl 234 | } 235 | 236 | func getSlogLevel(level string) slog.Level { 237 | switch level { 238 | case "info": 239 | return slog.LevelInfo 240 | case "debug": 241 | return slog.LevelDebug 242 | case "error": 243 | return slog.LevelError 244 | case "warn": 245 | return slog.LevelWarn 246 | default: 247 | return slog.LevelInfo 248 | } 249 | } 250 | 251 | // DebugOutput returns plaintext debug output for a probe. 252 | func DebugOutput(module *config.Module, logBuffer *bytes.Buffer, registry *prometheus.Registry) string { 253 | buf := &bytes.Buffer{} 254 | fmt.Fprintf(buf, "Logs for the probe:\n") 255 | logBuffer.WriteTo(buf) 256 | fmt.Fprintf(buf, "\n\n\nMetrics that would have been returned:\n") 257 | mfs, err := registry.Gather() 258 | if err != nil { 259 | fmt.Fprintf(buf, "Error gathering metrics: %s\n", err) 260 | } 261 | for _, mf := range mfs { 262 | expfmt.MetricFamilyToText(buf, mf) 263 | } 264 | fmt.Fprintf(buf, "\n\n\nModule configuration:\n") 265 | c, err := yaml.Marshal(module) 266 | if err != nil { 267 | fmt.Fprintf(buf, "Error marshalling config: %s\n", err) 268 | } 269 | buf.Write(c) 270 | 271 | return buf.String() 272 | } 273 | 274 | func getTimeout(r *http.Request, module config.Module, offset float64) (timeoutSeconds float64, err error) { 275 | // If a timeout is configured via the Prometheus header, add it to the request. 276 | if v := r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds"); v != "" { 277 | var err error 278 | timeoutSeconds, err = strconv.ParseFloat(v, 64) 279 | if err != nil { 280 | return 0, err 281 | } 282 | } 283 | if timeoutSeconds == 0 { 284 | timeoutSeconds = 120 285 | } 286 | 287 | var maxTimeoutSeconds = timeoutSeconds - offset 288 | if module.Timeout.Seconds() < maxTimeoutSeconds && module.Timeout.Seconds() > 0 || maxTimeoutSeconds < 0 { 289 | timeoutSeconds = module.Timeout.Seconds() 290 | } else { 291 | timeoutSeconds = maxTimeoutSeconds 292 | } 293 | 294 | return timeoutSeconds, nil 295 | } 296 | -------------------------------------------------------------------------------- /prober/handler_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "net" 20 | "net/http" 21 | "net/http/httptest" 22 | "strconv" 23 | "strings" 24 | "testing" 25 | "time" 26 | 27 | "github.com/prometheus/client_golang/prometheus" 28 | pconfig "github.com/prometheus/common/config" 29 | "github.com/prometheus/common/promslog" 30 | 31 | "github.com/prometheus/blackbox_exporter/config" 32 | ) 33 | 34 | var c = &config.Config{ 35 | Modules: map[string]config.Module{ 36 | "http_2xx": { 37 | Prober: "http", 38 | Timeout: 10 * time.Second, 39 | HTTP: config.HTTPProbe{ 40 | HTTPClientConfig: pconfig.HTTPClientConfig{ 41 | BearerToken: "mysecret", 42 | }, 43 | }, 44 | }, 45 | }, 46 | } 47 | 48 | func TestPrometheusTimeoutHTTP(t *testing.T) { 49 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 50 | time.Sleep(2 * time.Second) 51 | })) 52 | defer ts.Close() 53 | 54 | req, err := http.NewRequest("GET", "?target="+ts.URL, nil) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | req.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", "1") 59 | 60 | rr := httptest.NewRecorder() 61 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 | Handler(w, r, c, promslog.NewNopLogger(), &ResultHistory{}, 0.5, nil, nil, promslog.NewLevel(), promslog.NewLevel()) 63 | }) 64 | 65 | handler.ServeHTTP(rr, req) 66 | 67 | if status := rr.Code; status != http.StatusOK { 68 | t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusOK) 69 | } 70 | } 71 | 72 | func TestPrometheusConfigSecretsHidden(t *testing.T) { 73 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | time.Sleep(2 * time.Second) 75 | })) 76 | defer ts.Close() 77 | 78 | req, err := http.NewRequest("GET", "?debug=true&target="+ts.URL, nil) 79 | if err != nil { 80 | t.Fatal(err) 81 | } 82 | rr := httptest.NewRecorder() 83 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 | Handler(w, r, c, promslog.NewNopLogger(), &ResultHistory{}, 0.5, nil, nil, promslog.NewLevel(), promslog.NewLevel()) 85 | }) 86 | handler.ServeHTTP(rr, req) 87 | 88 | body := rr.Body.String() 89 | if strings.Contains(body, "mysecret") { 90 | t.Errorf("Secret exposed in debug config output: %v", body) 91 | } 92 | if !strings.Contains(body, "<secret>") { 93 | t.Errorf("Hidden secret missing from debug config output: %v", body) 94 | } 95 | } 96 | 97 | func TestDebugOutputSecretsHidden(t *testing.T) { 98 | module := c.Modules["http_2xx"] 99 | out := DebugOutput(&module, &bytes.Buffer{}, prometheus.NewRegistry()) 100 | 101 | if strings.Contains(out, "mysecret") { 102 | t.Errorf("Secret exposed in debug output: %v", out) 103 | } 104 | if !strings.Contains(out, "<secret>") { 105 | t.Errorf("Hidden secret missing from debug output: %v", out) 106 | } 107 | } 108 | 109 | func TestTimeoutIsSetCorrectly(t *testing.T) { 110 | var tests = []struct { 111 | inModuleTimeout time.Duration 112 | inPrometheusTimeout string 113 | inOffset float64 114 | outTimeout float64 115 | }{ 116 | {0 * time.Second, "15", 0.5, 14.5}, 117 | {0 * time.Second, "15", 0, 15}, 118 | {20 * time.Second, "15", 0.5, 14.5}, 119 | {20 * time.Second, "15", 0, 15}, 120 | {5 * time.Second, "15", 0, 5}, 121 | {5 * time.Second, "15", 0.5, 5}, 122 | {10 * time.Second, "", 0.5, 10}, 123 | {10 * time.Second, "10", 0.5, 9.5}, 124 | {9500 * time.Millisecond, "", 0.5, 9.5}, 125 | {9500 * time.Millisecond, "", 1, 9.5}, 126 | {0 * time.Second, "", 0.5, 119.5}, 127 | {0 * time.Second, "", 0, 120}, 128 | } 129 | 130 | for _, v := range tests { 131 | request, _ := http.NewRequest("GET", "", nil) 132 | request.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", v.inPrometheusTimeout) 133 | module := config.Module{ 134 | Timeout: v.inModuleTimeout, 135 | } 136 | 137 | timeout, _ := getTimeout(request, module, v.inOffset) 138 | if timeout != v.outTimeout { 139 | t.Errorf("timeout is incorrect: %v, want %v", timeout, v.outTimeout) 140 | } 141 | } 142 | } 143 | 144 | func TestHostnameParam(t *testing.T) { 145 | headers := map[string]string{} 146 | c := &config.Config{ 147 | Modules: map[string]config.Module{ 148 | "http_2xx": { 149 | Prober: "http", 150 | Timeout: 10 * time.Second, 151 | HTTP: config.HTTPProbe{ 152 | Headers: headers, 153 | IPProtocolFallback: true, 154 | }, 155 | }, 156 | }, 157 | } 158 | 159 | // check that 'hostname' parameter make its way to Host header 160 | hostname := "foo.example.com" 161 | 162 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 163 | if r.Host != hostname { 164 | t.Errorf("Unexpected Host: expected %q, got %q.", hostname, r.Host) 165 | } 166 | w.WriteHeader(http.StatusOK) 167 | })) 168 | defer ts.Close() 169 | 170 | requrl := fmt.Sprintf("?debug=true&hostname=%s&target=%s", hostname, ts.URL) 171 | 172 | req, err := http.NewRequest("GET", requrl, nil) 173 | if err != nil { 174 | t.Fatal(err) 175 | } 176 | 177 | rr := httptest.NewRecorder() 178 | 179 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 | Handler(w, r, c, promslog.NewNopLogger(), &ResultHistory{}, 0.5, nil, nil, promslog.NewLevel(), promslog.NewLevel()) 181 | }) 182 | 183 | handler.ServeHTTP(rr, req) 184 | 185 | if status := rr.Code; status != http.StatusOK { 186 | t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusOK) 187 | } 188 | 189 | // check that ts got the request to perform header check 190 | if !strings.Contains(rr.Body.String(), "probe_success 1") { 191 | t.Errorf("probe failed, response body: %v", rr.Body.String()) 192 | } 193 | 194 | // check that host header both in config and in parameter will result in 400 195 | c.Modules["http_2xx"].HTTP.Headers["Host"] = hostname + ".something" 196 | 197 | handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 198 | Handler(w, r, c, promslog.NewNopLogger(), &ResultHistory{}, 0.5, nil, nil, promslog.NewLevel(), promslog.NewLevel()) 199 | }) 200 | 201 | rr = httptest.NewRecorder() 202 | handler.ServeHTTP(rr, req) 203 | 204 | if status := rr.Code; status != http.StatusBadRequest { 205 | t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusBadRequest) 206 | } 207 | } 208 | 209 | func TestTCPHostnameParam(t *testing.T) { 210 | c := &config.Config{ 211 | Modules: map[string]config.Module{ 212 | "tls_connect": { 213 | Prober: "tcp", 214 | Timeout: 10 * time.Second, 215 | TCP: config.TCPProbe{ 216 | TLS: true, 217 | IPProtocol: "ip4", 218 | TLSConfig: pconfig.TLSConfig{InsecureSkipVerify: true}, 219 | }, 220 | }, 221 | }, 222 | } 223 | 224 | // check that 'hostname' parameter make its way to server_name in the tls_config 225 | hostname := "foo.example.com" 226 | 227 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 228 | if r.Host != hostname { 229 | t.Errorf("Unexpected Host: expected %q, got %q.", hostname, r.Host) 230 | } 231 | w.WriteHeader(http.StatusOK) 232 | })) 233 | defer ts.Close() 234 | 235 | requrl := fmt.Sprintf("?module=tls_connect&debug=true&hostname=%s&target=%s", hostname, ts.Listener.Addr().(*net.TCPAddr).IP.String()+":"+strconv.Itoa(ts.Listener.Addr().(*net.TCPAddr).Port)) 236 | 237 | req, err := http.NewRequest("GET", requrl, nil) 238 | if err != nil { 239 | t.Fatal(err) 240 | } 241 | 242 | rr := httptest.NewRecorder() 243 | 244 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 | Handler(w, r, c, promslog.NewNopLogger(), &ResultHistory{}, 0.5, nil, nil, promslog.NewLevel(), promslog.NewLevel()) 246 | }) 247 | 248 | handler.ServeHTTP(rr, req) 249 | 250 | if status := rr.Code; status != http.StatusOK { 251 | t.Errorf("probe request handler returned wrong status code: %v, want %v", status, http.StatusOK) 252 | } 253 | 254 | // check debug output to confirm the server_name is set in tls_config and matches supplied hostname 255 | if !strings.Contains(rr.Body.String(), "server_name: "+hostname) { 256 | t.Errorf("probe failed, response body: %v", rr.Body.String()) 257 | } 258 | 259 | } 260 | -------------------------------------------------------------------------------- /prober/history.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "sync" 18 | ) 19 | 20 | // Result contains the result of the execution of a probe 21 | type Result struct { 22 | Id int64 23 | ModuleName string 24 | Target string 25 | DebugOutput string 26 | Success bool 27 | } 28 | 29 | // ResultHistory contains two history slices: `results` contains most recent `maxResults` results. 30 | // After they expire out of `results`, failures will be saved in `preservedFailedResults`. This 31 | // ensures that we are always able to see debug information about recent failures. 32 | type ResultHistory struct { 33 | mu sync.Mutex 34 | nextId int64 35 | results []*Result 36 | preservedFailedResults []*Result 37 | MaxResults uint 38 | } 39 | 40 | // Add a result to the history. 41 | func (rh *ResultHistory) Add(moduleName, target, debugOutput string, success bool) { 42 | rh.mu.Lock() 43 | defer rh.mu.Unlock() 44 | 45 | r := &Result{ 46 | Id: rh.nextId, 47 | ModuleName: moduleName, 48 | Target: target, 49 | DebugOutput: debugOutput, 50 | Success: success, 51 | } 52 | rh.nextId++ 53 | 54 | rh.results = append(rh.results, r) 55 | if uint(len(rh.results)) > rh.MaxResults { 56 | // If we are about to remove a failure, add it to the failed result history, then 57 | // remove the oldest failed result, if needed. 58 | if !rh.results[0].Success { 59 | rh.preservedFailedResults = append(rh.preservedFailedResults, rh.results[0]) 60 | if uint(len(rh.preservedFailedResults)) > rh.MaxResults { 61 | preservedFailedResults := make([]*Result, len(rh.preservedFailedResults)-1) 62 | copy(preservedFailedResults, rh.preservedFailedResults[1:]) 63 | rh.preservedFailedResults = preservedFailedResults 64 | } 65 | } 66 | results := make([]*Result, len(rh.results)-1) 67 | copy(results, rh.results[1:]) 68 | rh.results = results 69 | } 70 | } 71 | 72 | // List returns a list of all results. 73 | func (rh *ResultHistory) List() []*Result { 74 | rh.mu.Lock() 75 | defer rh.mu.Unlock() 76 | 77 | // Results in each slice are disjoint. We can simply concatenate the results. 78 | return append(rh.preservedFailedResults[:], rh.results...) 79 | } 80 | 81 | // Get returns a given result by id. 82 | func (rh *ResultHistory) GetById(id int64) *Result { 83 | rh.mu.Lock() 84 | defer rh.mu.Unlock() 85 | 86 | for _, r := range rh.preservedFailedResults { 87 | if r.Id == id { 88 | return r 89 | } 90 | } 91 | for _, r := range rh.results { 92 | if r.Id == id { 93 | return r 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | 100 | // Get returns a given result by url. 101 | func (rh *ResultHistory) GetByTarget(target string) *Result { 102 | rh.mu.Lock() 103 | defer rh.mu.Unlock() 104 | 105 | for _, r := range rh.preservedFailedResults { 106 | if r.Target == target { 107 | return r 108 | } 109 | } 110 | for _, r := range rh.results { 111 | if r.Target == target { 112 | return r 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /prober/history_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "fmt" 18 | "testing" 19 | ) 20 | 21 | func TestHistoryKeepsLatestResults(t *testing.T) { 22 | history := &ResultHistory{MaxResults: 3} 23 | for i := 0; i < 4; i++ { 24 | history.Add("module", "target", fmt.Sprintf("result %d", i), true) 25 | } 26 | 27 | savedResults := history.List() 28 | for i := 0; i < len(savedResults); i++ { 29 | if savedResults[i].DebugOutput != fmt.Sprintf("result %d", i+1) { 30 | t.Errorf("History contained the wrong result at index %d", i) 31 | } 32 | } 33 | } 34 | 35 | func FillHistoryWithMaxSuccesses(h *ResultHistory) { 36 | for i := uint(0); i < h.MaxResults; i++ { 37 | h.Add("module", "target", fmt.Sprintf("result %d", h.nextId), true) 38 | } 39 | } 40 | 41 | func FillHistoryWithMaxPreservedFailures(h *ResultHistory) { 42 | for i := uint(0); i < h.MaxResults; i++ { 43 | h.Add("module", "target", fmt.Sprintf("result %d", h.nextId), false) 44 | } 45 | } 46 | 47 | func TestHistoryPreservesExpiredFailedResults(t *testing.T) { 48 | history := &ResultHistory{MaxResults: 3} 49 | 50 | // Success are expired, no failures are expired 51 | FillHistoryWithMaxSuccesses(history) 52 | FillHistoryWithMaxPreservedFailures(history) 53 | savedResults := history.List() 54 | for i := uint(0); i < uint(len(savedResults)); i++ { 55 | expectedDebugOutput := fmt.Sprintf("result %d", i+history.MaxResults) 56 | if savedResults[i].DebugOutput != expectedDebugOutput { 57 | t.Errorf("History contained the wrong result at index %d. Expected: %s, Actual: %s", i, expectedDebugOutput, savedResults[i].DebugOutput) 58 | } 59 | } 60 | 61 | // Failures are expired, should all be preserved 62 | FillHistoryWithMaxPreservedFailures(history) 63 | savedResults = history.List() 64 | for i := uint(0); i < uint(len(savedResults)); i++ { 65 | expectedDebugOutput := fmt.Sprintf("result %d", i+history.MaxResults) 66 | if savedResults[i].DebugOutput != expectedDebugOutput { 67 | t.Errorf("History contained the wrong result at index %d. Expected: %s, Actual: %s", i, expectedDebugOutput, savedResults[i].DebugOutput) 68 | } 69 | } 70 | 71 | // New expired failures are preserved, new success are not expired 72 | FillHistoryWithMaxPreservedFailures(history) 73 | FillHistoryWithMaxSuccesses(history) 74 | savedResults = history.List() 75 | for i := uint(0); i < uint(len(savedResults)); i++ { 76 | expectedDebugOutput := fmt.Sprintf("result %d", i+history.MaxResults*3) 77 | if savedResults[i].DebugOutput != expectedDebugOutput { 78 | t.Errorf("History contained the wrong result at index %d. Expected: %s, Actual: %s", i, expectedDebugOutput, savedResults[i].DebugOutput) 79 | } 80 | } 81 | } 82 | 83 | func TestHistoryGetById(t *testing.T) { 84 | history := &ResultHistory{MaxResults: 2} 85 | 86 | history.Add("module", "target-0", fmt.Sprintf("result %d", history.nextId), true) 87 | history.Add("module", "target-1", fmt.Sprintf("result %d", history.nextId), false) 88 | 89 | // Get a Result object for a target that exists 90 | resultTrue := history.GetById(0) 91 | if resultTrue == nil { 92 | t.Errorf("Error finding the result in history by id for id: 1") 93 | } else { 94 | if resultTrue.Id != 0 { 95 | t.Errorf("Error finding the result in history by id: expected \"%d\" and got \"%d\"", 0, resultTrue.Id) 96 | } 97 | } 98 | 99 | resultFalse := history.GetById(1) 100 | if resultFalse == nil { 101 | t.Errorf("Error finding the result in history by id for id: 1") 102 | } else { 103 | if resultFalse.Id != 1 { 104 | t.Errorf("Error finding the result in history by id: expected \"%d\" and got \"%d\"", 1, resultFalse.Id) 105 | } 106 | } 107 | 108 | // Get a Result object for a target that doesn't exist 109 | if history.GetById(5) != nil { 110 | t.Errorf("Error finding the result in history by id for id: 5") 111 | } 112 | } 113 | 114 | func TestHistoryGetByTarget(t *testing.T) { 115 | history := &ResultHistory{MaxResults: 2} 116 | 117 | history.Add("module", "target-0", fmt.Sprintf("result %d", history.nextId), true) 118 | history.Add("module", "target-1", fmt.Sprintf("result %d", history.nextId), false) 119 | 120 | // Get a Result object for a target that exists 121 | resultTrue := history.GetByTarget("target-0") 122 | if resultTrue == nil { 123 | t.Errorf("Error finding the result in history by target for target-0") 124 | } else { 125 | if resultTrue.Target != "target-0" { 126 | t.Errorf("Error finding the result in history by target for target: expected \"%s\" and got \"%s\"", "target-0", resultTrue.Target) 127 | } 128 | } 129 | 130 | resultFalse := history.GetByTarget("target-1") 131 | if resultFalse == nil { 132 | t.Errorf("Error finding the result in history by target for target-1") 133 | } else { 134 | if resultFalse.Target != "target-1" { 135 | t.Errorf("Error finding the result in history by target for target: expected \"%s\" and got \"%s\"", "target-1", resultFalse.Target) 136 | } 137 | } 138 | 139 | // Get a Result object for a target that doesn't exist 140 | if history.GetByTarget("target-5") != nil { 141 | t.Errorf("Error finding the result in history by target for target-5") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /prober/icmp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "bytes" 18 | "context" 19 | "log/slog" 20 | "math/rand" 21 | "net" 22 | "os" 23 | "runtime" 24 | "sync" 25 | "time" 26 | 27 | "github.com/prometheus/client_golang/prometheus" 28 | "golang.org/x/net/icmp" 29 | "golang.org/x/net/ipv4" 30 | "golang.org/x/net/ipv6" 31 | 32 | "github.com/prometheus/blackbox_exporter/config" 33 | ) 34 | 35 | var ( 36 | icmpID int 37 | icmpSequence uint16 38 | icmpSequenceMutex sync.Mutex 39 | ) 40 | 41 | func init() { 42 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 43 | // PID is typically 1 when running in a container; in that case, set 44 | // the ICMP echo ID to a random value to avoid potential clashes with 45 | // other blackbox_exporter instances. See #411. 46 | if pid := os.Getpid(); pid == 1 { 47 | icmpID = r.Intn(1 << 16) 48 | } else { 49 | icmpID = pid & 0xffff 50 | } 51 | 52 | // Start the ICMP echo sequence at a random offset to prevent them from 53 | // being in sync when several blackbox_exporter instances are restarted 54 | // at the same time. See #411. 55 | icmpSequence = uint16(r.Intn(1 << 16)) 56 | } 57 | 58 | func getICMPSequence() uint16 { 59 | icmpSequenceMutex.Lock() 60 | defer icmpSequenceMutex.Unlock() 61 | icmpSequence++ 62 | return icmpSequence 63 | } 64 | 65 | func ProbeICMP(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger *slog.Logger) (success bool) { 66 | var ( 67 | requestType icmp.Type 68 | replyType icmp.Type 69 | icmpConn *icmp.PacketConn 70 | v4RawConn *ipv4.RawConn 71 | hopLimitFlagSet = true 72 | 73 | durationGaugeVec = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 74 | Name: "probe_icmp_duration_seconds", 75 | Help: "Duration of icmp request by phase", 76 | }, []string{"phase"}) 77 | 78 | hopLimitGauge = prometheus.NewGauge(prometheus.GaugeOpts{ 79 | Name: "probe_icmp_reply_hop_limit", 80 | Help: "Replied packet hop limit (TTL for ipv4)", 81 | }) 82 | ) 83 | 84 | for _, lv := range []string{"resolve", "setup", "rtt"} { 85 | durationGaugeVec.WithLabelValues(lv) 86 | } 87 | 88 | registry.MustRegister(durationGaugeVec) 89 | 90 | dstIPAddr, lookupTime, err := chooseProtocol(ctx, module.ICMP.IPProtocol, module.ICMP.IPProtocolFallback, target, registry, logger) 91 | 92 | if err != nil { 93 | logger.Error("Error resolving address", "err", err) 94 | return false 95 | } 96 | durationGaugeVec.WithLabelValues("resolve").Add(lookupTime) 97 | 98 | var srcIP net.IP 99 | if len(module.ICMP.SourceIPAddress) > 0 { 100 | if srcIP = net.ParseIP(module.ICMP.SourceIPAddress); srcIP == nil { 101 | logger.Error("Error parsing source ip address", "srcIP", module.ICMP.SourceIPAddress) 102 | return false 103 | } 104 | logger.Info("Using source address", "srcIP", srcIP) 105 | } 106 | 107 | setupStart := time.Now() 108 | logger.Info("Creating socket") 109 | 110 | privileged := true 111 | // Unprivileged sockets are supported on Darwin and Linux only. 112 | tryUnprivileged := runtime.GOOS == "darwin" || runtime.GOOS == "linux" 113 | 114 | if dstIPAddr.IP.To4() == nil { 115 | requestType = ipv6.ICMPTypeEchoRequest 116 | replyType = ipv6.ICMPTypeEchoReply 117 | 118 | if srcIP == nil { 119 | srcIP = net.ParseIP("::") 120 | } 121 | 122 | if tryUnprivileged { 123 | // "udp" here means unprivileged -- not the protocol "udp". 124 | icmpConn, err = icmp.ListenPacket("udp6", srcIP.String()) 125 | if err != nil { 126 | logger.Debug("Unable to do unprivileged listen on socket, will attempt privileged", "err", err) 127 | } else { 128 | privileged = false 129 | } 130 | } 131 | 132 | if privileged { 133 | icmpConn, err = icmp.ListenPacket("ip6:ipv6-icmp", srcIP.String()) 134 | if err != nil { 135 | logger.Error("Error listening to socket", "err", err) 136 | return 137 | } 138 | } 139 | defer icmpConn.Close() 140 | 141 | if err := icmpConn.IPv6PacketConn().SetControlMessage(ipv6.FlagHopLimit, true); err != nil { 142 | logger.Debug("Failed to set Control Message for retrieving Hop Limit", "err", err) 143 | hopLimitFlagSet = false 144 | } 145 | } else { 146 | requestType = ipv4.ICMPTypeEcho 147 | replyType = ipv4.ICMPTypeEchoReply 148 | 149 | if srcIP == nil { 150 | srcIP = net.ParseIP("0.0.0.0") 151 | } 152 | 153 | if module.ICMP.DontFragment { 154 | // If the user has set the don't fragment option we cannot use unprivileged 155 | // sockets as it is not possible to set IP header level options. 156 | netConn, err := net.ListenPacket("ip4:icmp", srcIP.String()) 157 | if err != nil { 158 | logger.Error("Error listening to socket", "err", err) 159 | return 160 | } 161 | defer netConn.Close() 162 | 163 | v4RawConn, err = ipv4.NewRawConn(netConn) 164 | if err != nil { 165 | logger.Error("Error creating raw connection", "err", err) 166 | return 167 | } 168 | defer v4RawConn.Close() 169 | 170 | if err := v4RawConn.SetControlMessage(ipv4.FlagTTL, true); err != nil { 171 | logger.Debug("Failed to set Control Message for retrieving TTL", "err", err) 172 | hopLimitFlagSet = false 173 | } 174 | } else { 175 | if tryUnprivileged { 176 | icmpConn, err = icmp.ListenPacket("udp4", srcIP.String()) 177 | if err != nil { 178 | logger.Debug("Unable to do unprivileged listen on socket, will attempt privileged", "err", err) 179 | } else { 180 | privileged = false 181 | } 182 | } 183 | 184 | if privileged { 185 | icmpConn, err = icmp.ListenPacket("ip4:icmp", srcIP.String()) 186 | if err != nil { 187 | logger.Error("Error listening to socket", "err", err) 188 | return 189 | } 190 | } 191 | defer icmpConn.Close() 192 | 193 | if err := icmpConn.IPv4PacketConn().SetControlMessage(ipv4.FlagTTL, true); err != nil { 194 | logger.Debug("Failed to set Control Message for retrieving TTL", "err", err) 195 | hopLimitFlagSet = false 196 | } 197 | } 198 | } 199 | 200 | var dst net.Addr = dstIPAddr 201 | if !privileged { 202 | dst = &net.UDPAddr{IP: dstIPAddr.IP, Zone: dstIPAddr.Zone} 203 | } 204 | 205 | var data []byte 206 | if module.ICMP.PayloadSize != 0 { 207 | data = make([]byte, module.ICMP.PayloadSize) 208 | copy(data, "Prometheus Blackbox Exporter") 209 | } else { 210 | data = []byte("Prometheus Blackbox Exporter") 211 | } 212 | 213 | body := &icmp.Echo{ 214 | ID: icmpID, 215 | Seq: int(getICMPSequence()), 216 | Data: data, 217 | } 218 | logger.Info("Creating ICMP packet", "seq", body.Seq, "id", body.ID) 219 | wm := icmp.Message{ 220 | Type: requestType, 221 | Code: 0, 222 | Body: body, 223 | } 224 | 225 | wb, err := wm.Marshal(nil) 226 | if err != nil { 227 | logger.Error("Error marshalling packet", "err", err) 228 | return 229 | } 230 | 231 | durationGaugeVec.WithLabelValues("setup").Add(time.Since(setupStart).Seconds()) 232 | logger.Info("Writing out packet") 233 | rttStart := time.Now() 234 | 235 | if icmpConn != nil { 236 | ttl := module.ICMP.TTL 237 | if ttl > 0 { 238 | if c4 := icmpConn.IPv4PacketConn(); c4 != nil { 239 | logger.Debug("Setting TTL (IPv4 unprivileged)", "ttl", ttl) 240 | c4.SetTTL(ttl) 241 | } 242 | if c6 := icmpConn.IPv6PacketConn(); c6 != nil { 243 | logger.Debug("Setting TTL (IPv6 unprivileged)", "ttl", ttl) 244 | c6.SetHopLimit(ttl) 245 | } 246 | } 247 | _, err = icmpConn.WriteTo(wb, dst) 248 | } else { 249 | ttl := config.DefaultICMPTTL 250 | if module.ICMP.TTL > 0 { 251 | logger.Debug("Overriding TTL (raw IPv4)", "ttl", ttl) 252 | ttl = module.ICMP.TTL 253 | } 254 | // Only for IPv4 raw. Needed for setting DontFragment flag. 255 | header := &ipv4.Header{ 256 | Version: ipv4.Version, 257 | Len: ipv4.HeaderLen, 258 | Protocol: 1, 259 | TotalLen: ipv4.HeaderLen + len(wb), 260 | TTL: ttl, 261 | Dst: dstIPAddr.IP, 262 | Src: srcIP, 263 | } 264 | 265 | header.Flags |= ipv4.DontFragment 266 | 267 | err = v4RawConn.WriteTo(header, wb, nil) 268 | } 269 | if err != nil { 270 | logger.Warn("Error writing to socket", "err", err) 271 | return 272 | } 273 | 274 | // Reply should be the same except for the message type and ID if 275 | // unprivileged sockets were used and the kernel used its own. 276 | wm.Type = replyType 277 | // Unprivileged cannot set IDs on Linux. 278 | idUnknown := !privileged && runtime.GOOS == "linux" 279 | if idUnknown { 280 | body.ID = 0 281 | } 282 | wb, err = wm.Marshal(nil) 283 | if err != nil { 284 | logger.Error("Error marshalling packet", "err", err) 285 | return 286 | } 287 | 288 | if idUnknown { 289 | // If the ID is unknown (due to unprivileged sockets) we also cannot know 290 | // the checksum in userspace. 291 | wb[2] = 0 292 | wb[3] = 0 293 | } 294 | 295 | rb := make([]byte, 65536) 296 | deadline, _ := ctx.Deadline() 297 | if icmpConn != nil { 298 | err = icmpConn.SetReadDeadline(deadline) 299 | } else { 300 | err = v4RawConn.SetReadDeadline(deadline) 301 | } 302 | if err != nil { 303 | logger.Error("Error setting socket deadline", "err", err) 304 | return 305 | } 306 | logger.Info("Waiting for reply packets") 307 | for { 308 | var n int 309 | var peer net.Addr 310 | var err error 311 | var hopLimit float64 = -1 312 | 313 | if dstIPAddr.IP.To4() == nil { 314 | var cm *ipv6.ControlMessage 315 | n, cm, peer, err = icmpConn.IPv6PacketConn().ReadFrom(rb) 316 | // HopLimit == 0 is valid for IPv6, although go initialize it as 0. 317 | if cm != nil && hopLimitFlagSet { 318 | hopLimit = float64(cm.HopLimit) 319 | } else { 320 | logger.Debug("Cannot get Hop Limit from the received packet. 'probe_icmp_reply_hop_limit' will be missing.") 321 | } 322 | } else { 323 | var cm *ipv4.ControlMessage 324 | if icmpConn != nil { 325 | n, cm, peer, err = icmpConn.IPv4PacketConn().ReadFrom(rb) 326 | } else { 327 | var h *ipv4.Header 328 | var p []byte 329 | h, p, cm, err = v4RawConn.ReadFrom(rb) 330 | if err == nil { 331 | copy(rb, p) 332 | n = len(p) 333 | peer = &net.IPAddr{IP: h.Src} 334 | } 335 | } 336 | if cm != nil && hopLimitFlagSet { 337 | // Not really Hop Limit, but it is in practice. 338 | hopLimit = float64(cm.TTL) 339 | } else { 340 | logger.Debug("Cannot get TTL from the received packet. 'probe_icmp_reply_hop_limit' will be missing.") 341 | } 342 | } 343 | if err != nil { 344 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 345 | logger.Warn("Timeout reading from socket", "err", err) 346 | return 347 | } 348 | logger.Error("Error reading from socket", "err", err) 349 | continue 350 | } 351 | if peer.String() != dst.String() { 352 | continue 353 | } 354 | if idUnknown { 355 | // Clear the ID from the packet, as the kernel will have replaced it (and 356 | // kept track of our packet for us, hence clearing is safe). 357 | rb[4] = 0 358 | rb[5] = 0 359 | } 360 | if idUnknown || replyType == ipv6.ICMPTypeEchoReply { 361 | // Clear checksum to make comparison succeed. 362 | rb[2] = 0 363 | rb[3] = 0 364 | } 365 | if bytes.Equal(rb[:n], wb) { 366 | durationGaugeVec.WithLabelValues("rtt").Add(time.Since(rttStart).Seconds()) 367 | if hopLimit >= 0 { 368 | hopLimitGauge.Set(hopLimit) 369 | registry.MustRegister(hopLimitGauge) 370 | } 371 | logger.Info("Found matching reply packet") 372 | return true 373 | } 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /prober/prober.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | 22 | "github.com/prometheus/blackbox_exporter/config" 23 | ) 24 | 25 | type ProbeFn func(ctx context.Context, target string, config config.Module, registry *prometheus.Registry, logger *slog.Logger) bool 26 | 27 | const ( 28 | helpSSLEarliestCertExpiry = "Returns last SSL chain expiry in unixtime" 29 | helpSSLChainExpiryInTimeStamp = "Returns last SSL chain expiry in timestamp" 30 | helpProbeTLSInfo = "Returns the TLS version used or NaN when unknown" 31 | helpProbeTLSCipher = "Returns the TLS cipher negotiated during handshake" 32 | ) 33 | 34 | var ( 35 | sslEarliestCertExpiryGaugeOpts = prometheus.GaugeOpts{ 36 | Name: "probe_ssl_earliest_cert_expiry", 37 | Help: helpSSLEarliestCertExpiry, 38 | } 39 | 40 | sslChainExpiryInTimeStampGaugeOpts = prometheus.GaugeOpts{ 41 | Name: "probe_ssl_last_chain_expiry_timestamp_seconds", 42 | Help: helpSSLChainExpiryInTimeStamp, 43 | } 44 | 45 | probeTLSInfoGaugeOpts = prometheus.GaugeOpts{ 46 | Name: "probe_tls_version_info", 47 | Help: helpProbeTLSInfo, 48 | } 49 | 50 | probeTLSCipherGaugeOpts = prometheus.GaugeOpts{ 51 | Name: "probe_tls_cipher_info", 52 | Help: helpProbeTLSCipher, 53 | } 54 | ) 55 | -------------------------------------------------------------------------------- /prober/tcp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "bufio" 18 | "context" 19 | "crypto/tls" 20 | "fmt" 21 | "log/slog" 22 | "net" 23 | 24 | "github.com/prometheus/client_golang/prometheus" 25 | pconfig "github.com/prometheus/common/config" 26 | 27 | "github.com/prometheus/blackbox_exporter/config" 28 | ) 29 | 30 | func dialTCP(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger *slog.Logger) (net.Conn, error) { 31 | var dialProtocol, dialTarget string 32 | dialer := &net.Dialer{} 33 | targetAddress, port, err := net.SplitHostPort(target) 34 | if err != nil { 35 | logger.Error("Error splitting target address and port", "err", err) 36 | return nil, err 37 | } 38 | 39 | ip, _, err := chooseProtocol(ctx, module.TCP.IPProtocol, module.TCP.IPProtocolFallback, targetAddress, registry, logger) 40 | if err != nil { 41 | logger.Error("Error resolving address", "err", err) 42 | return nil, err 43 | } 44 | 45 | if ip.IP.To4() == nil { 46 | dialProtocol = "tcp6" 47 | } else { 48 | dialProtocol = "tcp4" 49 | } 50 | 51 | if len(module.TCP.SourceIPAddress) > 0 { 52 | srcIP := net.ParseIP(module.TCP.SourceIPAddress) 53 | if srcIP == nil { 54 | logger.Error("Error parsing source ip address", "srcIP", module.TCP.SourceIPAddress) 55 | return nil, fmt.Errorf("error parsing source ip address: %s", module.TCP.SourceIPAddress) 56 | } 57 | logger.Info("Using local address", "srcIP", srcIP) 58 | dialer.LocalAddr = &net.TCPAddr{IP: srcIP} 59 | } 60 | 61 | dialTarget = net.JoinHostPort(ip.String(), port) 62 | 63 | if !module.TCP.TLS { 64 | logger.Info("Dialing TCP without TLS") 65 | return dialer.DialContext(ctx, dialProtocol, dialTarget) 66 | } 67 | tlsConfig, err := pconfig.NewTLSConfig(&module.TCP.TLSConfig) 68 | if err != nil { 69 | logger.Error("Error creating TLS configuration", "err", err) 70 | return nil, err 71 | } 72 | 73 | if len(tlsConfig.ServerName) == 0 { 74 | // If there is no `server_name` in tls_config, use 75 | // targetAddress as TLS-servername. Normally tls.DialWithDialer 76 | // would do this for us, but we pre-resolved the name by 77 | // `chooseProtocol` and pass the IP-address for dialing (prevents 78 | // resolving twice). 79 | // For this reason we need to specify the original targetAddress 80 | // via tlsConfig to enable hostname verification. 81 | tlsConfig.ServerName = targetAddress 82 | } 83 | timeoutDeadline, _ := ctx.Deadline() 84 | dialer.Deadline = timeoutDeadline 85 | 86 | logger.Info("Dialing TCP with TLS") 87 | return tls.DialWithDialer(dialer, dialProtocol, dialTarget, tlsConfig) 88 | } 89 | 90 | func probeExpectInfo(registry *prometheus.Registry, qr *config.QueryResponse, bytes []byte, match []int) { 91 | var names []string 92 | var values []string 93 | for _, s := range qr.Labels { 94 | names = append(names, s.Name) 95 | values = append(values, string(qr.Expect.Expand(nil, []byte(s.Value), bytes, match))) 96 | } 97 | metric := prometheus.NewGaugeVec( 98 | prometheus.GaugeOpts{ 99 | Name: "probe_expect_info", 100 | Help: "Explicit content matched", 101 | }, 102 | names, 103 | ) 104 | registry.MustRegister(metric) 105 | metric.WithLabelValues(values...).Set(1) 106 | } 107 | 108 | func ProbeTCP(ctx context.Context, target string, module config.Module, registry *prometheus.Registry, logger *slog.Logger) bool { 109 | probeSSLEarliestCertExpiry := prometheus.NewGauge(sslEarliestCertExpiryGaugeOpts) 110 | probeSSLLastChainExpiryTimestampSeconds := prometheus.NewGauge(sslChainExpiryInTimeStampGaugeOpts) 111 | probeSSLLastInformation := prometheus.NewGaugeVec( 112 | prometheus.GaugeOpts{ 113 | Name: "probe_ssl_last_chain_info", 114 | Help: "Contains SSL leaf certificate information", 115 | }, 116 | []string{"fingerprint_sha256", "subject", "issuer", "subjectalternative", "serialnumber"}, 117 | ) 118 | probeTLSVersion := prometheus.NewGaugeVec( 119 | probeTLSInfoGaugeOpts, 120 | []string{"version"}, 121 | ) 122 | probeFailedDueToRegex := prometheus.NewGauge(prometheus.GaugeOpts{ 123 | Name: "probe_failed_due_to_regex", 124 | Help: "Indicates if probe failed due to regex", 125 | }) 126 | registry.MustRegister(probeFailedDueToRegex) 127 | deadline, _ := ctx.Deadline() 128 | 129 | conn, err := dialTCP(ctx, target, module, registry, logger) 130 | if err != nil { 131 | logger.Error("Error dialing TCP", "err", err) 132 | return false 133 | } 134 | defer conn.Close() 135 | logger.Info("Successfully dialed") 136 | 137 | // Set a deadline to prevent the following code from blocking forever. 138 | // If a deadline cannot be set, better fail the probe by returning an error 139 | // now rather than blocking forever. 140 | if err := conn.SetDeadline(deadline); err != nil { 141 | logger.Error("Error setting deadline", "err", err) 142 | return false 143 | } 144 | if module.TCP.TLS { 145 | state := conn.(*tls.Conn).ConnectionState() 146 | registry.MustRegister(probeSSLEarliestCertExpiry, probeTLSVersion, probeSSLLastChainExpiryTimestampSeconds, probeSSLLastInformation) 147 | probeSSLEarliestCertExpiry.Set(float64(getEarliestCertExpiry(&state).Unix())) 148 | probeTLSVersion.WithLabelValues(getTLSVersion(&state)).Set(1) 149 | probeSSLLastChainExpiryTimestampSeconds.Set(float64(getLastChainExpiry(&state).Unix())) 150 | probeSSLLastInformation.WithLabelValues(getFingerprint(&state), getSubject(&state), getIssuer(&state), getDNSNames(&state), getSerialNumber(&state)).Set(1) 151 | } 152 | scanner := bufio.NewScanner(conn) 153 | for i, qr := range module.TCP.QueryResponse { 154 | logger.Info("Processing query response entry", "entry_number", i) 155 | send := qr.Send 156 | if qr.Expect.Regexp != nil { 157 | var match []int 158 | // Read lines until one of them matches the configured regexp. 159 | for scanner.Scan() { 160 | logger.Debug("Read line", "line", scanner.Text()) 161 | match = qr.Expect.FindSubmatchIndex(scanner.Bytes()) 162 | if match != nil { 163 | logger.Info("Regexp matched", "regexp", qr.Expect.Regexp, "line", scanner.Text()) 164 | break 165 | } 166 | } 167 | if scanner.Err() != nil { 168 | logger.Error("Error reading from connection", "err", scanner.Err().Error()) 169 | return false 170 | } 171 | if match == nil { 172 | probeFailedDueToRegex.Set(1) 173 | logger.Error("Regexp did not match", "regexp", qr.Expect.Regexp, "line", scanner.Text()) 174 | return false 175 | } 176 | probeFailedDueToRegex.Set(0) 177 | send = string(qr.Expect.Expand(nil, []byte(send), scanner.Bytes(), match)) 178 | if qr.Labels != nil { 179 | probeExpectInfo(registry, &qr, scanner.Bytes(), match) 180 | } 181 | } 182 | if send != "" { 183 | logger.Debug("Sending line", "line", send) 184 | if _, err := fmt.Fprintf(conn, "%s\n", send); err != nil { 185 | logger.Error("Failed to send", "err", err) 186 | return false 187 | } 188 | } 189 | if qr.StartTLS { 190 | // Upgrade TCP connection to TLS. 191 | tlsConfig, err := pconfig.NewTLSConfig(&module.TCP.TLSConfig) 192 | if err != nil { 193 | logger.Error("Failed to create TLS configuration", "err", err) 194 | return false 195 | } 196 | if tlsConfig.ServerName == "" { 197 | // Use target-hostname as default for TLS-servername. 198 | targetAddress, _, _ := net.SplitHostPort(target) // Had succeeded in dialTCP already. 199 | tlsConfig.ServerName = targetAddress 200 | } 201 | tlsConn := tls.Client(conn, tlsConfig) 202 | defer tlsConn.Close() 203 | 204 | // Initiate TLS handshake (required here to get TLS state). 205 | if err := tlsConn.Handshake(); err != nil { 206 | logger.Error("TLS Handshake (client) failed", "err", err) 207 | return false 208 | } 209 | logger.Info("TLS Handshake (client) succeeded.") 210 | conn = net.Conn(tlsConn) 211 | scanner = bufio.NewScanner(conn) 212 | 213 | // Get certificate expiry. 214 | state := tlsConn.ConnectionState() 215 | registry.MustRegister(probeSSLEarliestCertExpiry, probeTLSVersion, probeSSLLastChainExpiryTimestampSeconds, probeSSLLastInformation) 216 | probeSSLEarliestCertExpiry.Set(float64(getEarliestCertExpiry(&state).Unix())) 217 | probeTLSVersion.WithLabelValues(getTLSVersion(&state)).Set(1) 218 | probeSSLLastChainExpiryTimestampSeconds.Set(float64(getLastChainExpiry(&state).Unix())) 219 | probeSSLLastInformation.WithLabelValues(getFingerprint(&state), getSubject(&state), getIssuer(&state), getDNSNames(&state), getSerialNumber(&state)).Set(1) 220 | } 221 | } 222 | return true 223 | } 224 | -------------------------------------------------------------------------------- /prober/tls.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "crypto/sha256" 18 | "crypto/tls" 19 | "encoding/hex" 20 | "fmt" 21 | "strings" 22 | "time" 23 | ) 24 | 25 | func getEarliestCertExpiry(state *tls.ConnectionState) time.Time { 26 | earliest := time.Time{} 27 | for _, cert := range state.PeerCertificates { 28 | if (earliest.IsZero() || cert.NotAfter.Before(earliest)) && !cert.NotAfter.IsZero() { 29 | earliest = cert.NotAfter 30 | } 31 | } 32 | return earliest 33 | } 34 | 35 | func getFingerprint(state *tls.ConnectionState) string { 36 | cert := state.PeerCertificates[0] 37 | fingerprint := sha256.Sum256(cert.Raw) 38 | return hex.EncodeToString(fingerprint[:]) 39 | } 40 | 41 | func getSubject(state *tls.ConnectionState) string { 42 | cert := state.PeerCertificates[0] 43 | return cert.Subject.String() 44 | } 45 | 46 | func getIssuer(state *tls.ConnectionState) string { 47 | cert := state.PeerCertificates[0] 48 | return cert.Issuer.String() 49 | } 50 | 51 | func getDNSNames(state *tls.ConnectionState) string { 52 | cert := state.PeerCertificates[0] 53 | return strings.Join(cert.DNSNames, ",") 54 | } 55 | 56 | func getLastChainExpiry(state *tls.ConnectionState) time.Time { 57 | lastChainExpiry := time.Time{} 58 | for _, chain := range state.VerifiedChains { 59 | earliestCertExpiry := time.Time{} 60 | for _, cert := range chain { 61 | if (earliestCertExpiry.IsZero() || cert.NotAfter.Before(earliestCertExpiry)) && !cert.NotAfter.IsZero() { 62 | earliestCertExpiry = cert.NotAfter 63 | } 64 | } 65 | if lastChainExpiry.IsZero() || lastChainExpiry.Before(earliestCertExpiry) { 66 | lastChainExpiry = earliestCertExpiry 67 | } 68 | 69 | } 70 | return lastChainExpiry 71 | } 72 | 73 | func getSerialNumber(state *tls.ConnectionState) string { 74 | cert := state.PeerCertificates[0] 75 | // Using `cert.SerialNumber.Text(16)` will drop the leading zeros when converting the SerialNumber to String, see https://github.com/mozilla/tls-observatory/pull/245. 76 | // To avoid that, we format in lowercase the bytes with `%x` to base 16, with lower-case letters for a-f, see https://go.dev/play/p/Fylce70N2Zl. 77 | 78 | return fmt.Sprintf("%x", cert.SerialNumber.Bytes()) 79 | } 80 | 81 | func getTLSVersion(state *tls.ConnectionState) string { 82 | switch state.Version { 83 | case tls.VersionTLS10: 84 | return "TLS 1.0" 85 | case tls.VersionTLS11: 86 | return "TLS 1.1" 87 | case tls.VersionTLS12: 88 | return "TLS 1.2" 89 | case tls.VersionTLS13: 90 | return "TLS 1.3" 91 | default: 92 | return "unknown" 93 | } 94 | } 95 | 96 | func getTLSCipher(state *tls.ConnectionState) string { 97 | return tls.CipherSuiteName(state.CipherSuite) 98 | } 99 | -------------------------------------------------------------------------------- /prober/utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "hash/fnv" 20 | "log/slog" 21 | "net" 22 | "time" 23 | 24 | "github.com/prometheus/client_golang/prometheus" 25 | ) 26 | 27 | var protocolToGauge = map[string]float64{ 28 | "ip4": 4, 29 | "ip6": 6, 30 | } 31 | 32 | // Returns the IP for the IPProtocol and lookup time. 33 | func chooseProtocol(ctx context.Context, IPProtocol string, fallbackIPProtocol bool, target string, registry *prometheus.Registry, logger *slog.Logger) (ip *net.IPAddr, lookupTime float64, err error) { 34 | var fallbackProtocol string 35 | probeDNSLookupTimeSeconds := prometheus.NewGauge(prometheus.GaugeOpts{ 36 | Name: "probe_dns_lookup_time_seconds", 37 | Help: "Returns the time taken for probe dns lookup in seconds", 38 | }) 39 | 40 | probeIPProtocolGauge := prometheus.NewGauge(prometheus.GaugeOpts{ 41 | Name: "probe_ip_protocol", 42 | Help: "Specifies whether probe ip protocol is IP4 or IP6", 43 | }) 44 | 45 | probeIPAddrHash := prometheus.NewGauge(prometheus.GaugeOpts{ 46 | Name: "probe_ip_addr_hash", 47 | Help: "Specifies the hash of IP address. It's useful to detect if the IP address changes.", 48 | }) 49 | registry.MustRegister(probeIPProtocolGauge) 50 | registry.MustRegister(probeDNSLookupTimeSeconds) 51 | registry.MustRegister(probeIPAddrHash) 52 | 53 | if IPProtocol == "ip6" || IPProtocol == "" { 54 | IPProtocol = "ip6" 55 | fallbackProtocol = "ip4" 56 | } else { 57 | IPProtocol = "ip4" 58 | fallbackProtocol = "ip6" 59 | } 60 | 61 | logger.Info("Resolving target address", "target", target, "ip_protocol", IPProtocol) 62 | resolveStart := time.Now() 63 | 64 | defer func() { 65 | lookupTime = time.Since(resolveStart).Seconds() 66 | probeDNSLookupTimeSeconds.Add(lookupTime) 67 | }() 68 | 69 | resolver := &net.Resolver{} 70 | if !fallbackIPProtocol { 71 | ips, err := resolver.LookupIP(ctx, IPProtocol, target) 72 | if err == nil { 73 | for _, ip := range ips { 74 | logger.Info("Resolved target address", "target", target, "ip", ip.String()) 75 | probeIPProtocolGauge.Set(protocolToGauge[IPProtocol]) 76 | probeIPAddrHash.Set(ipHash(ip)) 77 | return &net.IPAddr{IP: ip}, lookupTime, nil 78 | } 79 | } 80 | logger.Error("Resolution with IP protocol failed", "target", target, "ip_protocol", IPProtocol, "err", err) 81 | return nil, 0.0, err 82 | } 83 | 84 | ips, err := resolver.LookupIPAddr(ctx, target) 85 | if err != nil { 86 | logger.Error("Resolution with IP protocol failed", "target", target, "err", err) 87 | return nil, 0.0, err 88 | } 89 | 90 | // Return the IP in the requested protocol. 91 | var fallback *net.IPAddr 92 | for _, ip := range ips { 93 | switch IPProtocol { 94 | case "ip4": 95 | if ip.IP.To4() != nil { 96 | logger.Info("Resolved target address", "target", target, "ip", ip.String()) 97 | probeIPProtocolGauge.Set(4) 98 | probeIPAddrHash.Set(ipHash(ip.IP)) 99 | return &ip, lookupTime, nil 100 | } 101 | 102 | // ip4 as fallback 103 | fallback = &ip 104 | 105 | case "ip6": 106 | if ip.IP.To4() == nil { 107 | logger.Info("Resolved target address", "target", target, "ip", ip.String()) 108 | probeIPProtocolGauge.Set(6) 109 | probeIPAddrHash.Set(ipHash(ip.IP)) 110 | return &ip, lookupTime, nil 111 | } 112 | 113 | // ip6 as fallback 114 | fallback = &ip 115 | } 116 | } 117 | 118 | // Unable to find ip and no fallback set. 119 | if fallback == nil || !fallbackIPProtocol { 120 | return nil, 0.0, fmt.Errorf("unable to find ip; no fallback") 121 | } 122 | 123 | // Use fallback ip protocol. 124 | if fallbackProtocol == "ip4" { 125 | probeIPProtocolGauge.Set(4) 126 | } else { 127 | probeIPProtocolGauge.Set(6) 128 | } 129 | probeIPAddrHash.Set(ipHash(fallback.IP)) 130 | logger.Info("Resolved target address", "target", target, "ip", fallback.String()) 131 | return fallback, lookupTime, nil 132 | } 133 | 134 | func ipHash(ip net.IP) float64 { 135 | h := fnv.New32a() 136 | if ip.To4() != nil { 137 | h.Write(ip.To4()) 138 | } else { 139 | h.Write(ip.To16()) 140 | } 141 | return float64(h.Sum32()) 142 | } 143 | -------------------------------------------------------------------------------- /prober/utils_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package prober 15 | 16 | import ( 17 | "context" 18 | "crypto/rand" 19 | "crypto/rsa" 20 | "crypto/tls" 21 | "crypto/x509" 22 | "crypto/x509/pkix" 23 | "encoding/pem" 24 | "fmt" 25 | "math/big" 26 | "net" 27 | "slices" 28 | "testing" 29 | "time" 30 | 31 | "github.com/prometheus/client_golang/prometheus" 32 | dto "github.com/prometheus/client_model/go" 33 | "github.com/prometheus/common/promslog" 34 | ) 35 | 36 | // Check if expected results are in the registry 37 | func checkRegistryResults(expRes map[string]float64, mfs []*dto.MetricFamily, t *testing.T) { 38 | res := make(map[string]float64) 39 | for i := range mfs { 40 | res[mfs[i].GetName()] = mfs[i].Metric[0].GetGauge().GetValue() 41 | } 42 | for k, v := range expRes { 43 | val, ok := res[k] 44 | if !ok { 45 | t.Fatalf("Expected metric %v not found in returned metrics", k) 46 | } 47 | if val != v { 48 | t.Fatalf("Expected: %v: %v, got: %v: %v", k, v, k, val) 49 | } 50 | } 51 | } 52 | 53 | // Check if expected labels are in the registry 54 | func checkRegistryLabels(expRes map[string]map[string]string, mfs []*dto.MetricFamily, t *testing.T) { 55 | results := make(map[string]map[string]string) 56 | for _, mf := range mfs { 57 | result := make(map[string]string) 58 | for _, metric := range mf.Metric { 59 | for _, l := range metric.GetLabel() { 60 | result[l.GetName()] = l.GetValue() 61 | } 62 | } 63 | results[mf.GetName()] = result 64 | } 65 | 66 | for metric, labelValues := range expRes { 67 | if _, ok := results[metric]; !ok { 68 | t.Fatalf("Expected metric %v not found in returned metrics", metric) 69 | } 70 | for name, exp := range labelValues { 71 | val, ok := results[metric][name] 72 | if !ok { 73 | t.Fatalf("Expected label %v for metric %v not found in returned metrics", val, name) 74 | } 75 | if val != exp { 76 | t.Fatalf("Expected: %v{%q=%q}, got: %v{%q=%q}", metric, name, exp, metric, name, val) 77 | } 78 | } 79 | } 80 | } 81 | 82 | func generateCertificateTemplate(expiry time.Time, IPAddressSAN bool) *x509.Certificate { 83 | template := &x509.Certificate{ 84 | BasicConstraintsValid: true, 85 | SubjectKeyId: []byte{1}, 86 | SerialNumber: big.NewInt(1), 87 | Subject: pkix.Name{ 88 | CommonName: "Example", 89 | Organization: []string{"Example Org"}, 90 | }, 91 | NotBefore: time.Now(), 92 | NotAfter: expiry, 93 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, 94 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 95 | } 96 | 97 | template.DNSNames = append(template.DNSNames, "localhost") 98 | if IPAddressSAN { 99 | template.IPAddresses = append(template.IPAddresses, net.ParseIP("127.0.0.1")) 100 | template.IPAddresses = append(template.IPAddresses, net.ParseIP("::1")) 101 | } 102 | 103 | return template 104 | } 105 | 106 | func generateCertificate(template, parent *x509.Certificate, publickey *rsa.PublicKey, privatekey *rsa.PrivateKey) (*x509.Certificate, []byte) { 107 | derCert, err := x509.CreateCertificate(rand.Reader, template, template, publickey, privatekey) 108 | if err != nil { 109 | panic(fmt.Sprintf("Error signing test-certificate: %s", err)) 110 | } 111 | cert, err := x509.ParseCertificate(derCert) 112 | if err != nil { 113 | panic(fmt.Sprintf("Error parsing test-certificate: %s", err)) 114 | } 115 | pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derCert}) 116 | return cert, pemCert 117 | 118 | } 119 | 120 | func generateSignedCertificate(template, parentCert *x509.Certificate, parentKey *rsa.PrivateKey) (*x509.Certificate, []byte, *rsa.PrivateKey) { 121 | privatekey, err := rsa.GenerateKey(rand.Reader, 2048) 122 | if err != nil { 123 | panic(fmt.Sprintf("Error creating rsa key: %s", err)) 124 | } 125 | cert, pemCert := generateCertificate(template, parentCert, &privatekey.PublicKey, parentKey) 126 | return cert, pemCert, privatekey 127 | } 128 | 129 | func generateSelfSignedCertificate(template *x509.Certificate) (*x509.Certificate, []byte, *rsa.PrivateKey) { 130 | privatekey, err := rsa.GenerateKey(rand.Reader, 2048) 131 | if err != nil { 132 | panic(fmt.Sprintf("Error creating rsa key: %s", err)) 133 | } 134 | publickey := &privatekey.PublicKey 135 | 136 | cert, pemCert := generateCertificate(template, template, publickey, privatekey) 137 | return cert, pemCert, privatekey 138 | } 139 | 140 | func generateSelfSignedCertificateWithPrivateKey(template *x509.Certificate, privatekey *rsa.PrivateKey) (*x509.Certificate, []byte) { 141 | publickey := &privatekey.PublicKey 142 | cert, pemCert := generateCertificate(template, template, publickey, privatekey) 143 | return cert, pemCert 144 | } 145 | 146 | func TestChooseProtocol(t *testing.T) { 147 | if testing.Short() { 148 | t.Skip("skipping network dependent test") 149 | } 150 | ctx := context.Background() 151 | registry := prometheus.NewPedanticRegistry() 152 | logger := promslog.New(&promslog.Config{}) 153 | 154 | ip, _, err := chooseProtocol(ctx, "ip4", true, "ipv6.google.com", registry, logger) 155 | if err != nil { 156 | t.Error(err) 157 | } 158 | if ip == nil || ip.IP.To4() != nil { 159 | t.Error("with fallback it should answer") 160 | } 161 | 162 | registry = prometheus.NewPedanticRegistry() 163 | 164 | ip, _, err = chooseProtocol(ctx, "ip4", false, "ipv6.google.com", registry, logger) 165 | if err != nil && !err.(*net.DNSError).IsNotFound { 166 | t.Error(err) 167 | } else if err == nil { 168 | t.Error("should set error") 169 | } 170 | if ip != nil { 171 | t.Error("without fallback it should not answer") 172 | } 173 | } 174 | 175 | func checkMetrics(expected map[string]map[string]map[string]struct{}, mfs []*dto.MetricFamily, t *testing.T) { 176 | type ( 177 | valueValidation struct { 178 | found bool 179 | } 180 | labelValidation struct { 181 | found bool 182 | values map[string]valueValidation 183 | } 184 | metricValidation struct { 185 | found bool 186 | labels map[string]labelValidation 187 | } 188 | ) 189 | 190 | foundMetrics := map[string]metricValidation{} 191 | 192 | for mname, labels := range expected { 193 | var mv metricValidation 194 | if labels != nil { 195 | mv.labels = map[string]labelValidation{} 196 | for lname, values := range labels { 197 | var lv labelValidation 198 | if values != nil { 199 | lv.values = map[string]valueValidation{} 200 | for vname := range values { 201 | lv.values[vname] = valueValidation{} 202 | } 203 | } 204 | mv.labels[lname] = lv 205 | } 206 | } 207 | foundMetrics[mname] = mv 208 | } 209 | 210 | for _, mf := range mfs { 211 | info, wanted := foundMetrics[mf.GetName()] 212 | if !wanted { 213 | continue 214 | } 215 | info.found = true 216 | for _, metric := range mf.GetMetric() { 217 | if info.labels == nil { 218 | continue 219 | } 220 | for _, lp := range metric.Label { 221 | if label, labelWanted := info.labels[lp.GetName()]; labelWanted { 222 | label.found = true 223 | if label.values != nil { 224 | if value, wanted := label.values[lp.GetValue()]; !wanted { 225 | t.Fatalf("Unexpected label %s=%s", lp.GetName(), lp.GetValue()) 226 | } else if value.found { 227 | t.Fatalf("Label %s=%s duplicated", lp.GetName(), lp.GetValue()) 228 | } 229 | label.values[lp.GetValue()] = valueValidation{found: true} 230 | } 231 | info.labels[lp.GetName()] = label 232 | } 233 | } 234 | } 235 | foundMetrics[mf.GetName()] = info 236 | } 237 | 238 | for mname, m := range foundMetrics { 239 | if !m.found { 240 | t.Fatalf("metric %s wanted, not found", mname) 241 | } 242 | for lname, label := range m.labels { 243 | if !label.found { 244 | t.Fatalf("metric %s, label %s wanted, not found", mname, lname) 245 | } 246 | for vname, value := range label.values { 247 | if !value.found { 248 | t.Fatalf("metric %s, label %s, value %s wanted, not found", mname, lname, vname) 249 | } 250 | } 251 | } 252 | } 253 | } 254 | 255 | func TestGetSerialNumber(t *testing.T) { 256 | tests := []struct { 257 | name string 258 | serialNumber *big.Int 259 | expected string 260 | }{ 261 | { 262 | name: "Serial number with leading zeros", 263 | serialNumber: func() *big.Int { 264 | serialNumber, _ := new(big.Int).SetString("0BFFBC11F1907D02AF719AFCD64FB253", 16) 265 | return serialNumber 266 | }(), 267 | expected: "0bffbc11f1907d02af719afcd64fb253", 268 | }, 269 | { 270 | name: "Serial number without leading zeros", 271 | serialNumber: func() *big.Int { 272 | serialNumber, _ := new(big.Int).SetString("BBFFBC11F1907D02AF719AFCD64FB253", 16) 273 | return serialNumber 274 | }(), 275 | expected: "bbffbc11f1907d02af719afcd64fb253", 276 | }, 277 | } 278 | 279 | for _, tt := range tests { 280 | t.Run(tt.name, func(t *testing.T) { 281 | cert := &x509.Certificate{ 282 | SerialNumber: tt.serialNumber, 283 | } 284 | state := &tls.ConnectionState{ 285 | PeerCertificates: []*x509.Certificate{cert}, 286 | } 287 | result := getSerialNumber(state) 288 | if result != tt.expected { 289 | t.Errorf("expected %s, got %s", tt.expected, result) 290 | } 291 | }) 292 | } 293 | } 294 | 295 | func checkAbsentMetrics(absent []string, mfs []*dto.MetricFamily, t *testing.T) { 296 | for _, v := range mfs { 297 | name := v.GetName() 298 | if slices.Contains(absent, name) { 299 | t.Fatalf("metric %s was found but should be absent", name) 300 | } 301 | } 302 | } 303 | --------------------------------------------------------------------------------