Compare commits
15 Commits
feat/rsync
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc72fab412 | |||
|
|
d428774bb3 | ||
|
|
e1d798c432 | ||
|
|
0afa3eb181 | ||
|
|
bf5c9fed60 | ||
| a923b29aa6 | |||
|
|
9ffdc76e6a | ||
|
|
5f80f4a907 | ||
| f0b0306144 | |||
|
|
88790ed23a | ||
|
|
dc121ae8e5 | ||
|
|
18888f319a | ||
| 9ee6282f99 | |||
| ad47824a4a | |||
| 3280a9ebea |
69
.gitea/workflows/adguard-exporter.yaml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
name: Build and push adguard-exporter image
|
||||||
|
run-name: ${{ gitea.actor }} is building and pushing adguard-exporter image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feat/**'
|
||||||
|
paths:
|
||||||
|
- 'adguard-exporter/**'
|
||||||
|
- '.gitea/workflows/adguard-exporter.yaml'
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
RESULT_IMAGE_NAME: tools/adguard-exporter
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.PRIVATE_REGISTRY_DOMAIN }}
|
||||||
|
username: ${{ vars.PRIVATE_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.PRIVATE_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Install private CA certificate
|
||||||
|
run: |
|
||||||
|
echo "${{ vars.PRIVATE_REGISTRY_CA_CERT }}" | sudo tee /usr/local/share/ca-certificates/private-ca.crt > /dev/null
|
||||||
|
sudo update-ca-certificates
|
||||||
|
|
||||||
|
- name: Compute image tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
BASE="${{ vars.PRIVATE_REGISTRY_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}"
|
||||||
|
{
|
||||||
|
echo "value<<EOF"
|
||||||
|
echo "${BASE}:${{ gitea.sha }}"
|
||||||
|
if [ "${{ gitea.ref_name }}" = "main" ]; then
|
||||||
|
echo "${BASE}:latest"
|
||||||
|
fi
|
||||||
|
if [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
||||||
|
echo "${BASE}:${{ gitea.ref_name }}"
|
||||||
|
fi
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."harbor.lan"]
|
||||||
|
ca = ["/usr/local/share/ca-certificates/private-ca.crt"]
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: adguard-exporter
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.tags.outputs.value }}
|
||||||
@ -10,6 +10,7 @@ on:
|
|||||||
- '.gitea/workflows/telegraf-snmp-unifi.yaml'
|
- '.gitea/workflows/telegraf-snmp-unifi.yaml'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
RESULT_IMAGE_NAME: tools/telegraf-snmp-unifi
|
RESULT_IMAGE_NAME: tools/telegraf-snmp-unifi
|
||||||
|
|||||||
13
README.md
@ -6,6 +6,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `tools/telegraf-snmp-unifi` | Telegraf mit UniFi SNMP MIBs |
|
| `tools/telegraf-snmp-unifi` | Telegraf mit UniFi SNMP MIBs |
|
||||||
| `tools/rsyncd` | rsync Daemon |
|
| `tools/rsyncd` | rsync Daemon |
|
||||||
|
| `tools/adguard-exporter` | Prometheus Exporter für AdGuard Home — basiert auf [znand-dev/adguardexporter](https://github.com/znand-dev/adguardexporter), GeoLite2 deaktiviert (kein DB-File) |
|
||||||
|
|
||||||
## CI Workflows
|
## CI Workflows
|
||||||
|
|
||||||
@ -16,3 +17,15 @@
|
|||||||
| `git tag v1.0.0 && git push origin v1.0.0` | `:<sha>` + `:v1.0.0` |
|
| `git tag v1.0.0 && git push origin v1.0.0` | `:<sha>` + `:v1.0.0` |
|
||||||
|
|
||||||
`:latest` wird nur durch Pushes auf `main` überschrieben. Feature-Branches bauen immer, lassen `:latest` aber unberührt.
|
`:latest` wird nur durch Pushes auf `main` überschrieben. Feature-Branches bauen immer, lassen `:latest` aber unberührt.
|
||||||
|
|
||||||
|
## Rebuild without code changes
|
||||||
|
|
||||||
|
To trigger a rebuild (e.g. to pull latest `apt upgrade`):
|
||||||
|
|
||||||
|
**Option 1 — empty commit:**
|
||||||
|
```bash
|
||||||
|
git commit --allow-empty -m "chore: rebuild to pull latest apt upgrades"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 — manual trigger:** Use the "Run workflow" button in the Gitea Actions UI (`telegraf-snmp-unifi` workflow only).
|
||||||
|
|||||||
19
adguard-exporter/.env.example
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Contoh konfigurasi, silakan sesuaikan
|
||||||
|
#Fill up your adguard url, if didn't works try docker ips (172.x.x.x) (required)
|
||||||
|
ADGUARD_HOST=http://172.x.x.x:20271
|
||||||
|
|
||||||
|
#Your AdGuard username and password for authentication that you setup during the init (required)
|
||||||
|
ADGUARD_USER=
|
||||||
|
ADGUARD_PASS=
|
||||||
|
|
||||||
|
#Your exporter port (optional, default 9617)
|
||||||
|
EXPORTER_PORT=9200
|
||||||
|
|
||||||
|
#Scrape Interval by prometheus (optional, default 15s)
|
||||||
|
SCRAPE_INTERVAL=15s
|
||||||
|
|
||||||
|
#Fill up your LogLevel you want it to, (DEBUG,WARN,ERROR,INFO)
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
#GEOIP_DB File download => wget https://raw.githubusercontent.com/P3TERX/GeoLite.mmdb/download/GeoLite2-City.mmdb
|
||||||
|
GEOIP_DB=/path/to/your/GeoLite2-City.mmdb
|
||||||
29
adguard-exporter/.github/renovate.json
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"labels": [
|
||||||
|
"renovate::dependencies",
|
||||||
|
"{{#if category}}renovate::{{category}}{{/if}}",
|
||||||
|
"{{#if updateType}}renovate::{{updateType}}{{/if}}",
|
||||||
|
"{{#if datasource}}renovate::{{datasource}}{{/if}}",
|
||||||
|
"{{#if manager}}renovate::{{manager}}{{/if}}",
|
||||||
|
"{{#if vulnerabilitySeverity}}renovate::{{vulnerabilitySeverity}}{{/if}}",
|
||||||
|
"renovate::{{#if isVulnerabilityAlert}}vulnerability{{else}}not-vulnerability{{/if}}"
|
||||||
|
],
|
||||||
|
"pinDigests": false,
|
||||||
|
"enabled": true,
|
||||||
|
"separateMajorMinor": true,
|
||||||
|
"automerge": true,
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"gomod"
|
||||||
|
],
|
||||||
|
"postUpdateOptions": [
|
||||||
|
"gomodTidy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
44
adguard-exporter/.github/workflows/go-ci.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: Go CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests & Linter
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
# - name: Cache Go modules
|
||||||
|
# uses: actions/cache@v3
|
||||||
|
# with:
|
||||||
|
# path: |
|
||||||
|
# ~/.cache/go-build
|
||||||
|
# ~/go/pkg/mod
|
||||||
|
# key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
# restore-keys: |
|
||||||
|
# ${{ runner.os }}-go-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: go mod tidy
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: go test -v ./...
|
||||||
|
|
||||||
|
- name: Run linter (golangci-lint)
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: --timeout=3m
|
||||||
32
adguard-exporter/.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version: 1.26
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u znand-dev --password-stdin
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v7
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
13
adguard-exporter/.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Binaries
|
||||||
|
adguardexporter
|
||||||
|
dist/
|
||||||
|
# Env file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS / Editor files
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
#db
|
||||||
|
GeoLite2-City.mmdb
|
||||||
|
geolite2-db/GeoLite2-City.mmdb
|
||||||
79
adguard-exporter/.goreleaser.yml
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
project_name: adguard-exporter
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: universal
|
||||||
|
main: main.go
|
||||||
|
binary: adguard-exporter
|
||||||
|
env: [CGO_ENABLED=0]
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- windows
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- arm
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- format: tar.gz
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
|
||||||
|
dockers:
|
||||||
|
- id: docker-amd64
|
||||||
|
ids: [universal]
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/amd64"
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-amd64
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-amd64
|
||||||
|
|
||||||
|
- id: docker-arm64
|
||||||
|
ids: [universal]
|
||||||
|
goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm64"
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-arm64v8
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-arm64v8
|
||||||
|
|
||||||
|
- id: docker-arm
|
||||||
|
ids: [universal]
|
||||||
|
goos: linux
|
||||||
|
goarch: arm
|
||||||
|
use: buildx
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
build_flag_templates:
|
||||||
|
- "--platform=linux/arm/v6"
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-armv6
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-armv6
|
||||||
|
|
||||||
|
docker_manifests:
|
||||||
|
- name_template: ghcr.io/znand-dev/adguardexporter:{{ .Tag }}
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-amd64
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-arm64v8
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:{{ .Tag }}-armv6
|
||||||
|
|
||||||
|
- name_template: ghcr.io/znand-dev/adguardexporter:latest
|
||||||
|
image_templates:
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-amd64
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-arm64v8
|
||||||
|
- ghcr.io/znand-dev/adguardexporter:latest-armv6
|
||||||
20
adguard-exporter/CHANGELOG.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v1.1.0 - 2025-06-18
|
||||||
|
### ✨ Added
|
||||||
|
- `adguard_replaced_parental`: total queries replaced due to parental control
|
||||||
|
- `adguard_safe_search`: total queries forced into Safe Search (YouTube, Google, Bing)
|
||||||
|
- `adguard_safe_browsing`: total Safe Browsing protections triggered
|
||||||
|
- `adguard_top_upstreams`: top DNS upstreams based on query count
|
||||||
|
- `adguard_top_clients`: top client IPs querying AdGuard
|
||||||
|
- `adguard_top_queried_domains`: most frequently queried domains
|
||||||
|
- `adguard_upstream_avg_response_time_seconds`: average DNS upstream response time
|
||||||
|
|
||||||
|
### 🛠 Fixed
|
||||||
|
- Fixed: missing `upstream` and `client` labels due to empty maps or parsing errors
|
||||||
|
- Fixed: more reliable parsing of nested JSON from `/control/stats` and `/control/status`
|
||||||
|
- Fixed: exporter now exposes partial metrics even if part of the JSON fails
|
||||||
|
|
||||||
|
### 🔄 Refactored
|
||||||
|
- Cleaned up metric and label names to follow Prometheus naming conventions
|
||||||
|
- Fixed Dockerfile lint warning: unified casing of `FROM ... AS` keyword for best practices
|
||||||
34
adguard-exporter/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates git
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
|
go build -ldflags="-s -w" -o adguard-exporter .
|
||||||
|
|
||||||
|
# Create empty GeoLite2 placeholder (geo metrics disabled without real DB)
|
||||||
|
RUN touch /app/GeoLite2-City.mmdb
|
||||||
|
|
||||||
|
FROM alpine:3.24 AS certs
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=build /app/adguard-exporter /adguard-exporter
|
||||||
|
COPY --from=build /app/GeoLite2-City.mmdb /GeoLite2-City.mmdb
|
||||||
|
|
||||||
|
USER 65532:65532
|
||||||
|
|
||||||
|
EXPOSE 9200
|
||||||
|
|
||||||
|
ENTRYPOINT ["/adguard-exporter"]
|
||||||
21
adguard-exporter/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Januar Ismunandar
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
307
adguard-exporter/README.md
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
# AdGuard Exporter for Prometheus
|
||||||
|
|
||||||
|
A lightweight Prometheus exporter written in Go that exposes detailed metrics from your AdGuard Home instance — including DNS statistics, blocked domains, upstreams, and client data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Category | Capabilities |
|
||||||
|
|----------|--------------|
|
||||||
|
| API Integration | • Authenticated access to AdGuard Home API<br>• Supports `/control/status`, `/control/stats`, and `/control/querylog` endpoints |
|
||||||
|
| DNS Analytics | • Total DNS queries metrics<br>• Blocked DNS queries tracking<br>• Upstream DNS statistics<br>• Per-client query statistics<br>• Query reason statistics<br>• DNS upstream latency histograms |
|
||||||
|
| Query Log Processing | • Real-time query log analysis<br>• Query deduplication to prevent metric inflation<br>• Domain and query reason tracking |
|
||||||
|
| GeoIP Enrichment | • MaxMind GeoLite2 IP geolocation lookup<br>• DNS client geographic visualization (Grafana Geomap)<br>• DNS threat map for blocked queries<br>• GeoIP caching for improved performance |
|
||||||
|
| Traffic Analytics | • DNS queries grouped by ISP organization<br>• Country-level DNS traffic insights<br>• ISP-based DNS traffic monitoring |
|
||||||
|
| Exporter Monitoring | • Deduplication cache metrics<br>• GeoIP cache usage monitoring<br>• Deduplication hit counters<br>• Exporter scrape performance metrics |
|
||||||
|
| Deployment | • Customizable scrape interval<br>• Lightweight single binary deployment<br>• Docker-friendly deployment |
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built With
|
||||||
|
|
||||||
|
- [Go (Golang)](https://golang.org/)
|
||||||
|
- [Prometheus Client Library](https://github.com/prometheus/client_golang)
|
||||||
|
- [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome)
|
||||||
|
|
||||||
|
---
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before running this exporter, make sure:
|
||||||
|
|
||||||
|
- AdGuard Home is up and running
|
||||||
|
- You have a valid AdGuard username & password (without special character (!,@,`,* etc.))
|
||||||
|
- Prometheus is configured to scrape this exporter
|
||||||
|
- Docker installed (or alternatively Go 1.20+ for building from source)
|
||||||
|
- Port `9617` is available on your system
|
||||||
|
|
||||||
|
To make sure the endpoint is valid, you can check the endpoints via curl:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/stats
|
||||||
|
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/status
|
||||||
|
curl -v -u <yourusername>:<yourpassword> <ADGUARD_URL>:<PORT>/control/querylog
|
||||||
|
```
|
||||||
|
After that, you should got the output like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
* Trying 172.31.20.12:80...
|
||||||
|
* Connected to 172.31.20.12 (172.31.20.12) port 80 (#0)
|
||||||
|
* Server auth using Basic with user 'admin'
|
||||||
|
> GET /control/stats HTTP/1.1
|
||||||
|
> Host: 172.31.20.12
|
||||||
|
> Authorization: Basic Xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
> User-Agent: curl/7.88.1
|
||||||
|
> Accept: */*
|
||||||
|
>
|
||||||
|
< HTTP/1.1 200 OK
|
||||||
|
< Access-Control-Allow-Origin: http://172.31.20.12
|
||||||
|
< Content-Type: application/json
|
||||||
|
< Server: AdGuardHome/v0.107.62
|
||||||
|
< Vary: Origin
|
||||||
|
< Vary: Accept-Encoding
|
||||||
|
< Date: Tue, 08 Jul 2025 16:45:56 GMT
|
||||||
|
< Transfer-Encoding: chunked
|
||||||
|
<
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Required | Example |
|
||||||
|
|------------------|---------------------------- | -------------------|-------------------
|
||||||
|
| `ADGUARD_HOST`| AdGuard Home base URL | ✅ | `http://192.168.1.1:3000,` |
|
||||||
|
| `ADGUARD_USER`| AdGuard Home username | ✅ | `admin` |
|
||||||
|
| `ADGUARD_PASS`| AdGuard Home password | ✅ | `secretpassword` |
|
||||||
|
| `EXPORTER_PORT` | Port to expose metrics (default: 9617) | ❌ | `9200` |
|
||||||
|
| `SCRAPE_INTERVAL` | How often to scrape (default: 15s) | ❌ | `30s` |
|
||||||
|
| `LOG_LEVEL` | Log Level to analyze, INFO, WARN, DEBUG | ❌ | `DEBUG`,`WARN`,`INFO` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
## Run via Docker Compose
|
||||||
|
|
||||||
|
### 1. Create docker-compose.yml in your root dir
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
adguard-exporter:
|
||||||
|
image: ghcr.io/znandev/adguardexporter:latest
|
||||||
|
container_name: adguard_exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9617:9617"
|
||||||
|
volumes:
|
||||||
|
- ./<path/to/your>/GeoLite2-City.mmdb:/GeoLite2-City.mmdb
|
||||||
|
environment:
|
||||||
|
- ADGUARD_HOST=<YOUR_ADGUARD_URL>
|
||||||
|
- ADGUARD_USER=<YOUR_ADGUARD_USERNAME>
|
||||||
|
- ADGUARD_PASS=<YOUR_ADGUARD_PASSWORD>
|
||||||
|
- EXPORTER_PORT=9617
|
||||||
|
- SCRAPE_INTERVAL=15s
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run with Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics Endpoint
|
||||||
|
|
||||||
|
Once running, your exporter will be available at:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<host>:9617/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Ready to scrape by Prometheus!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Prometheus Job
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- job_name: 'adguard-exporter'
|
||||||
|
scrape_interval: 15s
|
||||||
|
static_configs:
|
||||||
|
- targets: ['adguard-exporter:9617']
|
||||||
|
```
|
||||||
|
---
|
||||||
|
## Available Prometheus Metrics
|
||||||
|
|
||||||
|
This exporter exposes the following metrics from AdGuard Home.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Core Metrics
|
||||||
|
|
||||||
|
* `adguard_dns_queries_total`
|
||||||
|
Total DNS queries processed by AdGuard Home.
|
||||||
|
|
||||||
|
* `adguard_blocked_filtering_total`
|
||||||
|
Total DNS queries blocked by filtering rules.
|
||||||
|
|
||||||
|
* `adguard_replaced_parental`
|
||||||
|
Queries replaced by parental filtering.
|
||||||
|
|
||||||
|
* `adguard_avg_processing_time`
|
||||||
|
Average DNS query processing time in milliseconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AdGuard Status
|
||||||
|
|
||||||
|
* `adguard_protection_enabled`
|
||||||
|
Whether DNS filtering protection is enabled (1 = enabled, 0 = disabled).
|
||||||
|
|
||||||
|
* `adguard_running`
|
||||||
|
Whether the AdGuard Home DNS service is running.
|
||||||
|
|
||||||
|
* `adguard_dhcp_available`
|
||||||
|
Indicates if DHCP functionality is available.
|
||||||
|
|
||||||
|
* `adguard_protection_disabled_duration_seconds`
|
||||||
|
Time since DNS protection was disabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Top Queries
|
||||||
|
|
||||||
|
Metrics with labels:
|
||||||
|
|
||||||
|
* `adguard_top_queried_domain_total{domain="example.com"}`
|
||||||
|
* `adguard_top_blocked_domain_total{domain="ads.example.com"}`
|
||||||
|
* `adguard_top_client_total{client="192.168.1.2"}`
|
||||||
|
* `adguard_top_upstream_total{upstream="8.8.8.8"}`
|
||||||
|
* `adguard_upstream_avg_response_time_seconds{upstream="8.8.8.8"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Query Log Metrics
|
||||||
|
|
||||||
|
Metrics generated from AdGuard query log analysis.
|
||||||
|
|
||||||
|
* `adguard_query_domain_total{domain="example.com"}`
|
||||||
|
* `adguard_query_reason_total{reason="FilteredBlackList"}`
|
||||||
|
* `adguard_query_type_total{type="A"}`
|
||||||
|
* `adguard_query_upstream_total{upstream="8.8.8.8"}`
|
||||||
|
* `adguard_query_client_reason_total{client="192.168.1.10",reason="FilteredBlackList"}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Upstream Latency Metrics
|
||||||
|
|
||||||
|
Histogram metrics that track DNS upstream response time distribution.
|
||||||
|
|
||||||
|
* `adguard_upstream_latency_seconds`
|
||||||
|
|
||||||
|
This metric allows latency percentile calculations such as **p95 or p99 upstream DNS latency**.
|
||||||
|
|
||||||
|
Generated metrics:
|
||||||
|
|
||||||
|
* `adguard_upstream_latency_seconds_bucket`
|
||||||
|
* `adguard_upstream_latency_seconds_sum`
|
||||||
|
* `adguard_upstream_latency_seconds_count`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
adguard_upstream_latency_seconds_bucket{upstream="1.1.1.1",le="0.005"} 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GeoIP Metrics
|
||||||
|
|
||||||
|
#### `adguard_client_geo_queries`
|
||||||
|
|
||||||
|
Total DNS queries per client enriched with geographic information.
|
||||||
|
|
||||||
|
Labels:
|
||||||
|
|
||||||
|
* `client` – Client IP address
|
||||||
|
* `country` – ISO country code
|
||||||
|
* `lat` – Latitude
|
||||||
|
* `lon` – Longitude
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
adguard_client_geo_queries{client="118.99.94.204",country="ID",lat="-2.969000",lon="104.744200"} 1000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `adguard_blocked_geo_queries`
|
||||||
|
|
||||||
|
Counts DNS queries that were blocked by filtering rules and enriches them with geographic information.
|
||||||
|
|
||||||
|
Labels:
|
||||||
|
|
||||||
|
* `client`
|
||||||
|
* `country`
|
||||||
|
* `lat`
|
||||||
|
* `lon`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
adguard_blocked_geo_queries{client="118.99.94.204",country="ID",lat="-2.969000",lon="104.744200"} 306
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Exporter Health Metrics
|
||||||
|
|
||||||
|
Metrics used to monitor the exporter itself.
|
||||||
|
|
||||||
|
* `adguard_exporter_up` — Indicates if the exporter successfully scraped the AdGuard API.
|
||||||
|
* `adguard_exporter_scrape_duration_seconds` — Time taken for the exporter to scrape AdGuard API data.
|
||||||
|
* `adguard_exporter_scrape_errors_total` — Total number of errors encountered during scraping.
|
||||||
|
* `adguard_exporter_query_cache_size` — Number of cached DNS query entries used for deduplication.
|
||||||
|
* `adguard_exporter_geo_cache_size` — Number of cached GeoIP lookup entries.
|
||||||
|
* `adguard_exporter_dedup_hits_total` — Total number of duplicate DNS queries skipped by the exporter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Grafana Dashboard Preview
|
||||||
|
|
||||||
|
[Live preview and monitoring, click here](https://stats.znand.my.id/public-dashboards/a0b05deb37464a17b01cbc69eb65ac5a?orgId=1&refresh=5s)
|
||||||
|
|
||||||
|
[Deploy yours here](https://grafana.com/grafana/dashboards/23579-adguard-metrics-statistics/)
|
||||||
|
— new dashboard.json for grafana will be uploaded soon
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💬 Feedback
|
||||||
|
|
||||||
|
Pull Requests, Issues, and Suggestions are always welcome!
|
||||||
|
|
||||||
|
**Made with ❤️ by [znanddev](https://github.com/znand-dev)**
|
||||||
21
adguard-exporter/docker-compose.yml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
adguard-exporter:
|
||||||
|
image: ghcr.io/znand-dev/adguardexporter:latest
|
||||||
|
container_name: adguard_exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "9617:9617"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./<path/to/your>/GeoLite2-City.mmdb:/GeoLite2-City.mmdb # your geolite2 db file
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- ADGUARD_HOST=<ADGUARD_URL> # try docker ips if didn't works http://172.xx.x.x for example
|
||||||
|
- ADGUARD_USER=<ADGUARD_USERNAME> # required
|
||||||
|
- ADGUARD_PASS=<ADGUARD_PASSWORD> # required
|
||||||
|
- EXPORTER_PORT=9200 # optional, default 9617
|
||||||
|
- SCRAPE_INTERVAL=15s # optional
|
||||||
|
- LOG_LEVEL=DEBUG # optional
|
||||||
8
adguard-exporter/geolite2-db/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
Download GeoLite2 City database from MaxMind:
|
||||||
|
|
||||||
|
https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||||
|
|
||||||
|
Place the file here:
|
||||||
|
|
||||||
|
geoip/GeoLite2-City.mmdb
|
||||||
|
|
||||||
25
adguard-exporter/go.mod
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module github.com/znand-dev/adguardexporter
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
)
|
||||||
52
adguard-exporter/go.sum
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||||
|
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
1540
adguard-exporter/grafana/dashboard+node.json
Normal file
3470
adguard-exporter/grafana/dashboard.json
Normal file
BIN
adguard-exporter/grafana/dashboard01.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
adguard-exporter/grafana/dashboard02.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
adguard-exporter/grafana/dashboard03.png
Normal file
|
After Width: | Height: | Size: 795 KiB |
BIN
adguard-exporter/grafana/dashboard04.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
adguard-exporter/grafana/dashboard06.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
adguard-exporter/grafana/dashboard1.png
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
adguard-exporter/grafana/dashboard2.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
adguard-exporter/grafana/dashboard3.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
adguard-exporter/grafana/dashboard4.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
860
adguard-exporter/main.go
Normal file
@ -0,0 +1,860 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/oschwald/geoip2-golang"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
📦 AdGuard Exporter for Prometheus
|
||||||
|
----------------------------------
|
||||||
|
Author : @znand-dev
|
||||||
|
License : MIT
|
||||||
|
Repo : https://github.com/znand-dev/adguardexporter
|
||||||
|
|
||||||
|
This Go application fetches stats from AdGuard Home via API endpoints
|
||||||
|
and exposes them as Prometheus metrics at `/metrics`.
|
||||||
|
|
||||||
|
Required ENV variables:
|
||||||
|
- ADGUARD_HOST : AdGuard Home base URL (e.g. http://192.168.1.1:3000)
|
||||||
|
- ADGUARD_USER : API username (your adguard user)
|
||||||
|
- ADGUARD_PASS : API password (your adguard pass)
|
||||||
|
- EXPORTER_PORT : Port to expose metrics (default: 9617)
|
||||||
|
- SCRAPE_INTERVAL : Interval (in seconds) to fetch new stats (default: 15)
|
||||||
|
- LOG_LEVEL : Logging level (options: DEBUG, INFO, WARN, ERROR — default: INFO)
|
||||||
|
- GEOIP_DB : GeoLite2-City.mmdb
|
||||||
|
*/
|
||||||
|
|
||||||
|
var logLevelMap = map[string]int{"ERROR": 1, "WARN": 2, "INFO": 3, "DEBUG": 4}
|
||||||
|
var currentLogLevel = 3
|
||||||
|
|
||||||
|
func initLogger() {
|
||||||
|
level := os.Getenv("LOG_LEVEL")
|
||||||
|
if level == "" {
|
||||||
|
level = "INFO"
|
||||||
|
}
|
||||||
|
if val, ok := logLevelMap[level]; ok {
|
||||||
|
currentLogLevel = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logX(level string, format string, args ...interface{}) {
|
||||||
|
if logLevelMap[level] <= currentLogLevel {
|
||||||
|
log.Printf("[%s] %s", level, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//function hash query
|
||||||
|
func buildQueryKey(client, domain, upstream, reason, elapsed string) string {
|
||||||
|
return client + "|" + domain + "|" + upstream + "|" + reason + "|" + elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeoCache struct {
|
||||||
|
Country string
|
||||||
|
Lat float64
|
||||||
|
Lon float64
|
||||||
|
TS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
geoDB *geoip2.Reader
|
||||||
|
geoCache = map[string]GeoCache{}
|
||||||
|
geoMutex sync.RWMutex
|
||||||
|
// cache query hash
|
||||||
|
querySeen = map[string]int64{}
|
||||||
|
queryMutex sync.Mutex
|
||||||
|
queryTTL = int64(300) // 5 menit
|
||||||
|
geoTTL = int64(86400) // 24 jam
|
||||||
|
dedupHits int64
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdGuardStats struct {
|
||||||
|
NumDNSQueries float64 `json:"num_dns_queries"`
|
||||||
|
NumBlockedFiltering float64 `json:"num_blocked_filtering"`
|
||||||
|
NumReplacedParental float64 `json:"num_replaced_parental"`
|
||||||
|
AvgProcessingTime float64 `json:"avg_processing_time"`
|
||||||
|
TopQueriedDomains []map[string]float64 `json:"top_queried_domains"`
|
||||||
|
TopBlockedDomains []map[string]float64 `json:"top_blocked_domains"`
|
||||||
|
TopClients []map[string]float64 `json:"top_clients"`
|
||||||
|
TopUpstream []map[string]float64 `json:"top_upstreams_responses"`
|
||||||
|
TopUpstreamTime []map[string]float64 `json:"top_upstreams_avg_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdGuardStatus struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
DNSAddresses []string `json:"dns_addresses"`
|
||||||
|
DNSPort int `json:"dns_port"`
|
||||||
|
HTTPPort int `json:"http_port"`
|
||||||
|
ProtectionDisabledDuration int `json:"protection_disabled_duration"`
|
||||||
|
ProtectionEnabled bool `json:"protection_enabled"`
|
||||||
|
DHCPAvailable bool `json:"dhcp_available"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdGuardQueryLog struct {
|
||||||
|
Data []struct {
|
||||||
|
Question struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"question"`
|
||||||
|
Answer []interface{} `json:"answer"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Client string `json:"client"`
|
||||||
|
Elapsed string `json:"elapsedMs"`
|
||||||
|
Upstream string `json:"upstream"`
|
||||||
|
|
||||||
|
ClientInfo struct {
|
||||||
|
Whois struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
OrgName string `json:"orgname"`
|
||||||
|
} `json:"whois"`
|
||||||
|
} `json:"client_info"`
|
||||||
|
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
|
||||||
|
dnsQueries = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_dns_queries_total", Help: "Total DNS queries received",
|
||||||
|
})
|
||||||
|
|
||||||
|
blockedFiltering = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_blocked_filtering_total", Help: "Total DNS queries blocked",
|
||||||
|
})
|
||||||
|
|
||||||
|
replacedParental = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_replaced_parental", Help: "Total parental replaced queries",
|
||||||
|
})
|
||||||
|
|
||||||
|
avgProcessingTime = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_avg_processing_time", Help: "Avg DNS processing time",
|
||||||
|
})
|
||||||
|
|
||||||
|
statusProtectionEnabled = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_protection_enabled", Help: "Protection enabled (1/0)",
|
||||||
|
})
|
||||||
|
|
||||||
|
statusRunning = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_running", Help: "AdGuard running",
|
||||||
|
})
|
||||||
|
|
||||||
|
statusDHCPAvailable = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_dhcp_available", Help: "DHCP available",
|
||||||
|
})
|
||||||
|
|
||||||
|
statusDisabledDuration = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_protection_disabled_duration_seconds",
|
||||||
|
Help: "Time since protection disabled",
|
||||||
|
})
|
||||||
|
|
||||||
|
versionInfo = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_version_info",
|
||||||
|
Help: "AdGuard version",
|
||||||
|
},
|
||||||
|
[]string{"version"},
|
||||||
|
)
|
||||||
|
|
||||||
|
topQueriedDomains = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_top_queried_domain_total",
|
||||||
|
Help: "Top queried domains",
|
||||||
|
},
|
||||||
|
[]string{"domain"},
|
||||||
|
)
|
||||||
|
|
||||||
|
topBlockedDomains = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_top_blocked_domain_total",
|
||||||
|
Help: "Top blocked domains",
|
||||||
|
},
|
||||||
|
[]string{"domain"},
|
||||||
|
)
|
||||||
|
|
||||||
|
topClients = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_top_client_total",
|
||||||
|
Help: "Top client IPs",
|
||||||
|
},
|
||||||
|
[]string{"client"},
|
||||||
|
)
|
||||||
|
|
||||||
|
topUpstreams = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_top_upstream_total",
|
||||||
|
Help: "Top upstream servers",
|
||||||
|
},
|
||||||
|
[]string{"upstream"},
|
||||||
|
)
|
||||||
|
|
||||||
|
topUpstreamTime = prometheus.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_upstream_avg_response_time_seconds",
|
||||||
|
Help: "Avg response time per upstream",
|
||||||
|
},
|
||||||
|
[]string{"upstream"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountByReason = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_reason_total",
|
||||||
|
Help: "Queries by reason",
|
||||||
|
},
|
||||||
|
[]string{"reason"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountByType = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_type_total",
|
||||||
|
Help: "Queries by DNS type",
|
||||||
|
},
|
||||||
|
[]string{"type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryHistogramByClient = prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "adguard_query_elapsed_ms",
|
||||||
|
Help: "Query duration by client",
|
||||||
|
Buckets: prometheus.LinearBuckets(1, 5, 10),
|
||||||
|
},
|
||||||
|
[]string{"client"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountByUpstream = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_upstream_total",
|
||||||
|
Help: "Queries per upstream",
|
||||||
|
},
|
||||||
|
[]string{"upstream"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountByDomain = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_domain_total",
|
||||||
|
Help: "Queries per domain",
|
||||||
|
},
|
||||||
|
[]string{"domain"},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountClientReason = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_client_reason_total",
|
||||||
|
Help: "Queries per client per reason",
|
||||||
|
},
|
||||||
|
[]string{"client", "reason"},
|
||||||
|
)
|
||||||
|
|
||||||
|
clientGeoQueries = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_client_geo_queries",
|
||||||
|
Help: "Queries per client with geographic info",
|
||||||
|
},
|
||||||
|
[]string{"client", "country", "lat", "lon"},
|
||||||
|
)
|
||||||
|
|
||||||
|
blockedGeoQueries = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_blocked_geo_queries",
|
||||||
|
Help: "Blocked DNS queries per client with geographic info",
|
||||||
|
},
|
||||||
|
[]string{"client", "country", "lat", "lon"},
|
||||||
|
)
|
||||||
|
|
||||||
|
upstreamLatencyHistogram = prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "adguard_upstream_latency_seconds",
|
||||||
|
Help: "Latency distribution per upstream DNS server",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(
|
||||||
|
0.001, // 1ms
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
[]string{"upstream"},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterUp = prometheus.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_exporter_up",
|
||||||
|
Help: "Exporter scrape success",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterScrapeDuration = prometheus.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_exporter_scrape_duration_seconds",
|
||||||
|
Help: "Exporter scrape duration",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterErrors = prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_exporter_scrape_errors_total",
|
||||||
|
Help: "Total exporter errors",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterQueryCacheSize = prometheus.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_exporter_query_cache_size",
|
||||||
|
Help: "Current number of entries in query deduplication cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterGeoCacheSize = prometheus.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "adguard_exporter_geo_cache_size",
|
||||||
|
Help: "Current number of cached GeoIP entries",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exporterDedupHits = prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_exporter_dedup_hits_total",
|
||||||
|
Help: "Total number of duplicate queries skipped by deduplication",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
queryCountByISP = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "adguard_query_isp_total",
|
||||||
|
Help: "DNS queries grouped by ISP organization",
|
||||||
|
},
|
||||||
|
[]string{"isp", "country"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
initLogger()
|
||||||
|
|
||||||
|
prometheus.MustRegister(
|
||||||
|
dnsQueries,
|
||||||
|
blockedFiltering,
|
||||||
|
replacedParental,
|
||||||
|
avgProcessingTime,
|
||||||
|
statusProtectionEnabled,
|
||||||
|
statusRunning,
|
||||||
|
statusDHCPAvailable,
|
||||||
|
statusDisabledDuration,
|
||||||
|
versionInfo,
|
||||||
|
topQueriedDomains,
|
||||||
|
topBlockedDomains,
|
||||||
|
topClients,
|
||||||
|
topUpstreams,
|
||||||
|
topUpstreamTime,
|
||||||
|
queryCountByReason,
|
||||||
|
queryCountByType,
|
||||||
|
queryHistogramByClient,
|
||||||
|
queryCountByUpstream,
|
||||||
|
queryCountByDomain,
|
||||||
|
queryCountClientReason,
|
||||||
|
clientGeoQueries,
|
||||||
|
blockedGeoQueries,
|
||||||
|
exporterUp,
|
||||||
|
exporterScrapeDuration,
|
||||||
|
exporterErrors,
|
||||||
|
upstreamLatencyHistogram,
|
||||||
|
exporterQueryCacheSize,
|
||||||
|
exporterGeoCacheSize,
|
||||||
|
exporterDedupHits,
|
||||||
|
queryCountByISP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGeo(ipStr string) (GeoCache, bool) {
|
||||||
|
if geoDB == nil {
|
||||||
|
return GeoCache{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil || ip.IsPrivate() || ip.IsLoopback() {
|
||||||
|
return GeoCache{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
geoMutex.RLock()
|
||||||
|
if val, ok := geoCache[ipStr]; ok {
|
||||||
|
|
||||||
|
// cache valid
|
||||||
|
if now-val.TS < geoTTL {
|
||||||
|
geoMutex.RUnlock()
|
||||||
|
return val, true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
geoMutex.RUnlock()
|
||||||
|
|
||||||
|
record, err := geoDB.City(ip)
|
||||||
|
if err != nil {
|
||||||
|
return GeoCache{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := GeoCache{
|
||||||
|
Country: record.Country.IsoCode,
|
||||||
|
Lat: record.Location.Latitude,
|
||||||
|
Lon: record.Location.Longitude,
|
||||||
|
TS: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
geoMutex.Lock()
|
||||||
|
geoCache[ipStr] = cache
|
||||||
|
geoMutex.Unlock()
|
||||||
|
|
||||||
|
return cache, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchQueryLog() (*AdGuardQueryLog, error) {
|
||||||
|
|
||||||
|
host := os.Getenv("ADGUARD_HOST")
|
||||||
|
user := os.Getenv("ADGUARD_USER")
|
||||||
|
pass := os.Getenv("ADGUARD_PASS")
|
||||||
|
|
||||||
|
url := host + "/control/querylog"
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
logX("WARN", "failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var logData AdGuardQueryLog
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &logData)
|
||||||
|
|
||||||
|
return &logData, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStats() (*AdGuardStats, error) {
|
||||||
|
|
||||||
|
host := os.Getenv("ADGUARD_HOST")
|
||||||
|
user := os.Getenv("ADGUARD_USER")
|
||||||
|
pass := os.Getenv("ADGUARD_PASS")
|
||||||
|
|
||||||
|
url := host + "/control/stats"
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
logX("WARN", "failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats AdGuardStats
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &stats)
|
||||||
|
|
||||||
|
return &stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStatus() (*AdGuardStatus, error) {
|
||||||
|
|
||||||
|
host := os.Getenv("ADGUARD_HOST")
|
||||||
|
user := os.Getenv("ADGUARD_USER")
|
||||||
|
pass := os.Getenv("ADGUARD_PASS")
|
||||||
|
|
||||||
|
url := host + "/control/status"
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
logX("WARN", "failed to close response body: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var status AdGuardStatus
|
||||||
|
|
||||||
|
err = json.Unmarshal(body, &status)
|
||||||
|
|
||||||
|
return &status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStatsMetrics() {
|
||||||
|
|
||||||
|
stats, err := fetchStats()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logX("ERROR", "Failed fetch stats: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsQueries.Set(stats.NumDNSQueries)
|
||||||
|
blockedFiltering.Set(stats.NumBlockedFiltering)
|
||||||
|
replacedParental.Set(stats.NumReplacedParental)
|
||||||
|
avgProcessingTime.Set(stats.AvgProcessingTime)
|
||||||
|
|
||||||
|
// reset gauge vectors
|
||||||
|
topQueriedDomains.Reset()
|
||||||
|
topBlockedDomains.Reset()
|
||||||
|
topClients.Reset()
|
||||||
|
topUpstreams.Reset()
|
||||||
|
topUpstreamTime.Reset()
|
||||||
|
|
||||||
|
// top queried domains
|
||||||
|
for _, d := range stats.TopQueriedDomains {
|
||||||
|
for domain, count := range d {
|
||||||
|
topQueriedDomains.WithLabelValues(domain).Set(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// top blocked domains
|
||||||
|
for _, d := range stats.TopBlockedDomains {
|
||||||
|
for domain, count := range d {
|
||||||
|
topBlockedDomains.WithLabelValues(domain).Set(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// top clients
|
||||||
|
for _, c := range stats.TopClients {
|
||||||
|
for client, count := range c {
|
||||||
|
topClients.WithLabelValues(client).Set(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream responses
|
||||||
|
for _, u := range stats.TopUpstream {
|
||||||
|
for upstream, count := range u {
|
||||||
|
topUpstreams.WithLabelValues(upstream).Set(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream avg response time
|
||||||
|
for _, u := range stats.TopUpstreamTime {
|
||||||
|
for upstream, time := range u {
|
||||||
|
topUpstreamTime.WithLabelValues(upstream).Set(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logX(
|
||||||
|
"DEBUG",
|
||||||
|
"Fetched stats: queries=%.0f blocked=%.0f replaced=%.0f avgTime=%.2fms topDomains=%d",
|
||||||
|
stats.NumDNSQueries,
|
||||||
|
stats.NumBlockedFiltering,
|
||||||
|
stats.NumReplacedParental,
|
||||||
|
stats.AvgProcessingTime,
|
||||||
|
len(stats.TopQueriedDomains),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStatusMetrics() {
|
||||||
|
|
||||||
|
status, err := fetchStatus()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logX("ERROR", "Failed fetch status: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Running {
|
||||||
|
statusRunning.Set(1)
|
||||||
|
} else {
|
||||||
|
statusRunning.Set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.ProtectionEnabled {
|
||||||
|
statusProtectionEnabled.Set(1)
|
||||||
|
} else {
|
||||||
|
statusProtectionEnabled.Set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.DHCPAvailable {
|
||||||
|
statusDHCPAvailable.Set(1)
|
||||||
|
} else {
|
||||||
|
statusDHCPAvailable.Set(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDisabledDuration.Set(float64(status.ProtectionDisabledDuration))
|
||||||
|
|
||||||
|
versionInfo.WithLabelValues(status.Version).Set(1)
|
||||||
|
|
||||||
|
logX(
|
||||||
|
"DEBUG",
|
||||||
|
"Fetched status: running=%v protection=%v DHCP=%v version=%s",
|
||||||
|
status.Running,
|
||||||
|
status.ProtectionEnabled,
|
||||||
|
status.DHCPAvailable,
|
||||||
|
status.Version,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateQueryLogMetrics() {
|
||||||
|
|
||||||
|
scanned := 0
|
||||||
|
processed := 0
|
||||||
|
skipped := 0
|
||||||
|
geoResolved := 0
|
||||||
|
privateClients := 0
|
||||||
|
publicClients := 0
|
||||||
|
|
||||||
|
logData, err := fetchQueryLog()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logX("ERROR", "Failed querylog: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range logData.Data {
|
||||||
|
|
||||||
|
scanned++
|
||||||
|
|
||||||
|
ip := net.ParseIP(q.Client)
|
||||||
|
|
||||||
|
if ip != nil {
|
||||||
|
if ip.IsPrivate() || ip.IsLoopback() {
|
||||||
|
privateClients++
|
||||||
|
} else {
|
||||||
|
publicClients++
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedup logic
|
||||||
|
key := buildQueryKey(
|
||||||
|
q.Client,
|
||||||
|
q.Question.Name,
|
||||||
|
q.Upstream,
|
||||||
|
q.Reason,
|
||||||
|
q.Elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
queryMutex.Lock()
|
||||||
|
|
||||||
|
if ts, exists := querySeen[key]; exists {
|
||||||
|
if now-ts < queryTTL {
|
||||||
|
queryMutex.Unlock()
|
||||||
|
skipped++
|
||||||
|
exporterDedupHits.Inc()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
querySeen[key] = now
|
||||||
|
queryMutex.Unlock()
|
||||||
|
|
||||||
|
processed++
|
||||||
|
|
||||||
|
queryCountByReason.WithLabelValues(q.Reason).Inc()
|
||||||
|
queryCountByType.WithLabelValues(q.Question.Type).Inc()
|
||||||
|
|
||||||
|
elapsedMs, err := strconv.ParseFloat(q.Elapsed, 64)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
|
||||||
|
queryHistogramByClient.WithLabelValues(q.Client).Observe(elapsedMs)
|
||||||
|
|
||||||
|
if q.Upstream != "" {
|
||||||
|
upstreamLatencyHistogram.WithLabelValues(q.Upstream).Observe(elapsedMs / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryCountByUpstream.WithLabelValues(q.Upstream).Inc()
|
||||||
|
queryCountByDomain.WithLabelValues(q.Question.Name).Inc()
|
||||||
|
queryCountClientReason.WithLabelValues(q.Client, q.Reason).Inc()
|
||||||
|
|
||||||
|
geo, ok := resolveGeo(q.Client)
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
|
||||||
|
geoResolved++
|
||||||
|
|
||||||
|
clientGeoQueries.WithLabelValues(
|
||||||
|
q.Client,
|
||||||
|
geo.Country,
|
||||||
|
fmt.Sprintf("%f", geo.Lat),
|
||||||
|
fmt.Sprintf("%f", geo.Lon),
|
||||||
|
).Inc()
|
||||||
|
|
||||||
|
if q.Reason == "FilteredBlackList" ||
|
||||||
|
q.Reason == "FilteredSafeBrowsing" ||
|
||||||
|
q.Reason == "FilteredParental" {
|
||||||
|
|
||||||
|
blockedGeoQueries.WithLabelValues(
|
||||||
|
q.Client,
|
||||||
|
geo.Country,
|
||||||
|
fmt.Sprintf("%f", geo.Lat),
|
||||||
|
fmt.Sprintf("%f", geo.Lon),
|
||||||
|
).Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isp := q.ClientInfo.Whois.OrgName
|
||||||
|
country := q.ClientInfo.Whois.Country
|
||||||
|
|
||||||
|
if isp != "" {
|
||||||
|
queryCountByISP.WithLabelValues(isp, country).Inc()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
logX("DEBUG",
|
||||||
|
"Querylog: scanned=%d new=%d skipped=%d geoip=%d dedupHits=%d",
|
||||||
|
scanned,
|
||||||
|
processed,
|
||||||
|
skipped,
|
||||||
|
geoResolved,
|
||||||
|
dedupHits,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//cleanup memory
|
||||||
|
func cleanupQueryCache() {
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
queryMutex.Lock()
|
||||||
|
|
||||||
|
for k, v := range querySeen {
|
||||||
|
|
||||||
|
if now-v > queryTTL {
|
||||||
|
delete(querySeen, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
queryMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
dbPath := os.Getenv("GEOIP_DB")
|
||||||
|
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "GeoLite2-City.mmdb"
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := geoip2.Open(dbPath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logX("WARN", "GeoIP DB not found (%v) — geo metrics disabled", err)
|
||||||
|
} else {
|
||||||
|
geoDB = db
|
||||||
|
logX("INFO", "GeoIP database loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeIntervalStr := os.Getenv("SCRAPE_INTERVAL")
|
||||||
|
port := os.Getenv("EXPORTER_PORT")
|
||||||
|
|
||||||
|
if port == "" {
|
||||||
|
port = "9617"
|
||||||
|
}
|
||||||
|
|
||||||
|
interval, err := strconv.Atoi(scrapeIntervalStr)
|
||||||
|
|
||||||
|
if err != nil || interval < 1 {
|
||||||
|
interval = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
logX("INFO", "Scrape interval set to %ds", interval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
|
||||||
|
for {
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
updateStatsMetrics()
|
||||||
|
updateStatusMetrics()
|
||||||
|
updateQueryLogMetrics()
|
||||||
|
|
||||||
|
cleanupQueryCache()
|
||||||
|
|
||||||
|
duration := time.Since(start).Seconds()
|
||||||
|
|
||||||
|
exporterScrapeDuration.Set(duration)
|
||||||
|
exporterUp.Set(1)
|
||||||
|
|
||||||
|
exporterQueryCacheSize.Set(float64(len(querySeen)))
|
||||||
|
exporterGeoCacheSize.Set(float64(len(geoCache)))
|
||||||
|
|
||||||
|
logX(
|
||||||
|
"DEBUG",
|
||||||
|
"Exporter caches: query_cache_entries=%d geo_cache_entries=%d dedup_hits_total=%d",
|
||||||
|
len(querySeen),
|
||||||
|
len(geoCache),
|
||||||
|
dedupHits,
|
||||||
|
)
|
||||||
|
|
||||||
|
logX("DEBUG", "Scrape finished in %.3fs", duration)
|
||||||
|
|
||||||
|
time.Sleep(time.Duration(interval) * time.Second)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
|
http.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
logX("INFO", "Starting exporter at :%s ..", port)
|
||||||
|
|
||||||
|
err = http.ListenAndServe(":"+port, nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logX("ERROR", "Server failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,6 +1,28 @@
|
|||||||
FROM telegraf:latest
|
FROM buildpack-deps:bookworm-curl
|
||||||
LABEL org.opencontainers.image.authors="dockerimage@dresi.wtf"
|
LABEL org.opencontainers.image.authors="dockerimage@dresi.wtf"
|
||||||
|
|
||||||
|
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
iputils-ping snmp procps lm-sensors libcap2-bin && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN wget -q -O - https://repos.influxdata.com/influxdata-archive.key | gpg --import
|
||||||
|
|
||||||
|
ENV TELEGRAF_VERSION=1.39.0
|
||||||
|
RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" && \
|
||||||
|
case "${dpkgArch##*-}" in \
|
||||||
|
amd64) ARCH='amd64';; \
|
||||||
|
arm64) ARCH='arm64';; \
|
||||||
|
*) echo "Unsupported architecture: ${dpkgArch}"; exit 1;; \
|
||||||
|
esac && \
|
||||||
|
wget --no-verbose https://dl.influxdata.com/telegraf/releases/telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb.asc && \
|
||||||
|
wget --no-verbose https://dl.influxdata.com/telegraf/releases/telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb && \
|
||||||
|
gpg --batch --verify telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb.asc telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb && \
|
||||||
|
dpkg -i telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb && \
|
||||||
|
rm -f telegraf_${TELEGRAF_VERSION}-1_${ARCH}.deb*
|
||||||
|
|
||||||
RUN cd /usr/share/snmp/mibs && \
|
RUN cd /usr/share/snmp/mibs && \
|
||||||
wget -q https://raw.githubusercontent.com/net-snmp/net-snmp/master/mibs/SNMPv2-SMI.txt \
|
wget -q https://raw.githubusercontent.com/net-snmp/net-snmp/master/mibs/SNMPv2-SMI.txt \
|
||||||
https://raw.githubusercontent.com/net-snmp/net-snmp/master/mibs/SNMPv2-TC.txt \
|
https://raw.githubusercontent.com/net-snmp/net-snmp/master/mibs/SNMPv2-TC.txt \
|
||||||
@ -16,3 +38,8 @@ RUN cd /usr/share/snmp/mibs && \
|
|||||||
wget -q -O UBNT-UniFi-MIB \
|
wget -q -O UBNT-UniFi-MIB \
|
||||||
https://raw.githubusercontent.com/librenms/librenms/master/mibs/ubnt/UBNT-UniFi-MIB && \
|
https://raw.githubusercontent.com/librenms/librenms/master/mibs/ubnt/UBNT-UniFi-MIB && \
|
||||||
echo "mibs +ALL" >> /etc/snmp/snmp.conf
|
echo "mibs +ALL" >> /etc/snmp/snmp.conf
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["telegraf"]
|
||||||
|
|||||||
29
telegraf-snmp-unifi/entrypoint.sh
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${1:0:1}" = '-' ]; then
|
||||||
|
set -- telegraf "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $EUID -ne 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
else
|
||||||
|
# Allow telegraf to send ICMP packets and bind to privliged ports
|
||||||
|
setcap cap_net_raw,cap_net_bind_service+ep /usr/bin/telegraf || echo "Failed to set additional capabilities on /usr/bin/telegraf"
|
||||||
|
|
||||||
|
# ensure HOME is set to the telegraf user's home dir
|
||||||
|
export HOME=$(getent passwd telegraf | cut -d : -f 6)
|
||||||
|
|
||||||
|
# honor groups supplied via 'docker run --group-add ...' but drop 'root'
|
||||||
|
# (also removes 'telegraf' since we unconditionally add it and don't want it listed twice)
|
||||||
|
# see https://github.com/influxdata/influxdata-docker/issues/724
|
||||||
|
groups="telegraf"
|
||||||
|
extra_groups="$(id -Gn || true)"
|
||||||
|
for group in $extra_groups; do
|
||||||
|
case "$group" in
|
||||||
|
root | telegraf) ;;
|
||||||
|
*) groups="$groups,$group" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec setpriv --reuid telegraf --regid telegraf --groups "$groups" "$@"
|
||||||
|
fi
|
||||||