#!/usr/bin/env bash ######################################################################################### #### pihole-exporter.sh — Prometheus metrics exporter for Pi-hole DNS #### #### Exports DNS query stats, blocking rates, top domains, client counts, #### #### and gravity database health as Prometheus metrics #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./pihole-exporter.sh --http --port 9617 #### #### #### #### See --help for all options. #### ######################################################################################### set -uo pipefail # ============================================================================ # COLOR VARIABLES # ============================================================================ RED="" GREEN="" YELLOW="" BLUE="" RESET="" setup_colors() { if [[ -t 2 ]] && [[ "${TERM:-}" != "dumb" ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' RESET='\033[0m' fi } # ============================================================================ # LOGGING HELPERS # ============================================================================ log() { echo -e "${GREEN}[INFO]${RESET} $*" >&2; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { [[ "${VERBOSE:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${RESET} $*" >&2; } die() { err "$@"; exit 1; } # ============================================================================ # CONFIGURATION VARIABLES # ============================================================================ PIHOLE_URL="${PIHOLE_URL:-http://localhost/admin/api.php}" PIHOLE_API_KEY="${PIHOLE_API_KEY:-}" TEXTFILE_DIR="/var/lib/node_exporter" OUTPUT_FILE="" HTTP_MODE=false HTTP_PORT=9617 VERBOSE=false # ============================================================================ # HELPER FUNCTIONS # ============================================================================ show_usage() { cat <&2; exit 1 ;; esac done } # Build API URL with optional authentication # Args: $1 - query parameter (e.g., "summary", "topItems") # Returns: full URL string build_url() { local query="$1" local url="${PIHOLE_URL}?${query}" if [[ -n "$PIHOLE_API_KEY" ]]; then url="${url}&auth=${PIHOLE_API_KEY}" fi echo "$url" } # Fetch JSON from Pi-hole API # Args: $1 - query parameter # Returns: JSON response on stdout, empty string on error api_fetch() { local query="$1" local url url=$(build_url "$query") verbose "Fetching: ${PIHOLE_URL}?${query}" curl -sf --max-time 10 "$url" 2>/dev/null } # Check prerequisites # Returns: 0 if OK, 1 if error check_deps() { if ! command -v curl >/dev/null 2>&1; then err "curl not found (required for API calls)" return 1 fi if ! command -v jq >/dev/null 2>&1; then err "jq not found (required for JSON parsing)" return 1 fi return 0 } # Escape special characters in Prometheus label values # Args: $1 - string to escape # Returns: escaped string safe for Prometheus labels prom_escape() { local val="$1" val="${val//\\/\\\\}" val="${val//\"/\\\"}" val="${val//$'\n'/}" echo "$val" } # ============================================================================ # METRIC GENERATION # ============================================================================ # Generate all Prometheus metrics # Returns: Prometheus text format metrics on stdout generate_metrics() { local script_start script_start=$(date +%s%N 2>/dev/null || date +%s) # Check dependencies if ! check_deps; then cat </dev/null) if [[ -z "$status" ]]; then cat </dev/null | tr -d ',') dns_queries_today=$(echo "$summary_json" | jq -r '.dns_queries_today // 0' 2>/dev/null | tr -d ',') ads_blocked_today=$(echo "$summary_json" | jq -r '.ads_blocked_today // 0' 2>/dev/null | tr -d ',') ads_percentage_today=$(echo "$summary_json" | jq -r '.ads_percentage_today // 0' 2>/dev/null | tr -d ',') unique_domains=$(echo "$summary_json" | jq -r '.unique_domains // 0' 2>/dev/null | tr -d ',') queries_forwarded=$(echo "$summary_json" | jq -r '.queries_forwarded // 0' 2>/dev/null | tr -d ',') queries_cached=$(echo "$summary_json" | jq -r '.queries_cached // 0' 2>/dev/null | tr -d ',') clients_ever_seen=$(echo "$summary_json" | jq -r '.clients_ever_seen // 0' 2>/dev/null | tr -d ',') unique_clients=$(echo "$summary_json" | jq -r '.unique_clients // 0' 2>/dev/null | tr -d ',') cat </dev/null | tr -d ',') reply_nxdomain=$(echo "$summary_json" | jq -r '.reply_NXDOMAIN // 0' 2>/dev/null | tr -d ',') reply_cname=$(echo "$summary_json" | jq -r '.reply_CNAME // 0' 2>/dev/null | tr -d ',') reply_ip=$(echo "$summary_json" | jq -r '.reply_IP // 0' 2>/dev/null | tr -d ',') cat </dev/null) cat </dev/null | while read -r qtype qval; do [[ -z "$qtype" ]] && continue local clean_type="${qtype#(}" clean_type="${clean_type%)}" echo "pihole_query_type{type=\"$(prom_escape "$clean_type")\"} $qval" done fi echo "" # ======================================================================== # TOP DOMAINS (PERMITTED) # ======================================================================== local topitems_json topitems_json=$(api_fetch "topItems") cat </dev/null | while read -r domain count; do [[ -z "$domain" ]] && continue echo "pihole_top_domain_queries{domain=\"$(prom_escape "$domain")\"} $count" done fi echo "" # ======================================================================== # TOP BLOCKED DOMAINS # ======================================================================== cat </dev/null | while read -r domain count; do [[ -z "$domain" ]] && continue echo "pihole_top_blocked_queries{domain=\"$(prom_escape "$domain")\"} $count" done fi echo "" # ======================================================================== # TOP CLIENTS # ======================================================================== local topclients_json topclients_json=$(api_fetch "topClients") cat </dev/null | while read -r client count; do [[ -z "$client" ]] && continue echo "pihole_top_client_queries{client=\"$(prom_escape "$client")\"} $count" done fi echo "" # ======================================================================== # EXPORTER RUNTIME # ======================================================================== local script_end script_duration script_end=$(date +%s) if [[ "$script_start" =~ [0-9]{10,} ]]; then local end_ns end_ns=$(date +%s%N 2>/dev/null || echo "${script_end}000000000") script_duration=$(( (end_ns - script_start) / 1000000000 )) else script_duration=$(( script_end - script_start )) fi cat </dev/null 2>&1; then die "netcat (nc) required for HTTP mode" fi while true; do { read -r request if [[ "$request" =~ ^GET\ /metrics ]]; then echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\n\r" generate_metrics else echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r" cat < Pi-hole Exporter v1.00

Pi-hole Prometheus Exporter v1.00

Metrics

DNS query stats, blocking rates, top domains, client activity.

EOF fi } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null done } # ============================================================================ # MAIN EXECUTION # ============================================================================ main() { setup_colors parse_args "$@" if [[ "$HTTP_MODE" == "true" ]]; then run_http_server elif [[ -n "$OUTPUT_FILE" ]]; then local output_dir output_dir="$(dirname "$OUTPUT_FILE")" mkdir -p "$output_dir" local temp_file temp_file=$(mktemp "${output_dir}/.pihole_metrics.XXXXXX") if ! generate_metrics > "$temp_file" 2>/dev/null; then rm -f "$temp_file" die "Failed to generate metrics" fi local file_lines file_lines=$(wc -l < "$temp_file" 2>/dev/null || echo 0) if [[ "$file_lines" -lt 10 ]]; then rm -f "$temp_file" die "Metrics file too small ($file_lines lines), keeping previous" fi chmod 644 "$temp_file" mv -f "$temp_file" "$OUTPUT_FILE" log "Metrics written to $OUTPUT_FILE ($file_lines lines)" else generate_metrics fi } main "$@"