├── .github ├── dependabot.yaml └── workflows │ └── main.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yaml ├── fixtures ├── destinations.json ├── devices.json ├── dnssec.json ├── domains.json ├── encryption.json ├── ip_versions.json ├── protocols.json ├── query_types.json └── status.json ├── go.mod ├── go.sum ├── grafana ├── dashboards │ ├── local.yaml │ ├── nextdns.json │ └── nextdns.png ├── datasources │ └── automatic.yaml ├── grafana.ini └── prometheus.yaml ├── internal ├── api │ ├── client.go │ ├── destinations.go │ ├── destinations_test.go │ ├── devices.go │ ├── devices_test.go │ ├── dnssec.go │ ├── dnssec_test.go │ ├── domains.go │ ├── domains_test.go │ ├── encryption.go │ ├── encryption_test.go │ ├── ip_versions.go │ ├── ip_versions_test.go │ ├── protocols.go │ ├── protocols_test.go │ ├── query_types.go │ ├── query_types_test.go │ ├── status.go │ └── status_test.go └── util │ ├── config.go │ ├── util.go │ └── util_test.go └── main.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | 8 | - package-ecosystem: gomod 9 | directory: / 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - '*' 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | GO_VERSION: ^1.19.3 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: ${{ env.GO_VERSION }} 29 | 30 | - name: Run tests 31 | run: go test -v ./... 32 | env: 33 | # False values to satisfy environment checks. 34 | NEXTDNS_PROFILE: bhu7e3 35 | NEXTDNS_API_KEY: 1234567890abcdef 36 | 37 | release: 38 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 39 | needs: test 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | with: 45 | fetch-depth: 0 46 | 47 | - name: Set up Go 48 | uses: actions/setup-go@v5 49 | with: 50 | go-version: ${{ env.GO_VERSION }} 51 | 52 | - name: Configure environment 53 | id: context 54 | run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} 55 | 56 | - name: Release 57 | uses: goreleaser/goreleaser-action@v5 58 | with: 59 | distribution: goreleaser 60 | version: latest 61 | args: release --rm-dist 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | 65 | - name: Set up QEMU 66 | uses: docker/setup-qemu-action@v3 67 | 68 | - name: Set up Docker Buildx 69 | uses: docker/setup-buildx-action@v3 70 | 71 | - name: Log in to registry 72 | run: | 73 | docker login \ 74 | -u ${{ github.actor }} \ 75 | -p ${{ secrets.GITHUB_TOKEN }} \ 76 | ${{ env.REGISTRY }} 77 | 78 | - name: Set image metadata 79 | id: meta 80 | uses: docker/metadata-action@v5 81 | with: 82 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 83 | tags: | 84 | type=raw,value=${{ steps.context.outputs.tag }} 85 | flavor: | 86 | latest=true 87 | 88 | - name: Build and push image 89 | uses: docker/build-push-action@v6 90 | with: 91 | context: . 92 | platforms: linux/amd64,linux/arm64 93 | push: true 94 | tags: ${{ steps.meta.outputs.tags }} 95 | labels: ${{ steps.meta.outputs.labels }} 96 | build-args: | 97 | VERSION=${{ steps.context.outputs.tag }} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | TODO.md 2 | nextdns-exporter 3 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | ldflags: 5 | - -extldflags "-static" 6 | flags: 7 | - -trimpath 8 | goos: 9 | - linux 10 | - darwin 11 | goarch: 12 | - amd64 13 | - arm64 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21.5-alpine3.19 AS build 2 | WORKDIR /src 3 | COPY . /src 4 | ARG VERSION 5 | RUN CGO_ENABLED=0 \ 6 | GOOS=linux \ 7 | go build \ 8 | -ldflags="-s -w -X 'main.version=$VERSION'" \ 9 | -o nextdns-exporter 10 | 11 | FROM alpine:3.19 AS src 12 | COPY --from=build /src/nextdns-exporter . 13 | ENTRYPOINT /nextdns-exporter 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Raymond Douglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextdns-exporter 2 | 3 | [![Main](https://github.com/raylas/nextdns-exporter/actions/workflows/main.yaml/badge.svg)](https://github.com/raylas/nextdns-exporter/actions/workflows/main.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/raylas/nextdns-exporter)](https://goreportcard.com/report/github.com/raylas/nextdns-exporter) 5 | 6 | A [Prometheus exporter](https://prometheus.io/docs/instrumenting/exporters/) for [NextDNS data](https://nextdns.github.io/api/#analytics). 7 | 8 | ## Configuration 9 | 10 | - `LOG_LEVEL` (default: `INFO`) 11 | - `METRICS_PORT` (default: `9948`) 12 | - `METRICS_PATH` (default: `/metrics`) 13 | - `NEXTDNS_RESULT_WINDOW` (default: `-5m`) 14 | - `NEXTDNS_RESULT_LIMIT` (default: `50`) 15 | - `NEXTDNS_PROFILE` (required: [docs](https://nextdns.github.io/api/#profile)) 16 | - `NEXTDNS_API_KEY` (required: [docs](https://nextdns.github.io/api/#authentication)) 17 | 18 | For most accurate data, the scrape interval _should_ match the value set via `NEXTDNS_RESULT_WINDOW`. 19 | 20 | Recommended scrape timeout is `10` seconds. 21 | 22 | ## Usage 23 | 24 | ### Binary 25 | 26 | Either [download a recent release](https://github.com/raylas/nextdns-exporter/releases) or compile the binary yourself (`go build -o nextdns-exporter`) and run: 27 | ```sh 28 | export NEXTDNS_PROFILE= 29 | export NEXTDNS_API_KEY= 30 | ./nextdns-exporter 31 | 2022-11-19T12:39:34.479-0800 [INFO] starting exporter: port=:9948 path=/metrics 32 | ``` 33 | 34 | ### Docker 35 | 36 | ```sh 37 | docker run -d \ 38 | -p 9948:9948 \ 39 | -e NEXTDNS_PROFILE= \ 40 | -e NEXTDNS_API_KEY= \ 41 | ghcr.io/raylas/nextdns-exporter 42 | ``` 43 | 44 | ### Compose 45 | 46 | The following will create a local stack of the exporter, Prometheus, and Grafana: 47 | ```sh 48 | NEXTDNS_PROFILE= \ 49 | NEXTDNS_API_KEY= \ 50 | docker-compose up -d 51 | ``` 52 | 53 | Access Grafana by navigating to [http://localhost:3000](http://localhost:3000). 54 | 55 | **Note:** Data will take a few minutes to trickle in. 56 | 57 | ## Dashboard 58 | 59 | A basic Grafana dashboard is found [here](/grafana/dashboards/nextdns.json). 60 | 61 | ![Grafana dashboard](/grafana/dashboards/nextdns.png) 62 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | nextdns-exporter: 3 | build: 4 | context: . 5 | restart: unless-stopped 6 | ports: 7 | - 9948:9948 8 | environment: 9 | NEXTDNS_PROFILE: ${NEXTDNS_PROFILE} 10 | NEXTDNS_API_KEY: ${NEXTDNS_API_KEY} 11 | NEXTDNS_RESULT_WINDOW: -1m 12 | 13 | prometheus: 14 | image: prom/prometheus:v2.40.3 15 | restart: unless-stopped 16 | ports: 17 | - 9090:9090 18 | volumes: 19 | - ./grafana/prometheus.yaml:/etc/prometheus/prometheus.yml 20 | 21 | grafana: 22 | image: grafana/grafana-oss:9.2.6 23 | restart: unless-stopped 24 | ports: 25 | - 3000:3000 26 | volumes: 27 | - ./grafana/grafana.ini:/etc/grafana/grafana.ini 28 | - ./grafana:/etc/grafana/provisioning 29 | -------------------------------------------------------------------------------- /fixtures/destinations.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "code": "US", 5 | "name": "United States of America", 6 | "domains": [ 7 | "weather-data.apple.com", 8 | "gateway.icloud.com", 9 | "mobile.events.data.microsoft.com", 10 | "ocsp2.apple.com" 11 | ], 12 | "queries": 137 13 | }, 14 | { 15 | "code": "DE", 16 | "name": "Germany", 17 | "domains": [ 18 | "controlplane.tailscale.com" 19 | ], 20 | "queries": 2 21 | }, 22 | { 23 | "code": "FR", 24 | "name": "France", 25 | "domains": [ 26 | "mobile.events.data.microsoft.com" 27 | ], 28 | "queries": 1 29 | } 30 | ], 31 | "meta": { 32 | "pagination": { 33 | "cursor": null 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /fixtures/devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": "E8TTX", 5 | "name": "Gaming PC", 6 | "model": "linux", 7 | "localIp": "192.168.1.100", 8 | "queries": 12 9 | }, 10 | { 11 | "id": "85C3A", 12 | "name": "iPhone", 13 | "model": "Apple, Inc.", 14 | "localIp": "192.168.1.105", 15 | "queries": 83 16 | } 17 | ], 18 | "meta": { 19 | "pagination": { 20 | "cursor": null 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /fixtures/dnssec.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "validated": false, 5 | "queries": 183 6 | }, 7 | { 8 | "validated": true, 9 | "queries": 4 10 | } 11 | ], 12 | "meta": { 13 | "pagination": { 14 | "cursor": null 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/domains.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "domain": "metrics.icloud.com", 5 | "root": "icloud.com", 6 | "queries": 15005 7 | }, 8 | { 9 | "domain": "app-measurement.com", 10 | "queries": 3922 11 | }, 12 | { 13 | "domain": "notify.bugsnag.com", 14 | "root": "bugsnag.com", 15 | "tracker": "bugsnag", 16 | "queries": 3760 17 | } 18 | ], 19 | "meta": { 20 | "pagination": { 21 | "cursor": "64r0" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /fixtures/encryption.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "encrypted": true, 5 | "queries": 48058 6 | }, 7 | { 8 | "encrypted": false, 9 | "queries": 1629 10 | } 11 | ], 12 | "meta": { 13 | "pagination": { 14 | "cursor": null 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/ip_versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "version": 4, 5 | "queries": 392 6 | }, 7 | { 8 | "version": 6, 9 | "queries": 10 10 | } 11 | ], 12 | "meta": { 13 | "pagination": { 14 | "cursor": null 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/protocols.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "protocol": "DNS-over-HTTPS", 5 | "queries": 17115 6 | }, 7 | { 8 | "protocol": "UDP", 9 | "queries": 354 10 | } 11 | ], 12 | "meta": { 13 | "pagination": { 14 | "cursor": null 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /fixtures/query_types.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": 1, 5 | "name": "A", 6 | "queries": 207 7 | }, 8 | { 9 | "type": 28, 10 | "name": "AAAA", 11 | "queries": 199 12 | }, 13 | { 14 | "type": 65, 15 | "name": "HTTPS", 16 | "queries": 87 17 | }, 18 | { 19 | "type": 12, 20 | "name": "PTR", 21 | "queries": 11 22 | }, 23 | { 24 | "type": 16, 25 | "name": "TXT", 26 | "queries": 5 27 | } 28 | ], 29 | "meta": { 30 | "pagination": { 31 | "cursor": null 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /fixtures/status.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "status": "default", 5 | "queries": 1587523 6 | }, 7 | { 8 | "status": "blocked", 9 | "queries": 80343 10 | }, 11 | { 12 | "status": "allowed", 13 | "queries": 478 14 | } 15 | ], 16 | "meta": { 17 | "pagination": { 18 | "cursor": null 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/raylas/nextdns-exporter 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/beorn7/perks v1.0.1 // indirect 7 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 8 | github.com/klauspost/compress v1.17.11 // indirect 9 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 10 | github.com/prometheus/client_model v0.6.1 // indirect 11 | github.com/prometheus/common v0.62.0 // indirect 12 | github.com/prometheus/procfs v0.15.1 // indirect 13 | google.golang.org/protobuf v1.36.1 // indirect 14 | ) 15 | 16 | require ( 17 | github.com/prometheus/client_golang v1.21.1 18 | golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc 19 | golang.org/x/sys v0.28.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 10 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 11 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 12 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 13 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 14 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 18 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 19 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 20 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 21 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 22 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 23 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 24 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 25 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 26 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 27 | golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= 28 | golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 29 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 30 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 32 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /grafana/dashboards/local.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: Local 5 | orgId: 1 6 | type: file 7 | disableDeletion: false 8 | updateIntervalSeconds: 10 9 | allowUiUpdates: false 10 | options: 11 | path: /etc/grafana/provisioning/dashboards 12 | -------------------------------------------------------------------------------- /grafana/dashboards/nextdns.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 0, 27 | "id": 2, 28 | "links": [], 29 | "liveNow": true, 30 | "panels": [ 31 | { 32 | "datasource": { 33 | "type": "prometheus", 34 | "uid": "${source}" 35 | }, 36 | "fieldConfig": { 37 | "defaults": { 38 | "color": { 39 | "fixedColor": "blue", 40 | "mode": "fixed" 41 | }, 42 | "custom": { 43 | "axisLabel": "", 44 | "axisPlacement": "auto", 45 | "barAlignment": 0, 46 | "drawStyle": "line", 47 | "fillOpacity": 25, 48 | "gradientMode": "none", 49 | "hideFrom": { 50 | "legend": false, 51 | "tooltip": false, 52 | "viz": false 53 | }, 54 | "lineInterpolation": "linear", 55 | "lineWidth": 1, 56 | "pointSize": 5, 57 | "scaleDistribution": { 58 | "type": "linear" 59 | }, 60 | "showPoints": "auto", 61 | "spanNulls": true, 62 | "stacking": { 63 | "group": "A", 64 | "mode": "none" 65 | }, 66 | "thresholdsStyle": { 67 | "mode": "off" 68 | } 69 | }, 70 | "mappings": [], 71 | "thresholds": { 72 | "mode": "absolute", 73 | "steps": [ 74 | { 75 | "color": "green", 76 | "value": null 77 | }, 78 | { 79 | "color": "red", 80 | "value": 80 81 | } 82 | ] 83 | } 84 | }, 85 | "overrides": [] 86 | }, 87 | "gridPos": { 88 | "h": 9, 89 | "w": 12, 90 | "x": 0, 91 | "y": 0 92 | }, 93 | "id": 2, 94 | "options": { 95 | "legend": { 96 | "calcs": [], 97 | "displayMode": "list", 98 | "placement": "bottom", 99 | "showLegend": false 100 | }, 101 | "tooltip": { 102 | "mode": "single", 103 | "sort": "none" 104 | } 105 | }, 106 | "targets": [ 107 | { 108 | "datasource": { 109 | "type": "prometheus", 110 | "uid": "${source}" 111 | }, 112 | "editorMode": "code", 113 | "expr": "nextdns_queries_total{profile=\"$profile\"}", 114 | "legendFormat": "All", 115 | "range": true, 116 | "refId": "all" 117 | } 118 | ], 119 | "title": "All Queries", 120 | "type": "timeseries" 121 | }, 122 | { 123 | "datasource": { 124 | "type": "prometheus", 125 | "uid": "${source}" 126 | }, 127 | "description": "", 128 | "fieldConfig": { 129 | "defaults": { 130 | "color": { 131 | "mode": "palette-classic" 132 | }, 133 | "custom": { 134 | "hideFrom": { 135 | "legend": false, 136 | "tooltip": false, 137 | "viz": false 138 | } 139 | }, 140 | "mappings": [] 141 | }, 142 | "overrides": [] 143 | }, 144 | "gridPos": { 145 | "h": 9, 146 | "w": 6, 147 | "x": 12, 148 | "y": 0 149 | }, 150 | "id": 6, 151 | "options": { 152 | "displayLabels": [], 153 | "legend": { 154 | "displayMode": "list", 155 | "placement": "bottom", 156 | "showLegend": false 157 | }, 158 | "pieType": "donut", 159 | "reduceOptions": { 160 | "calcs": [ 161 | "lastNotNull" 162 | ], 163 | "fields": "", 164 | "values": false 165 | }, 166 | "tooltip": { 167 | "mode": "single", 168 | "sort": "none" 169 | } 170 | }, 171 | "targets": [ 172 | { 173 | "datasource": { 174 | "type": "prometheus", 175 | "uid": "${source}" 176 | }, 177 | "editorMode": "code", 178 | "expr": "nextdns_blocked_queries{profile=\"$profile\"}", 179 | "legendFormat": "{{root}}", 180 | "range": true, 181 | "refId": "domains" 182 | } 183 | ], 184 | "title": "Domains", 185 | "type": "piechart" 186 | }, 187 | { 188 | "datasource": { 189 | "type": "prometheus", 190 | "uid": "${source}" 191 | }, 192 | "description": "", 193 | "fieldConfig": { 194 | "defaults": { 195 | "color": { 196 | "mode": "palette-classic" 197 | }, 198 | "custom": { 199 | "hideFrom": { 200 | "legend": false, 201 | "tooltip": false, 202 | "viz": false 203 | } 204 | }, 205 | "mappings": [] 206 | }, 207 | "overrides": [] 208 | }, 209 | "gridPos": { 210 | "h": 9, 211 | "w": 6, 212 | "x": 18, 213 | "y": 0 214 | }, 215 | "id": 7, 216 | "options": { 217 | "legend": { 218 | "displayMode": "list", 219 | "placement": "bottom", 220 | "showLegend": false 221 | }, 222 | "pieType": "donut", 223 | "reduceOptions": { 224 | "calcs": [ 225 | "lastNotNull" 226 | ], 227 | "fields": "", 228 | "values": false 229 | }, 230 | "tooltip": { 231 | "mode": "single", 232 | "sort": "none" 233 | } 234 | }, 235 | "targets": [ 236 | { 237 | "datasource": { 238 | "type": "prometheus", 239 | "uid": "${source}" 240 | }, 241 | "editorMode": "code", 242 | "expr": "nextdns_device_queries{profile=\"$profile\"}", 243 | "legendFormat": "{{name}}", 244 | "range": true, 245 | "refId": "devices" 246 | } 247 | ], 248 | "title": "Devices", 249 | "type": "piechart" 250 | }, 251 | { 252 | "datasource": { 253 | "type": "prometheus", 254 | "uid": "${source}" 255 | }, 256 | "fieldConfig": { 257 | "defaults": { 258 | "color": { 259 | "fixedColor": "red", 260 | "mode": "continuous-GrYlRd" 261 | }, 262 | "mappings": [], 263 | "thresholds": { 264 | "mode": "absolute", 265 | "steps": [ 266 | { 267 | "color": "green", 268 | "value": null 269 | }, 270 | { 271 | "color": "red", 272 | "value": 80 273 | } 274 | ] 275 | }, 276 | "unit": "percentunit" 277 | }, 278 | "overrides": [] 279 | }, 280 | "gridPos": { 281 | "h": 7, 282 | "w": 6, 283 | "x": 0, 284 | "y": 9 285 | }, 286 | "id": 10, 287 | "options": { 288 | "orientation": "auto", 289 | "reduceOptions": { 290 | "calcs": [ 291 | "lastNotNull" 292 | ], 293 | "fields": "", 294 | "values": false 295 | }, 296 | "showThresholdLabels": false, 297 | "showThresholdMarkers": false 298 | }, 299 | "pluginVersion": "9.0.2", 300 | "targets": [ 301 | { 302 | "datasource": { 303 | "type": "prometheus", 304 | "uid": "${source}" 305 | }, 306 | "editorMode": "code", 307 | "expr": "sum(increase(nextdns_blocked_queries_total{profile=\"$profile\"}[$__range]))", 308 | "hide": true, 309 | "legendFormat": "Blocked", 310 | "range": true, 311 | "refId": "blocked" 312 | }, 313 | { 314 | "datasource": { 315 | "type": "prometheus", 316 | "uid": "${source}" 317 | }, 318 | "editorMode": "code", 319 | "expr": "sum(increase(nextdns_queries_total{profile=\"$profile\"}[$__range]))", 320 | "hide": true, 321 | "legendFormat": "Total", 322 | "range": true, 323 | "refId": "total" 324 | }, 325 | { 326 | "conditions": [ 327 | { 328 | "evaluator": { 329 | "params": [ 330 | 0, 331 | 0 332 | ], 333 | "type": "gt" 334 | }, 335 | "operator": { 336 | "type": "and" 337 | }, 338 | "query": { 339 | "params": [] 340 | }, 341 | "reducer": { 342 | "params": [], 343 | "type": "avg" 344 | }, 345 | "type": "query" 346 | } 347 | ], 348 | "datasource": { 349 | "name": "Expression", 350 | "type": "__expr__", 351 | "uid": "__expr__" 352 | }, 353 | "expression": "$blocked / $total\n", 354 | "hide": false, 355 | "refId": "percentage", 356 | "type": "math" 357 | } 358 | ], 359 | "title": "Blocked Queries %", 360 | "type": "gauge" 361 | }, 362 | { 363 | "datasource": { 364 | "type": "prometheus", 365 | "uid": "${source}" 366 | }, 367 | "fieldConfig": { 368 | "defaults": { 369 | "color": { 370 | "fixedColor": "red", 371 | "mode": "palette-classic" 372 | }, 373 | "mappings": [], 374 | "thresholds": { 375 | "mode": "absolute", 376 | "steps": [ 377 | { 378 | "color": "green", 379 | "value": null 380 | }, 381 | { 382 | "color": "red", 383 | "value": 80 384 | } 385 | ] 386 | }, 387 | "unit": "none" 388 | }, 389 | "overrides": [] 390 | }, 391 | "gridPos": { 392 | "h": 7, 393 | "w": 6, 394 | "x": 6, 395 | "y": 9 396 | }, 397 | "id": 11, 398 | "options": { 399 | "colorMode": "value", 400 | "graphMode": "none", 401 | "justifyMode": "auto", 402 | "orientation": "auto", 403 | "reduceOptions": { 404 | "calcs": [ 405 | "lastNotNull" 406 | ], 407 | "fields": "", 408 | "values": false 409 | }, 410 | "textMode": "auto" 411 | }, 412 | "pluginVersion": "9.0.2", 413 | "targets": [ 414 | { 415 | "datasource": { 416 | "type": "prometheus", 417 | "uid": "${source}" 418 | }, 419 | "editorMode": "code", 420 | "expr": "sum(increase(nextdns_queries_total{profile=\"$profile\"}[$__range]))", 421 | "hide": false, 422 | "legendFormat": "Total", 423 | "range": true, 424 | "refId": "total" 425 | } 426 | ], 427 | "title": "Total Queries", 428 | "type": "stat" 429 | }, 430 | { 431 | "datasource": { 432 | "type": "prometheus", 433 | "uid": "${source}" 434 | }, 435 | "fieldConfig": { 436 | "defaults": { 437 | "color": { 438 | "mode": "palette-classic" 439 | }, 440 | "custom": { 441 | "axisLabel": "", 442 | "axisPlacement": "auto", 443 | "barAlignment": 0, 444 | "drawStyle": "line", 445 | "fillOpacity": 12, 446 | "gradientMode": "none", 447 | "hideFrom": { 448 | "legend": false, 449 | "tooltip": false, 450 | "viz": false 451 | }, 452 | "lineInterpolation": "linear", 453 | "lineWidth": 1, 454 | "pointSize": 5, 455 | "scaleDistribution": { 456 | "type": "linear" 457 | }, 458 | "showPoints": "auto", 459 | "spanNulls": true, 460 | "stacking": { 461 | "group": "A", 462 | "mode": "none" 463 | }, 464 | "thresholdsStyle": { 465 | "mode": "off" 466 | } 467 | }, 468 | "mappings": [], 469 | "thresholds": { 470 | "mode": "absolute", 471 | "steps": [ 472 | { 473 | "color": "green", 474 | "value": null 475 | }, 476 | { 477 | "color": "red", 478 | "value": 80 479 | } 480 | ] 481 | } 482 | }, 483 | "overrides": [] 484 | }, 485 | "gridPos": { 486 | "h": 7, 487 | "w": 12, 488 | "x": 12, 489 | "y": 9 490 | }, 491 | "id": 4, 492 | "options": { 493 | "legend": { 494 | "calcs": [], 495 | "displayMode": "list", 496 | "placement": "bottom", 497 | "showLegend": true 498 | }, 499 | "tooltip": { 500 | "mode": "single", 501 | "sort": "none" 502 | } 503 | }, 504 | "targets": [ 505 | { 506 | "datasource": { 507 | "type": "prometheus", 508 | "uid": "${source}" 509 | }, 510 | "editorMode": "code", 511 | "expr": "nextdns_type_queries{profile=\"$profile\"}", 512 | "legendFormat": "{{name}}", 513 | "range": true, 514 | "refId": "types" 515 | } 516 | ], 517 | "title": "Query Types", 518 | "type": "timeseries" 519 | }, 520 | { 521 | "datasource": { 522 | "type": "prometheus", 523 | "uid": "${source}" 524 | }, 525 | "fieldConfig": { 526 | "defaults": { 527 | "color": { 528 | "fixedColor": "red", 529 | "mode": "fixed" 530 | }, 531 | "custom": { 532 | "axisLabel": "", 533 | "axisPlacement": "auto", 534 | "barAlignment": 0, 535 | "drawStyle": "line", 536 | "fillOpacity": 25, 537 | "gradientMode": "none", 538 | "hideFrom": { 539 | "legend": false, 540 | "tooltip": false, 541 | "viz": false 542 | }, 543 | "lineInterpolation": "linear", 544 | "lineWidth": 1, 545 | "pointSize": 5, 546 | "scaleDistribution": { 547 | "type": "linear" 548 | }, 549 | "showPoints": "auto", 550 | "spanNulls": true, 551 | "stacking": { 552 | "group": "A", 553 | "mode": "none" 554 | }, 555 | "thresholdsStyle": { 556 | "mode": "off" 557 | } 558 | }, 559 | "mappings": [], 560 | "thresholds": { 561 | "mode": "absolute", 562 | "steps": [ 563 | { 564 | "color": "green", 565 | "value": null 566 | }, 567 | { 568 | "color": "red", 569 | "value": 80 570 | } 571 | ] 572 | } 573 | }, 574 | "overrides": [] 575 | }, 576 | "gridPos": { 577 | "h": 7, 578 | "w": 12, 579 | "x": 0, 580 | "y": 16 581 | }, 582 | "id": 8, 583 | "options": { 584 | "legend": { 585 | "calcs": [], 586 | "displayMode": "list", 587 | "placement": "bottom", 588 | "showLegend": false 589 | }, 590 | "tooltip": { 591 | "mode": "single", 592 | "sort": "none" 593 | } 594 | }, 595 | "targets": [ 596 | { 597 | "datasource": { 598 | "type": "prometheus", 599 | "uid": "${source}" 600 | }, 601 | "editorMode": "code", 602 | "expr": "nextdns_blocked_queries_total{profile=\"$profile\"}", 603 | "legendFormat": "Blocked", 604 | "range": true, 605 | "refId": "blocked" 606 | } 607 | ], 608 | "title": "Blocked Queries", 609 | "type": "timeseries" 610 | }, 611 | { 612 | "datasource": { 613 | "type": "prometheus", 614 | "uid": "${source}" 615 | }, 616 | "fieldConfig": { 617 | "defaults": { 618 | "color": { 619 | "mode": "palette-classic" 620 | }, 621 | "custom": { 622 | "axisLabel": "", 623 | "axisPlacement": "auto", 624 | "barAlignment": 0, 625 | "drawStyle": "bars", 626 | "fillOpacity": 100, 627 | "gradientMode": "hue", 628 | "hideFrom": { 629 | "legend": false, 630 | "tooltip": false, 631 | "viz": false 632 | }, 633 | "lineInterpolation": "stepBefore", 634 | "lineWidth": 1, 635 | "pointSize": 5, 636 | "scaleDistribution": { 637 | "type": "linear" 638 | }, 639 | "showPoints": "auto", 640 | "spanNulls": false, 641 | "stacking": { 642 | "group": "A", 643 | "mode": "none" 644 | }, 645 | "thresholdsStyle": { 646 | "mode": "off" 647 | } 648 | }, 649 | "mappings": [], 650 | "thresholds": { 651 | "mode": "absolute", 652 | "steps": [ 653 | { 654 | "color": "green", 655 | "value": null 656 | }, 657 | { 658 | "color": "red", 659 | "value": 80 660 | } 661 | ] 662 | } 663 | }, 664 | "overrides": [] 665 | }, 666 | "gridPos": { 667 | "h": 7, 668 | "w": 12, 669 | "x": 12, 670 | "y": 16 671 | }, 672 | "id": 9, 673 | "options": { 674 | "legend": { 675 | "calcs": [], 676 | "displayMode": "list", 677 | "placement": "bottom", 678 | "showLegend": true 679 | }, 680 | "tooltip": { 681 | "mode": "single", 682 | "sort": "none" 683 | } 684 | }, 685 | "targets": [ 686 | { 687 | "datasource": { 688 | "type": "prometheus", 689 | "uid": "${source}" 690 | }, 691 | "editorMode": "code", 692 | "expr": "nextdns_ip_version_queries{profile=\"$profile\"}", 693 | "legendFormat": "IPv{{version}}", 694 | "range": true, 695 | "refId": "ip_versions" 696 | } 697 | ], 698 | "title": "IP Versions", 699 | "type": "timeseries" 700 | } 701 | ], 702 | "schemaVersion": 36, 703 | "style": "dark", 704 | "tags": [ 705 | "nextdns" 706 | ], 707 | "templating": { 708 | "list": [ 709 | { 710 | "current": { 711 | "selected": true, 712 | "text": "Prometheus", 713 | "value": "Prometheus" 714 | }, 715 | "hide": 0, 716 | "includeAll": false, 717 | "multi": false, 718 | "name": "source", 719 | "options": [], 720 | "query": "prometheus", 721 | "queryValue": "", 722 | "refresh": 1, 723 | "regex": "", 724 | "skipUrlSync": false, 725 | "type": "datasource" 726 | }, 727 | { 728 | "current": { 729 | "selected": false 730 | }, 731 | "datasource": { 732 | "type": "prometheus" 733 | }, 734 | "definition": "", 735 | "hide": 0, 736 | "includeAll": false, 737 | "multi": false, 738 | "name": "profile", 739 | "options": [], 740 | "query": { 741 | "query": "label_values(profile)", 742 | "refId": "StandardVariableQuery" 743 | }, 744 | "refresh": 1, 745 | "regex": "", 746 | "skipUrlSync": false, 747 | "sort": 0, 748 | "type": "query" 749 | } 750 | ] 751 | }, 752 | "time": { 753 | "from": "now-6h", 754 | "to": "now" 755 | }, 756 | "timepicker": {}, 757 | "timezone": "", 758 | "title": "NextDNS", 759 | "uid": "MwiWGgFVz", 760 | "version": 12, 761 | "weekStart": "" 762 | } 763 | -------------------------------------------------------------------------------- /grafana/dashboards/nextdns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raylas/nextdns-exporter/dbe9fc3398d3d599da13d1274179c40677ff8e9c/grafana/dashboards/nextdns.png -------------------------------------------------------------------------------- /grafana/datasources/automatic.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | isDefault: true 9 | editable: true 10 | -------------------------------------------------------------------------------- /grafana/grafana.ini: -------------------------------------------------------------------------------- 1 | [auth.anonymous] 2 | enabled = true 3 | org_role = Admin 4 | 5 | [dashboards] 6 | default_home_dashboard_path = /etc/grafana/provisioning/dashboards/nextdns.json 7 | -------------------------------------------------------------------------------- /grafana/prometheus.yaml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: nextdns 3 | scrape_interval: 1m 4 | static_configs: 5 | - targets: [nextdns-exporter:9948] 6 | -------------------------------------------------------------------------------- /internal/api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type Client struct { 12 | url string 13 | profile string 14 | apiKey string 15 | } 16 | 17 | func NewClient(url, profile, apiKey string) Client { 18 | return Client{url, profile, apiKey} 19 | } 20 | 21 | func (c Client) Request(uri string, params url.Values) ([]byte, error) { 22 | query := url.Values{ 23 | "from": {util.ResultWindow}, 24 | "limit": {util.ResultLimit}, 25 | } 26 | for k, v := range params { 27 | query.Add(k, v[0]) 28 | } 29 | 30 | req, err := http.NewRequest("GET", uri, nil) 31 | if err != nil { 32 | util.Log.Error("error creating request", "error", err) 33 | return nil, err 34 | } 35 | req.Header.Set("X-Api-Key", c.apiKey) 36 | req.URL.RawQuery = query.Encode() 37 | 38 | res, err := http.DefaultClient.Do(req) 39 | if err != nil { 40 | util.Log.Error("error making request", "error", err) 41 | return nil, err 42 | } 43 | 44 | body, err := io.ReadAll(res.Body) 45 | if err != nil { 46 | util.Log.Error("error reading response body", "error", err) 47 | return nil, err 48 | } 49 | 50 | return body, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/api/destinations.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type DestinationsResponse struct { 12 | Destinations []Destination `json:"data"` 13 | } 14 | 15 | type Destination struct { 16 | Code string `json:"code"` 17 | Name string `json:"name"` 18 | Queries int `json:"queries"` 19 | } 20 | 21 | type DestinationsMetrics struct { 22 | Destinations []DestinationMetric 23 | } 24 | 25 | type DestinationMetric struct { 26 | Code string 27 | Name string 28 | Queries float64 29 | } 30 | 31 | func (c Client) CollectDestinations() (*DestinationsMetrics, error) { 32 | destinationsURL := fmt.Sprintf("%s/profiles/%s/analytics/destinations", c.url, c.profile) 33 | 34 | destinationsResponse := DestinationsResponse{} 35 | metrics := DestinationsMetrics{} 36 | 37 | params := url.Values{ 38 | "type": {"countries"}, 39 | } 40 | 41 | body, err := c.Request(destinationsURL, params) 42 | if err != nil { 43 | util.Log.Error("error making request", "error", err) 44 | return nil, err 45 | } 46 | 47 | err = json.Unmarshal(body, &destinationsResponse) 48 | if err != nil { 49 | util.Log.Error("error unmarshalling response body", "error", err) 50 | return nil, err 51 | } 52 | 53 | for _, destination := range destinationsResponse.Destinations { 54 | destination := DestinationMetric{ 55 | Code: destination.Code, 56 | Name: destination.Name, 57 | Queries: float64(destination.Queries), 58 | } 59 | 60 | metrics.Destinations = append(metrics.Destinations, destination) 61 | } 62 | 63 | return &metrics, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/api/destinations_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectDestinations(t *testing.T) { 13 | expected := &DestinationsMetrics{ 14 | Destinations: []DestinationMetric{ 15 | { 16 | Code: "US", 17 | Name: "United States of America", 18 | Queries: 137, 19 | }, 20 | { 21 | Code: "DE", 22 | Name: "Germany", 23 | Queries: 2, 24 | }, 25 | { 26 | Code: "FR", 27 | Name: "France", 28 | Queries: 1, 29 | }, 30 | }, 31 | } 32 | 33 | destinations, err := os.ReadFile("../../fixtures/destinations.json") 34 | if err != nil { 35 | t.Errorf("error reading file: %v", err) 36 | } 37 | 38 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | fmt.Fprint(w, string(destinations)) 40 | })) 41 | defer svr.Close() 42 | 43 | c := NewClient(svr.URL, "profile", "apikey") 44 | res, err := c.CollectDestinations() 45 | if err != nil { 46 | t.Errorf("error collecting destinations data: %v", err) 47 | } 48 | 49 | if !reflect.DeepEqual(res, expected) { 50 | t.Errorf("expected %v, got %v", expected, res) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/api/devices.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/raylas/nextdns-exporter/internal/util" 8 | ) 9 | 10 | type DevicesResponse struct { 11 | Devices []Device `json:"data"` 12 | } 13 | 14 | type Device struct { 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | Model string `json:"model"` 18 | LocalIP string `json:"localIp"` 19 | Queries int `json:"queries"` 20 | } 21 | 22 | type DevicesMetrics struct { 23 | Devices []DeviceMetric 24 | } 25 | 26 | type DeviceMetric struct { 27 | ID string 28 | Name string 29 | Model string 30 | LocalIP string 31 | Queries float64 32 | } 33 | 34 | func (c Client) CollectDevices() (*DevicesMetrics, error) { 35 | devicesURL := fmt.Sprintf("%s/profiles/%s/analytics/devices", c.url, c.profile) 36 | 37 | devicesResponse := DevicesResponse{} 38 | metrics := DevicesMetrics{} 39 | 40 | body, err := c.Request(devicesURL, nil) 41 | if err != nil { 42 | util.Log.Error("error making request", "error", err) 43 | return nil, err 44 | } 45 | 46 | err = json.Unmarshal(body, &devicesResponse) 47 | if err != nil { 48 | util.Log.Error("error unmarshalling response body", "error", err) 49 | return nil, err 50 | } 51 | 52 | for _, device := range devicesResponse.Devices { 53 | device := DeviceMetric{ 54 | ID: device.ID, 55 | Name: device.Name, 56 | Model: device.Model, 57 | LocalIP: device.LocalIP, 58 | Queries: float64(device.Queries), 59 | } 60 | 61 | metrics.Devices = append(metrics.Devices, device) 62 | } 63 | 64 | return &metrics, nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/api/devices_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectDevices(t *testing.T) { 13 | expected := &DevicesMetrics{ 14 | Devices: []DeviceMetric{ 15 | { 16 | ID: "E8TTX", 17 | Name: "Gaming PC", 18 | Model: "linux", 19 | LocalIP: "192.168.1.100", 20 | Queries: 12, 21 | }, 22 | { 23 | ID: "85C3A", 24 | Name: "iPhone", 25 | Model: "Apple, Inc.", 26 | LocalIP: "192.168.1.105", 27 | Queries: 83, 28 | }, 29 | }, 30 | } 31 | 32 | devices, err := os.ReadFile("../../fixtures/devices.json") 33 | if err != nil { 34 | t.Errorf("error reading file: %v", err) 35 | } 36 | 37 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | fmt.Fprint(w, string(devices)) 39 | })) 40 | defer svr.Close() 41 | 42 | c := NewClient(svr.URL, "profile", "apikey") 43 | res, err := c.CollectDevices() 44 | if err != nil { 45 | t.Errorf("error collecting devices data: %v", err) 46 | } 47 | 48 | if !reflect.DeepEqual(res, expected) { 49 | t.Errorf("expected %v, got %v", expected, res) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /internal/api/dnssec.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type DNSSECResponse struct { 12 | Data []DNSSEC `json:"data"` 13 | } 14 | 15 | type DNSSEC struct { 16 | Validated bool `json:"validated"` 17 | Queries int `json:"queries"` 18 | } 19 | 20 | type DNSSECMetrics struct { 21 | Data []DNSSECMetric 22 | } 23 | 24 | type DNSSECMetric struct { 25 | Validated string 26 | Queries float64 27 | } 28 | 29 | func (c Client) CollectDNSSEC() (*DNSSECMetrics, error) { 30 | dnssecURL := fmt.Sprintf("%s/profiles/%s/analytics/dnssec", c.url, c.profile) 31 | 32 | dnssecResponse := DNSSECResponse{} 33 | metrics := DNSSECMetrics{} 34 | 35 | body, err := c.Request(dnssecURL, nil) 36 | if err != nil { 37 | util.Log.Error("error making request", "error", err) 38 | return nil, err 39 | } 40 | 41 | err = json.Unmarshal(body, &dnssecResponse) 42 | if err != nil { 43 | util.Log.Error("error unmarshalling response body", "error", err) 44 | return nil, err 45 | } 46 | 47 | for _, data := range dnssecResponse.Data { 48 | data := DNSSECMetric{ 49 | Validated: strconv.FormatBool(data.Validated), 50 | Queries: float64(data.Queries), 51 | } 52 | 53 | metrics.Data = append(metrics.Data, data) 54 | } 55 | 56 | return &metrics, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/dnssec_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectDNSSEC(t *testing.T) { 13 | expected := &DNSSECMetrics{ 14 | Data: []DNSSECMetric{ 15 | { 16 | Validated: "false", 17 | Queries: 183, 18 | }, 19 | { 20 | Validated: "true", 21 | Queries: 4, 22 | }, 23 | }, 24 | } 25 | 26 | ipVersions, err := os.ReadFile("../../fixtures/dnssec.json") 27 | if err != nil { 28 | t.Errorf("error reading file: %v", err) 29 | } 30 | 31 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | fmt.Fprint(w, string(ipVersions)) 33 | })) 34 | defer svr.Close() 35 | 36 | c := NewClient(svr.URL, "profile", "apikey") 37 | res, err := c.CollectDNSSEC() 38 | if err != nil { 39 | t.Errorf("error collecting DNSSEC data: %v", err) 40 | } 41 | 42 | if !reflect.DeepEqual(res, expected) { 43 | t.Errorf("expected %v, got %v", expected, res) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/api/domains.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type DomainsResponse struct { 12 | Domains []Domain `json:"data"` 13 | } 14 | 15 | type Domain struct { 16 | Domain string `json:"domain"` 17 | Root string `json:"root"` 18 | Tracker string `json:"tracker"` 19 | Queries int `json:"queries"` 20 | } 21 | 22 | type DomainsMetrics struct { 23 | BlockedDomains []DomainMetric 24 | } 25 | 26 | type DomainMetric struct { 27 | Domain string 28 | Root string 29 | Tracker string 30 | Queries float64 31 | } 32 | 33 | func (c Client) CollectDomains() (*DomainsMetrics, error) { 34 | domainsURL := fmt.Sprintf("%s/profiles/%s/analytics/domains", c.url, c.profile) 35 | 36 | domainsResponse := DomainsResponse{} 37 | metrics := DomainsMetrics{} 38 | 39 | params := url.Values{ 40 | "status": {"blocked"}, 41 | } 42 | 43 | body, err := c.Request(domainsURL, params) 44 | if err != nil { 45 | util.Log.Error("error making request", "error", err) 46 | return nil, err 47 | } 48 | 49 | err = json.Unmarshal(body, &domainsResponse) 50 | if err != nil { 51 | util.Log.Error("error unmarshalling response body", "error", err) 52 | return nil, err 53 | } 54 | 55 | for _, domain := range domainsResponse.Domains { 56 | // Some entries appear not to have a root, in which case replicate the domain. 57 | // https://github.com/raylas/nextdns-exporter/issues/20 58 | if len(domain.Root) == 0 { 59 | domain.Root = domain.Domain 60 | } 61 | domain := DomainMetric{ 62 | Domain: domain.Domain, 63 | Root: domain.Root, 64 | Tracker: domain.Tracker, 65 | Queries: float64(domain.Queries), 66 | } 67 | 68 | metrics.BlockedDomains = append(metrics.BlockedDomains, domain) 69 | } 70 | 71 | return &metrics, nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/api/domains_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectDomains(t *testing.T) { 13 | expected := &DomainsMetrics{ 14 | BlockedDomains: []DomainMetric{ 15 | { 16 | Domain: "metrics.icloud.com", 17 | Root: "icloud.com", 18 | Queries: 15005, 19 | }, 20 | { 21 | Domain: "app-measurement.com", 22 | Root: "app-measurement.com", 23 | Queries: 3922, 24 | }, 25 | { 26 | Domain: "notify.bugsnag.com", 27 | Root: "bugsnag.com", 28 | Tracker: "bugsnag", 29 | Queries: 3760, 30 | }, 31 | }, 32 | } 33 | 34 | domains, err := os.ReadFile("../../fixtures/domains.json") 35 | if err != nil { 36 | t.Errorf("error reading file: %v", err) 37 | } 38 | 39 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | fmt.Fprint(w, string(domains)) 41 | })) 42 | defer svr.Close() 43 | 44 | c := NewClient(svr.URL, "profile", "apikey") 45 | res, err := c.CollectDomains() 46 | if err != nil { 47 | t.Errorf("error collecting domains data: %v", err) 48 | } 49 | 50 | if !reflect.DeepEqual(res, expected) { 51 | t.Errorf("expected %v, got %v", expected, res) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/api/encryption.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type EncryptionResponse struct { 12 | Data []Encryption `json:"data"` 13 | } 14 | 15 | type Encryption struct { 16 | Encrypted bool `json:"encrypted"` 17 | Queries int `json:"queries"` 18 | } 19 | 20 | type EncryptionMetrics struct { 21 | Data []EncryptionMetric 22 | } 23 | 24 | type EncryptionMetric struct { 25 | Encrypted string 26 | Queries float64 27 | } 28 | 29 | func (c Client) CollectEncryption() (*EncryptionMetrics, error) { 30 | encryptionURL := fmt.Sprintf("%s/profiles/%s/analytics/encryption", c.url, c.profile) 31 | 32 | encryptionResponse := EncryptionResponse{} 33 | metrics := EncryptionMetrics{} 34 | 35 | body, err := c.Request(encryptionURL, nil) 36 | if err != nil { 37 | util.Log.Error("error making request", "error", err) 38 | return nil, err 39 | } 40 | 41 | err = json.Unmarshal(body, &encryptionResponse) 42 | if err != nil { 43 | util.Log.Error("error unmarshalling response body", "error", err) 44 | return nil, err 45 | } 46 | 47 | for _, data := range encryptionResponse.Data { 48 | data := EncryptionMetric{ 49 | Encrypted: strconv.FormatBool(data.Encrypted), 50 | Queries: float64(data.Queries), 51 | } 52 | 53 | metrics.Data = append(metrics.Data, data) 54 | } 55 | 56 | return &metrics, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/encryption_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectEncryption(t *testing.T) { 13 | expected := &EncryptionMetrics{ 14 | Data: []EncryptionMetric{ 15 | { 16 | Encrypted: "true", 17 | Queries: 48058, 18 | }, 19 | { 20 | Encrypted: "false", 21 | Queries: 1629, 22 | }, 23 | }, 24 | } 25 | 26 | ipVersions, err := os.ReadFile("../../fixtures/encryption.json") 27 | if err != nil { 28 | t.Errorf("error reading file: %v", err) 29 | } 30 | 31 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | fmt.Fprint(w, string(ipVersions)) 33 | })) 34 | defer svr.Close() 35 | 36 | c := NewClient(svr.URL, "profile", "apikey") 37 | res, err := c.CollectEncryption() 38 | if err != nil { 39 | t.Errorf("error collecting encryption data: %v", err) 40 | } 41 | 42 | if !reflect.DeepEqual(res, expected) { 43 | t.Errorf("expected %v, got %v", expected, res) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/api/ip_versions.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type IPVersionsResponse struct { 12 | IPVersions []IPVersion `json:"data"` 13 | } 14 | 15 | type IPVersion struct { 16 | Version int `json:"version"` 17 | Queries int `json:"queries"` 18 | } 19 | 20 | type IPVersionsMetrics struct { 21 | IPVersions []IPVersionMetric 22 | } 23 | 24 | type IPVersionMetric struct { 25 | Version string 26 | Queries float64 27 | } 28 | 29 | func (c Client) CollectIPVersions() (*IPVersionsMetrics, error) { 30 | ipVersionsURL := fmt.Sprintf("%s/profiles/%s/analytics/ipVersions", c.url, c.profile) 31 | 32 | ipVersionsResponse := IPVersionsResponse{} 33 | metrics := IPVersionsMetrics{} 34 | 35 | body, err := c.Request(ipVersionsURL, nil) 36 | if err != nil { 37 | util.Log.Error("error making request", "error", err) 38 | return nil, err 39 | } 40 | 41 | err = json.Unmarshal(body, &ipVersionsResponse) 42 | if err != nil { 43 | util.Log.Error("error unmarshalling response body", "error", err) 44 | return nil, err 45 | } 46 | 47 | for _, ipVersion := range ipVersionsResponse.IPVersions { 48 | ipVersion := IPVersionMetric{ 49 | Version: strconv.Itoa(ipVersion.Version), 50 | Queries: float64(ipVersion.Queries), 51 | } 52 | 53 | metrics.IPVersions = append(metrics.IPVersions, ipVersion) 54 | } 55 | 56 | return &metrics, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/api/ip_versions_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectIPVersions(t *testing.T) { 13 | expected := &IPVersionsMetrics{ 14 | IPVersions: []IPVersionMetric{ 15 | { 16 | Version: "4", 17 | Queries: 392, 18 | }, 19 | { 20 | Version: "6", 21 | Queries: 10, 22 | }, 23 | }, 24 | } 25 | 26 | ipVersions, err := os.ReadFile("../../fixtures/ip_versions.json") 27 | if err != nil { 28 | t.Errorf("error reading file: %v", err) 29 | } 30 | 31 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | fmt.Fprint(w, string(ipVersions)) 33 | })) 34 | defer svr.Close() 35 | 36 | c := NewClient(svr.URL, "profile", "apikey") 37 | res, err := c.CollectIPVersions() 38 | if err != nil { 39 | t.Errorf("error collecting IP versions data: %v", err) 40 | } 41 | 42 | if !reflect.DeepEqual(res, expected) { 43 | t.Errorf("expected %v, got %v", expected, res) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/api/protocols.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/raylas/nextdns-exporter/internal/util" 8 | ) 9 | 10 | type ProtocolsResponse struct { 11 | Protocols []Protocol `json:"data"` 12 | } 13 | 14 | type Protocol struct { 15 | Protocol string `json:"protocol"` 16 | Queries int `json:"queries"` 17 | } 18 | 19 | type ProtocolsMetrics struct { 20 | Protocols []ProtocolMetric 21 | } 22 | 23 | type ProtocolMetric struct { 24 | Protocol string 25 | Queries float64 26 | } 27 | 28 | func (c Client) CollectProtocols() (*ProtocolsMetrics, error) { 29 | protocolsURL := fmt.Sprintf("%s/profiles/%s/analytics/protocols", c.url, c.profile) 30 | 31 | protocolsResponse := ProtocolsResponse{} 32 | metrics := ProtocolsMetrics{} 33 | 34 | body, err := c.Request(protocolsURL, nil) 35 | if err != nil { 36 | util.Log.Error("error making request", "error", err) 37 | return nil, err 38 | } 39 | 40 | err = json.Unmarshal(body, &protocolsResponse) 41 | if err != nil { 42 | util.Log.Error("error unmarshalling response body", "error", err) 43 | return nil, err 44 | } 45 | 46 | for _, protocol := range protocolsResponse.Protocols { 47 | protocol := ProtocolMetric{ 48 | Protocol: protocol.Protocol, 49 | Queries: float64(protocol.Queries), 50 | } 51 | 52 | metrics.Protocols = append(metrics.Protocols, protocol) 53 | } 54 | 55 | return &metrics, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/api/protocols_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectProtocols(t *testing.T) { 13 | expected := &ProtocolsMetrics{ 14 | Protocols: []ProtocolMetric{ 15 | { 16 | Protocol: "DNS-over-HTTPS", 17 | Queries: 17115, 18 | }, 19 | { 20 | Protocol: "UDP", 21 | Queries: 354, 22 | }, 23 | }, 24 | } 25 | 26 | protocols, err := os.ReadFile("../../fixtures/protocols.json") 27 | if err != nil { 28 | t.Errorf("error reading file: %v", err) 29 | } 30 | 31 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | fmt.Fprint(w, string(protocols)) 33 | })) 34 | defer svr.Close() 35 | 36 | c := NewClient(svr.URL, "profile", "apikey") 37 | res, err := c.CollectProtocols() 38 | if err != nil { 39 | t.Errorf("error collecting protocols data: %v", err) 40 | } 41 | 42 | if !reflect.DeepEqual(res, expected) { 43 | t.Errorf("expected %v, got %v", expected, res) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/api/query_types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/raylas/nextdns-exporter/internal/util" 9 | ) 10 | 11 | type QueryTypesResponse struct { 12 | QueryTypes []QueryType `json:"data"` 13 | } 14 | 15 | type QueryType struct { 16 | Type int `json:"type"` 17 | Name string `json:"name"` 18 | Queries int `json:"queries"` 19 | } 20 | 21 | type QueryTypesMetrics struct { 22 | QueryTypes []QueryTypeMetric 23 | } 24 | 25 | type QueryTypeMetric struct { 26 | Type string 27 | Name string 28 | Queries float64 29 | } 30 | 31 | func (c Client) CollectQueryTypes() (*QueryTypesMetrics, error) { 32 | queryTypesURL := fmt.Sprintf("%s/profiles/%s/analytics/queryTypes", c.url, c.profile) 33 | 34 | queryTypesResponse := QueryTypesResponse{} 35 | metrics := QueryTypesMetrics{} 36 | 37 | body, err := c.Request(queryTypesURL, nil) 38 | if err != nil { 39 | util.Log.Error("error making request", "error", err) 40 | return nil, err 41 | } 42 | 43 | err = json.Unmarshal(body, &queryTypesResponse) 44 | if err != nil { 45 | util.Log.Error("error unmarshalling response body", "error", err) 46 | return nil, err 47 | } 48 | 49 | for _, queryType := range queryTypesResponse.QueryTypes { 50 | queryType := QueryTypeMetric{ 51 | Type: strconv.Itoa(queryType.Type), 52 | Name: queryType.Name, 53 | Queries: float64(queryType.Queries), 54 | } 55 | 56 | metrics.QueryTypes = append(metrics.QueryTypes, queryType) 57 | } 58 | 59 | return &metrics, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/api/query_types_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectQueryTypes(t *testing.T) { 13 | expected := &QueryTypesMetrics{ 14 | QueryTypes: []QueryTypeMetric{ 15 | { 16 | Type: "1", 17 | Name: "A", 18 | Queries: 207, 19 | }, 20 | { 21 | Type: "28", 22 | Name: "AAAA", 23 | Queries: 199, 24 | }, 25 | { 26 | Type: "65", 27 | Name: "HTTPS", 28 | Queries: 87, 29 | }, 30 | { 31 | Type: "12", 32 | Name: "PTR", 33 | Queries: 11, 34 | }, 35 | { 36 | Type: "16", 37 | Name: "TXT", 38 | Queries: 5, 39 | }, 40 | }, 41 | } 42 | 43 | queryTypes, err := os.ReadFile("../../fixtures/query_types.json") 44 | if err != nil { 45 | t.Errorf("error reading file: %v", err) 46 | } 47 | 48 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | fmt.Fprint(w, string(queryTypes)) 50 | })) 51 | defer svr.Close() 52 | 53 | c := NewClient(svr.URL, "profile", "apikey") 54 | res, err := c.CollectQueryTypes() 55 | if err != nil { 56 | t.Errorf("error collecting query types data: %v", err) 57 | } 58 | 59 | if !reflect.DeepEqual(res, expected) { 60 | t.Errorf("expected %v, got %v", expected, res) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/api/status.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/raylas/nextdns-exporter/internal/util" 8 | ) 9 | 10 | type StatusResponse struct { 11 | Statuses []Status `json:"data"` 12 | } 13 | 14 | type Status struct { 15 | Status string `json:"status"` 16 | Queries int `json:"queries"` 17 | } 18 | 19 | type StatusMetrics struct { 20 | TotalQueries float64 21 | AllowedQueries float64 22 | BlockedQueries float64 23 | } 24 | 25 | func (c Client) CollectStatus() (*StatusMetrics, error) { 26 | statusesURL := fmt.Sprintf("%s/profiles/%s/analytics/status", c.url, c.profile) 27 | 28 | statusResponse := StatusResponse{} 29 | metrics := StatusMetrics{} 30 | 31 | body, err := c.Request(statusesURL, nil) 32 | if err != nil { 33 | util.Log.Error("error making request", "error", err) 34 | return nil, err 35 | } 36 | 37 | err = json.Unmarshal(body, &statusResponse) 38 | if err != nil { 39 | util.Log.Error("error unmarshalling response body", "error", err) 40 | return nil, err 41 | } 42 | 43 | for _, status := range statusResponse.Statuses { 44 | switch status.Status { 45 | case "default": 46 | metrics.TotalQueries = float64(status.Queries) 47 | case "allowed": 48 | metrics.AllowedQueries = float64(status.Queries) 49 | case "blocked": 50 | metrics.BlockedQueries = float64(status.Queries) 51 | } 52 | } 53 | 54 | return &metrics, nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/api/status_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestCollectStatus(t *testing.T) { 13 | expected := &StatusMetrics{ 14 | TotalQueries: 1587523, 15 | AllowedQueries: 478, 16 | BlockedQueries: 80343, 17 | } 18 | 19 | status, err := os.ReadFile("../../fixtures/status.json") 20 | if err != nil { 21 | t.Errorf("error reading file: %v", err) 22 | } 23 | 24 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | fmt.Fprint(w, string(status)) 26 | })) 27 | defer svr.Close() 28 | 29 | c := NewClient(svr.URL, "profile", "apikey") 30 | res, err := c.CollectStatus() 31 | if err != nil { 32 | t.Errorf("error collecting status data: %v", err) 33 | } 34 | 35 | if !reflect.DeepEqual(res, expected) { 36 | t.Errorf("expected %v, got %v", expected, res) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/util/config.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "golang.org/x/exp/slog" 8 | ) 9 | 10 | const ( 11 | Namespace = "nextdns" 12 | BaseURL = "https://api.nextdns.io" 13 | ) 14 | 15 | var ( 16 | Log *slog.Logger 17 | Level slog.Level 18 | Port string 19 | MetricsPath string 20 | ResultWindow string 21 | ResultLimit string 22 | Profile string 23 | APIKey string 24 | ) 25 | 26 | // Initialize the configuration. 27 | func init() { 28 | // Set up logging. 29 | level := DefaultEnv("LOG_LEVEL", "INFO") 30 | switch level { 31 | case "DEBUG": 32 | Level = slog.LevelDebug 33 | case "INFO": 34 | Level = slog.LevelInfo 35 | case "WARN": 36 | Level = slog.LevelWarn 37 | case "ERROR": 38 | Level = slog.LevelError 39 | } 40 | 41 | Log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 42 | Level: Level, 43 | })) 44 | 45 | // Retrieve configuration, or use defaults. 46 | Port = fmt.Sprintf(":%s", DefaultEnv("METRICS_PORT", "9948")) 47 | MetricsPath = DefaultEnv("METRICS_PATH", "/metrics") 48 | ResultWindow = DefaultEnv("NEXTDNS_RESULT_WINDOW", "-5m") 49 | ResultLimit = DefaultEnv("NEXTDNS_RESULT_LIMIT", "50") 50 | 51 | // Required configuration. 52 | var err error 53 | Profile, err = initSecret("NEXTDNS_PROFILE") 54 | if err != nil { 55 | Log.Error(err.Error()) 56 | os.Exit(1) 57 | } 58 | APIKey, err = initSecret("NEXTDNS_API_KEY") 59 | if err != nil { 60 | Log.Error(err.Error()) 61 | os.Exit(1) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // Return the value of an environment variable, 10 | // or a default value if it is not set. 11 | func DefaultEnv(key, usual string) string { 12 | value := os.Getenv(key) 13 | if len(value) == 0 { 14 | return usual 15 | } 16 | return value 17 | } 18 | 19 | // initSecret returns secret either from env variable or from a file 20 | func initSecret(prefix string) (string, error) { 21 | key, ok := os.LookupEnv(prefix) 22 | if ok { 23 | return key, nil 24 | } 25 | file, ok := os.LookupEnv(fmt.Sprintf("%s_FILE", prefix)) 26 | if !ok { 27 | return "", fmt.Errorf("%s or %s_FILE must be set", prefix, prefix) 28 | } 29 | raw, err := os.ReadFile(file) 30 | if err != nil { 31 | return "", fmt.Errorf("read %s_FILE: %w", prefix, err) 32 | } 33 | return strings.TrimSpace(string(raw)), nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestDefaultEnv(t *testing.T) { 10 | os.Setenv("FOO", "bar") 11 | 12 | if DefaultEnv("FOO", "baz") != "bar" { 13 | t.Error("expected to get `bar`") 14 | } 15 | if DefaultEnv("BAR", "baz") != "baz" { 16 | t.Error("expected to get `baz`") 17 | } 18 | } 19 | 20 | func TestInitSecret(t *testing.T) { 21 | os.Setenv("BAZ", "1223") 22 | v, err := initSecret("BAZ") 23 | if err != nil { 24 | t.Errorf("expected no error: %s", err) 25 | } 26 | if v != "1223" { 27 | t.Errorf("expected 1223: %s", v) 28 | } 29 | 30 | f := filepath.Join(t.TempDir(), "boz") 31 | err = os.WriteFile(f, []byte("345"), 0o755) 32 | if err != nil { 33 | t.Errorf("expected no error: %s", err) 34 | } 35 | os.Setenv("BOZ_FILE", f) 36 | v, err = initSecret("BOZ") 37 | if err != nil { 38 | t.Errorf("expected no error: %s", err) 39 | } 40 | if v != "345" { 41 | t.Errorf("expected 345: %s", v) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | "github.com/raylas/nextdns-exporter/internal/api" 10 | "github.com/raylas/nextdns-exporter/internal/util" 11 | ) 12 | 13 | var version = "dev" // Set by goreleaser. 14 | 15 | type exporter struct { 16 | profile, apiKey string 17 | 18 | // Totaled metrics. 19 | totalQueries *prometheus.Desc 20 | totalAllowedQueries *prometheus.Desc 21 | totalBlockedQueries *prometheus.Desc 22 | 23 | // Detailed metrics. 24 | blockedQueries *prometheus.Desc 25 | deviceQueries *prometheus.Desc 26 | protocolQueries *prometheus.Desc 27 | typeQueries *prometheus.Desc 28 | ipVersionQueries *prometheus.Desc 29 | dnssecQueries *prometheus.Desc 30 | encryptedQueries *prometheus.Desc 31 | destinationQueries *prometheus.Desc 32 | } 33 | 34 | func newExporter(profile, apiKey string) *exporter { 35 | return &exporter{ 36 | profile: profile, 37 | apiKey: apiKey, 38 | 39 | // Totaled metrics. 40 | totalQueries: prometheus.NewDesc( 41 | prometheus.BuildFQName(util.Namespace, "queries", "total"), 42 | "Total number of queries.", 43 | []string{"profile"}, nil, 44 | ), 45 | totalAllowedQueries: prometheus.NewDesc( 46 | prometheus.BuildFQName(util.Namespace, "allowed_queries", "total"), 47 | "Total number of allowed queries.", 48 | []string{"profile"}, nil, 49 | ), 50 | totalBlockedQueries: prometheus.NewDesc( 51 | prometheus.BuildFQName(util.Namespace, "blocked_queries", "total"), 52 | "Total number of blocked queries.", 53 | []string{"profile"}, nil, 54 | ), 55 | 56 | // Detailed metrics. 57 | blockedQueries: prometheus.NewDesc( 58 | prometheus.BuildFQName(util.Namespace, "blocked", "queries"), 59 | "Number of blocked queries per domain.", 60 | []string{"profile", "domain", "root", "tracker"}, nil, 61 | ), 62 | deviceQueries: prometheus.NewDesc( 63 | prometheus.BuildFQName(util.Namespace, "device", "queries"), 64 | "Number of queries per device.", 65 | []string{"profile", "id", "name", "model", "local_ip"}, nil, 66 | ), 67 | protocolQueries: prometheus.NewDesc( 68 | prometheus.BuildFQName(util.Namespace, "protocol", "queries"), 69 | "Number of queries per protocol.", 70 | []string{"profile", "protocol"}, nil, 71 | ), 72 | typeQueries: prometheus.NewDesc( 73 | prometheus.BuildFQName(util.Namespace, "type", "queries"), 74 | "Number of queries per type.", 75 | []string{"profile", "type", "name"}, nil, 76 | ), 77 | ipVersionQueries: prometheus.NewDesc( 78 | prometheus.BuildFQName(util.Namespace, "ip_version", "queries"), 79 | "Number of queries per IP version.", 80 | []string{"profile", "version"}, nil, 81 | ), 82 | dnssecQueries: prometheus.NewDesc( 83 | prometheus.BuildFQName(util.Namespace, "dnssec", "queries"), 84 | "Number of DNSSEC and non-DNSSEC queries.", 85 | []string{"profile", "validated"}, nil, 86 | ), 87 | encryptedQueries: prometheus.NewDesc( 88 | prometheus.BuildFQName(util.Namespace, "encrypted", "queries"), 89 | "Number of encrypted and unencrypted queries.", 90 | []string{"profile", "encrypted"}, nil, 91 | ), 92 | destinationQueries: prometheus.NewDesc( 93 | prometheus.BuildFQName(util.Namespace, "destination", "queries"), 94 | "Number of queries per geographic destination.", 95 | []string{"profile", "code", "name"}, nil, 96 | ), 97 | } 98 | } 99 | 100 | func (e *exporter) Describe(ch chan<- *prometheus.Desc) { 101 | // Totaled metrics. 102 | ch <- e.totalQueries 103 | ch <- e.totalAllowedQueries 104 | ch <- e.totalBlockedQueries 105 | 106 | // Detailed metrics. 107 | ch <- e.blockedQueries 108 | ch <- e.deviceQueries 109 | ch <- e.protocolQueries 110 | ch <- e.typeQueries 111 | ch <- e.ipVersionQueries 112 | ch <- e.dnssecQueries 113 | ch <- e.encryptedQueries 114 | ch <- e.destinationQueries 115 | } 116 | 117 | func (e *exporter) Collect(ch chan<- prometheus.Metric) { 118 | c := api.NewClient(util.BaseURL, e.profile, e.apiKey) 119 | 120 | // Totaled metrics. 121 | status, err := c.CollectStatus() 122 | if err != nil { 123 | util.Log.Error("error collecting status data", "error", err) 124 | return 125 | } 126 | ch <- prometheus.MustNewConstMetric(e.totalQueries, prometheus.GaugeValue, status.TotalQueries, e.profile) 127 | ch <- prometheus.MustNewConstMetric(e.totalAllowedQueries, prometheus.GaugeValue, status.AllowedQueries, e.profile) 128 | ch <- prometheus.MustNewConstMetric(e.totalBlockedQueries, prometheus.GaugeValue, status.BlockedQueries, e.profile) 129 | 130 | // Detailed metrics. 131 | domains, err := c.CollectDomains() 132 | if err != nil { 133 | util.Log.Error("error collecting domains data", "error", err) 134 | return 135 | } 136 | for _, domain := range domains.BlockedDomains { 137 | ch <- prometheus.MustNewConstMetric( 138 | e.blockedQueries, 139 | prometheus.GaugeValue, 140 | domain.Queries, e.profile, domain.Domain, domain.Root, domain.Tracker, 141 | ) 142 | } 143 | 144 | devices, err := c.CollectDevices() 145 | if err != nil { 146 | util.Log.Error("error collecting devices data", "error", err) 147 | return 148 | } 149 | for _, device := range devices.Devices { 150 | ch <- prometheus.MustNewConstMetric( 151 | e.deviceQueries, 152 | prometheus.GaugeValue, 153 | device.Queries, e.profile, device.ID, device.Name, device.Model, device.LocalIP, 154 | ) 155 | } 156 | 157 | protocols, err := c.CollectProtocols() 158 | if err != nil { 159 | util.Log.Error("error collecting protocols data", "error", err) 160 | return 161 | } 162 | for _, protocol := range protocols.Protocols { 163 | ch <- prometheus.MustNewConstMetric( 164 | e.protocolQueries, 165 | prometheus.GaugeValue, 166 | protocol.Queries, e.profile, protocol.Protocol, 167 | ) 168 | } 169 | 170 | queryTypes, err := c.CollectQueryTypes() 171 | if err != nil { 172 | util.Log.Error("error collecting query types data", "error", err) 173 | return 174 | } 175 | for _, queryType := range queryTypes.QueryTypes { 176 | ch <- prometheus.MustNewConstMetric( 177 | e.typeQueries, 178 | prometheus.GaugeValue, 179 | queryType.Queries, e.profile, queryType.Type, queryType.Name, 180 | ) 181 | } 182 | 183 | ipVersions, err := c.CollectIPVersions() 184 | if err != nil { 185 | util.Log.Error("error collecting IP versions data", "error", err) 186 | return 187 | } 188 | for _, ipVersion := range ipVersions.IPVersions { 189 | ch <- prometheus.MustNewConstMetric( 190 | e.ipVersionQueries, 191 | prometheus.GaugeValue, 192 | ipVersion.Queries, e.profile, ipVersion.Version, 193 | ) 194 | } 195 | 196 | dnssec, err := c.CollectDNSSEC() 197 | if err != nil { 198 | util.Log.Error("error collecting DNSSEC data", "error", err) 199 | return 200 | } 201 | for _, data := range dnssec.Data { 202 | ch <- prometheus.MustNewConstMetric( 203 | e.dnssecQueries, 204 | prometheus.GaugeValue, 205 | data.Queries, e.profile, data.Validated, 206 | ) 207 | } 208 | 209 | encryption, err := c.CollectEncryption() 210 | if err != nil { 211 | util.Log.Error("error collecting encryption data", "error", err) 212 | return 213 | } 214 | for _, data := range encryption.Data { 215 | ch <- prometheus.MustNewConstMetric( 216 | e.encryptedQueries, 217 | prometheus.GaugeValue, 218 | data.Queries, e.profile, data.Encrypted, 219 | ) 220 | } 221 | 222 | destinations, err := c.CollectDestinations() 223 | if err != nil { 224 | util.Log.Error("error collecting destinations data", "error", err) 225 | return 226 | } 227 | for _, destination := range destinations.Destinations { 228 | ch <- prometheus.MustNewConstMetric( 229 | e.destinationQueries, 230 | prometheus.GaugeValue, 231 | destination.Queries, e.profile, destination.Code, destination.Name, 232 | ) 233 | } 234 | } 235 | 236 | func main() { 237 | exporter := newExporter(util.Profile, util.APIKey) 238 | prometheus.MustRegister(exporter) 239 | 240 | util.Log.Info("starting exporter", "version", version, "port", util.Port, "path", util.MetricsPath) 241 | http.Handle(util.MetricsPath, promhttp.Handler()) 242 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 243 | http.Redirect(w, r, util.MetricsPath, http.StatusMovedPermanently) 244 | }) 245 | if err := http.ListenAndServe(util.Port, nil); err != nil { 246 | util.Log.Error("error starting exporter", "error", err) 247 | os.Exit(1) 248 | } 249 | } 250 | --------------------------------------------------------------------------------