Compare commits

..

No commits in common. "main" and "feat/fix-telegraf-snmp-unifi-base-mibs" have entirely different histories.

33 changed files with 6 additions and 6822 deletions

View File

@ -1,69 +0,0 @@
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 }}

View File

@ -10,7 +10,6 @@ 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

View File

@ -6,7 +6,6 @@
|---|---| |---|---|
| `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
@ -17,15 +16,3 @@
| `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).

View File

@ -1,19 +0,0 @@
# 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

View File

@ -1,29 +0,0 @@
{
"$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"
]
}
]
}

View File

@ -1,44 +0,0 @@
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

View File

@ -1,32 +0,0 @@
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 }}

View File

@ -1,13 +0,0 @@
# Binaries
adguardexporter
dist/
# Env file
.env
# OS / Editor files
.DS_Store
*.swp
#db
GeoLite2-City.mmdb
geolite2-db/GeoLite2-City.mmdb

View File

@ -1,79 +0,0 @@
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

View File

@ -1,20 +0,0 @@
# 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

View File

@ -1,34 +0,0 @@
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"]

View File

@ -1,21 +0,0 @@
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.

View File

@ -1,307 +0,0 @@
# 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
![dashboard1](./grafana/dashboard01.png)
![dashboard2](./grafana/dashboard02.png)
![dashboard3](./grafana/dashboard03.png)
![dashboard4](./grafana/dashboard04.png)
![dashboard5](./grafana/dashboard06.png)
---
## 💬 Feedback
Pull Requests, Issues, and Suggestions are always welcome!
**Made with ❤️ by [znanddev](https://github.com/znand-dev)**

View File

@ -1,21 +0,0 @@
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

View File

@ -1,8 +0,0 @@
Download GeoLite2 City database from MaxMind:
https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
Place the file here:
geoip/GeoLite2-City.mmdb

View File

@ -1,25 +0,0 @@
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
)

View File

@ -1,52 +0,0 @@
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=

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

View File

@ -1,860 +0,0 @@
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)
}
}

View File

@ -1,73 +0,0 @@
# rsyncd
Simple rsync server running in a Docker container.
Image: `harbor.lan/tools/rsyncd:latest`
## Usage modes
### 1. Custom config (mount rsyncd.conf)
If `/etc/rsyncd.conf` is mounted, the container uses it as-is — no env vars needed.
```yaml
services:
rsyncd:
image: harbor.lan/tools/rsyncd:latest
ports:
- "873:873"
volumes:
- ./rsyncd.conf:/etc/rsyncd.conf:ro
- /srv/backup:/data
```
### 2. Multiple modules via env vars
Modules are defined via `MODULE_1_*`, `MODULE_2_*`, `MODULE_3_*` etc. — add as many as needed, as long as the numbering is consecutive.
```yaml
services:
rsyncd:
image: harbor.lan/tools/rsyncd:latest
ports:
- "873:873"
environment:
- ALLOW=10.0.0.0/8
- MODULE_1_NAME=backup
- MODULE_1_PATH=/data/backup
- MODULE_2_NAME=media
- MODULE_2_PATH=/data/media
- MODULE_2_ALLOW=192.168.1.0/24
volumes:
- /srv/backup:/data/backup
- /srv/media:/data/media
```
### 3. Single module (simple)
```yaml
services:
rsyncd:
image: harbor.lan/tools/rsyncd:latest
ports:
- "873:873"
environment:
- VOLUME=backup
- ALLOW=10.0.0.0/8
- OWNER=1000
- GROUP=1000
volumes:
- /srv/backup:/volume
```
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `VOLUME` | `volume` | Module name (single-module mode) |
| `ALLOW` | `192.168.0.0/16 172.16.0.0/12` | Allowed IP ranges (global default) |
| `OWNER` | `nobody` | UID for created files |
| `GROUP` | `nogroup` | GID for created files |
| `MODULE_x_NAME` | — | Module name (multi-module mode) |
| `MODULE_x_PATH` | `/volume_x` | Path for module x |
| `MODULE_x_ALLOW` | `$ALLOW` | Allowed IPs for module x |

View File

@ -1,51 +1,23 @@
#!/bin/bash #!/bin/bash
VOLUME=${VOLUME:-"volume"}
ALLOW=${ALLOW:-192.168.0.0/16 172.16.0.0/12} ALLOW=${ALLOW:-192.168.0.0/16 172.16.0.0/12}
OWNER=${OWNER:-nobody} OWNER=${OWNER:-nobody}
GROUP=${GROUP:-nogroup} GROUP=${GROUP:-nogroup}
# create users matching ids passed if necessary
if [ "${GROUP}" != "nogroup" ]; then if [ "${GROUP}" != "nogroup" ]; then
groupadd -g "${GROUP}" rsyncdgroup 2>/dev/null groupadd -g ${GROUP} rsyncdgroup
fi fi
if [ "${OWNER}" != "nobody" ]; then if [ "${OWNER}" != "nobody" ]; then
useradd -u "${OWNER}" -G rsyncdgroup rsyncduser 2>/dev/null groupadd -u ${OWNER} -G rsyncdgroup rsyncduser
fi fi
if [ ! -f /etc/rsyncd.conf ]; then [ -f /etc/rsyncd.conf ] || cat <<EOF > /etc/rsyncd.conf
cat <<EOF > /etc/rsyncd.conf
uid = ${OWNER} uid = ${OWNER}
gid = ${GROUP} gid = ${GROUP}
use chroot = yes use chroot = yes
pid file = /var/run/rsyncd.pid pid file = /var/run/rsyncd.pid
log file = /dev/stdout log file = /dev/stdout
EOF
# multi-module mode: MODULE_1_NAME, MODULE_1_PATH, MODULE_1_ALLOW, ...
i=1
while true; do
name_var="MODULE_${i}_NAME"
path_var="MODULE_${i}_PATH"
allow_var="MODULE_${i}_ALLOW"
name="${!name_var}"
[ -z "${name}" ] && break
path="${!path_var:-/volume_${i}}"
allow="${!allow_var:-${ALLOW}}"
cat <<EOF >> /etc/rsyncd.conf
[${name}]
hosts deny = *
hosts allow = ${allow}
read only = false
path = ${path}
comment = ${name}
EOF
i=$((i + 1))
done
# single-module fallback: VOLUME env var
if [ "${i}" -eq 1 ]; then
VOLUME=${VOLUME:-volume}
cat <<EOF >> /etc/rsyncd.conf
[${VOLUME}] [${VOLUME}]
hosts deny = * hosts deny = *
hosts allow = ${ALLOW} hosts allow = ${ALLOW}
@ -53,7 +25,5 @@ EOF
path = /volume path = /volume
comment = ${VOLUME} comment = ${VOLUME}
EOF EOF
fi
fi
exec /usr/bin/rsync --no-detach --daemon --config /etc/rsyncd.conf "$@" exec /usr/bin/rsync --no-detach --daemon --config /etc/rsyncd.conf "$@"

View File

@ -1,28 +1,6 @@
FROM buildpack-deps:bookworm-curl FROM telegraf:latest
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 \
@ -38,8 +16,3 @@ 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"]

View File

@ -1,29 +0,0 @@
#!/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