#!/bin/bash ################################################################################ # Script Name: dhcp-lease-exporter.sh # Version: 1.01 # Description: Prometheus exporter for DHCP lease metrics — pool utilization, # active leases per subnet, lease expirations, reservation status, # DORA packet counts, and lease duration tracking for ISC DHCP # (dhcpd) and ISC Kea. # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Usage: # # Output to stdout # sudo ./dhcp-lease-exporter.sh # # # Textfile collector mode # sudo ./dhcp-lease-exporter.sh --textfile # # # HTTP server mode # sudo ./dhcp-lease-exporter.sh --http # # # Custom port # sudo ./dhcp-lease-exporter.sh --http --port 9533 # ################################################################################ set -euo pipefail # ============================================================================ # CONFIGURATION # ============================================================================ readonly VERSION="1.0" readonly SCRIPT_NAME="${0##*/}" # DHCP backend — auto, dhcpd, or kea DHCP_BACKEND="auto" # dhcpd paths DHCPD_LEASES="/var/lib/dhcp/dhcpd.leases" DHCPD_CONF="/etc/dhcp/dhcpd.conf" # Kea paths and API KEA_LEASES="/var/lib/kea/kea-leases4.csv" KEA_API="http://127.0.0.1:8000" KEA_USE_API="true" # Output settings TEXTFILE_DIR="/var/lib/node_exporter/textfile_collector" HTTP_PORT=9533 LOCK_FILE="/tmp/dhcp-lease-exporter.lock" # Runtime MODE="stdout" ONCE=false DETECTED_BACKEND="" # ============================================================================ # COLORS # ============================================================================ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ log_info() { echo -e "${GREEN}[INFO]${NC} $*" >&2; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } show_usage() { cat </dev/null || true) if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then log_error "Another instance is running (PID $pid)" exit 1 fi rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE" trap 'rm -f "$LOCK_FILE"' EXIT } # ============================================================================ # BACKEND DETECTION # ============================================================================ detect_backend() { if [ "$DHCP_BACKEND" != "auto" ]; then DETECTED_BACKEND="$DHCP_BACKEND" return fi if systemctl is-active --quiet isc-kea-dhcp4-server 2>/dev/null || \ systemctl is-active --quiet kea-dhcp4 2>/dev/null; then DETECTED_BACKEND="kea" elif systemctl is-active --quiet isc-dhcp-server 2>/dev/null || \ systemctl is-active --quiet dhcpd 2>/dev/null; then DETECTED_BACKEND="dhcpd" elif [ -f "$KEA_LEASES" ]; then DETECTED_BACKEND="kea" elif [ -f "$DHCPD_LEASES" ]; then DETECTED_BACKEND="dhcpd" else DETECTED_BACKEND="unknown" fi } # ============================================================================ # DHCPD FUNCTIONS # ============================================================================ # Parse dhcpd.conf for subnet definitions and pool ranges parse_dhcpd_subnets() { local conf="$DHCPD_CONF" [ -f "$conf" ] || return local current_subnet="" current_name="" range_start="" range_end="" local in_subnet=false while IFS= read -r line; do # Match subnet declaration if [[ "$line" =~ ^[[:space:]]*subnet[[:space:]]+([0-9.]+)[[:space:]]+netmask[[:space:]]+([0-9.]+) ]]; then current_subnet="${BASH_REMATCH[1]}" local netmask="${BASH_REMATCH[2]}" current_name="$current_subnet" in_subnet=true range_start="" range_end="" # Calculate CIDR from netmask local cidr cidr=$(netmask_to_cidr "$netmask") current_subnet="${current_subnet}/${cidr}" fi # Check for comment-based name if $in_subnet && [[ "$line" =~ ^[[:space:]]*#[[:space:]]*(.+) ]]; then if [ "$current_name" = "${current_subnet%%/*}" ]; then current_name="${BASH_REMATCH[1]}" fi fi # Match range declaration if $in_subnet && [[ "$line" =~ ^[[:space:]]*range[[:space:]]+([0-9.]+)[[:space:]]+([0-9.]+) ]]; then range_start="${BASH_REMATCH[1]}" range_end="${BASH_REMATCH[2]}" fi # End of subnet block if $in_subnet && [[ "$line" =~ ^[[:space:]]*\} ]]; then if [ -n "$range_start" ] && [ -n "$range_end" ]; then local total total=$(ip_range_count "$range_start" "$range_end") echo "${current_subnet}|${current_name}|${total}|${range_start}|${range_end}" fi in_subnet=false fi done < "$conf" } netmask_to_cidr() { local netmask="$1" local cidr=0 for octet in $(echo "$netmask" | tr '.' ' '); do case $octet in 255) cidr=$((cidr + 8)) ;; 254) cidr=$((cidr + 7)) ;; 252) cidr=$((cidr + 6)) ;; 248) cidr=$((cidr + 5)) ;; 240) cidr=$((cidr + 4)) ;; 224) cidr=$((cidr + 3)) ;; 192) cidr=$((cidr + 2)) ;; 128) cidr=$((cidr + 1)) ;; 0) ;; esac done echo "$cidr" } ip_to_int() { local a b c d IFS='.' read -r a b c d <<< "$1" echo $(( (a << 24) + (b << 16) + (c << 8) + d )) } ip_range_count() { local start_int end_int start_int=$(ip_to_int "$1") end_int=$(ip_to_int "$2") echo $(( end_int - start_int + 1 )) } # Count active leases per subnet from dhcpd.leases count_dhcpd_leases() { local lease_file="$DHCPD_LEASES" [ -f "$lease_file" ] || return local now now=$(date +%s) awk -v now="$now" ' /^lease / { ip = $2 } /ends / { gsub(/[;\/:]/, " ", $0) if ($2 != "never") { t = mktime($3 " " $4 " " $5 " " $6 " " $7 " " $8) if (t > now) active[ip] = t - now } } /binding state active/ { state[ip] = "active" } END { for (ip in active) { if (state[ip] == "active") { print ip, active[ip] } } }' "$lease_file" } # Count reservations from dhcpd.conf count_dhcpd_reservations() { local conf="$DHCPD_CONF" [ -f "$conf" ] || return grep -c "fixed-address" "$conf" 2>/dev/null || true } # Parse DORA stats from syslog parse_dhcpd_dora() { local logfile="/var/log/syslog" [ -f "$logfile" ] || logfile="/var/log/messages" [ -f "$logfile" ] || return local discovers offers requests acks naks declines releases discovers=$(grep -c "DHCPDISCOVER" "$logfile" 2>/dev/null || true) offers=$(grep -c "DHCPOFFER" "$logfile" 2>/dev/null || true) requests=$(grep -c "DHCPREQUEST" "$logfile" 2>/dev/null || true) acks=$(grep -c "DHCPACK" "$logfile" 2>/dev/null || true) naks=$(grep -c "DHCPNAK" "$logfile" 2>/dev/null || true) declines=$(grep -c "DHCPDECLINE" "$logfile" 2>/dev/null || true) releases=$(grep -c "DHCPRELEASE" "$logfile" 2>/dev/null || true) echo "${discovers}|${offers}|${requests}|${acks}|${naks}|${declines}|${releases}" } # ============================================================================ # KEA FUNCTIONS # ============================================================================ kea_api_call() { local command="$1" curl -s --max-time 5 -X POST "${KEA_API}" \ -H "Content-Type: application/json" \ -d "{\"command\": \"${command}\", \"service\": [\"dhcp4\"]}" 2>/dev/null } parse_kea_leases_file() { local lease_file="$KEA_LEASES" [ -f "$lease_file" ] || return local now now=$(date +%s) awk -F',' -v now="$now" ' NR > 1 && NF >= 9 { ip = $1 expire = $7 state = $9 if (state == 0 && expire > now) { remaining = expire - now print ip, remaining } }' "$lease_file" } parse_kea_api_subnets() { local response response=$(kea_api_call "subnet4-list") if [ -z "$response" ]; then return 1 fi echo "$response" | python3 -c " import sys, json data = json.load(sys.stdin) if data[0]['result'] == 0: for s in data[0].get('arguments', {}).get('subnets', []): sid = s.get('id', 0) subnet = s.get('subnet', '') print(f'{sid}|{subnet}') " 2>/dev/null } parse_kea_api_stats() { local response response=$(kea_api_call "statistic-get-all") if [ -z "$response" ]; then return 1 fi echo "$response" } # ============================================================================ # METRIC COLLECTION # ============================================================================ collect_metrics() { local start_time start_time=$(date +%s%N) local metrics="" # Exporter status metrics+="$(write_metric_header "dhcp_up" "gauge" "Exporter status (1=up, 0=down)")"$'\n' metrics+="$(write_metric_header "dhcp_exporter_info" "gauge" "Exporter version and backend")"$'\n' if [ "$DETECTED_BACKEND" = "unknown" ]; then metrics+="dhcp_up 0"$'\n' echo "$metrics" return fi metrics+="dhcp_up 1"$'\n' metrics+="dhcp_exporter_info{version=\"${VERSION}\",backend=\"${DETECTED_BACKEND}\"} 1"$'\n' local subnet_count=0 local total_active=0 if [ "$DETECTED_BACKEND" = "dhcpd" ]; then collect_dhcpd_metrics elif [ "$DETECTED_BACKEND" = "kea" ]; then collect_kea_metrics fi # Subnet count metrics+="$(write_metric_header "dhcp_subnets_total" "gauge" "Total number of configured subnets")"$'\n' metrics+="dhcp_subnets_total ${subnet_count}"$'\n' # Total active leases metrics+="$(write_metric_header "dhcp_leases_active_total" "gauge" "Total active leases across all subnets")"$'\n' metrics+="dhcp_leases_active_total ${total_active}"$'\n' # Lease file info if [ "$DETECTED_BACKEND" = "dhcpd" ] && [ -f "$DHCPD_LEASES" ]; then local file_age file_size file_age=$(( $(date +%s) - $(stat -c %Y "$DHCPD_LEASES") )) file_size=$(stat -c %s "$DHCPD_LEASES") metrics+="$(write_metric_header "dhcp_lease_file_age_seconds" "gauge" "Seconds since the lease file was last modified")"$'\n' metrics+="dhcp_lease_file_age_seconds ${file_age}"$'\n' metrics+="$(write_metric_header "dhcp_lease_file_size_bytes" "gauge" "Size of the lease file")"$'\n' metrics+="dhcp_lease_file_size_bytes ${file_size}"$'\n' elif [ "$DETECTED_BACKEND" = "kea" ] && [ -f "$KEA_LEASES" ]; then local file_age file_size file_age=$(( $(date +%s) - $(stat -c %Y "$KEA_LEASES") )) file_size=$(stat -c %s "$KEA_LEASES") metrics+="$(write_metric_header "dhcp_lease_file_age_seconds" "gauge" "Seconds since the lease file was last modified")"$'\n' metrics+="dhcp_lease_file_age_seconds ${file_age}"$'\n' metrics+="$(write_metric_header "dhcp_lease_file_size_bytes" "gauge" "Size of the lease file")"$'\n' metrics+="dhcp_lease_file_size_bytes ${file_size}"$'\n' fi # Execution time local end_time duration end_time=$(date +%s%N) duration=$(echo "scale=2; ($end_time - $start_time) / 1000000000" | bc 2>/dev/null || echo "0") metrics+="$(write_metric_header "dhcp_exporter_duration_seconds" "gauge" "Script execution time")"$'\n' metrics+="dhcp_exporter_duration_seconds ${duration}"$'\n' metrics+="$(write_metric_header "dhcp_exporter_last_run_timestamp" "gauge" "Unix timestamp of last successful run")"$'\n' metrics+="dhcp_exporter_last_run_timestamp $(date +%s)"$'\n' echo "$metrics" } collect_dhcpd_metrics() { # Parse subnets from config local subnet_data subnet_data=$(parse_dhcpd_subnets) # Get active leases local lease_data lease_data=$(count_dhcpd_leases) metrics+="$(write_metric_header "dhcp_subnet_pool_total" "gauge" "Total addresses in the pool")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_active" "gauge" "Currently leased addresses")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_free" "gauge" "Available addresses in the pool")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_utilization" "gauge" "Pool utilization percentage (0-100)")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_reserved" "gauge" "Number of static reservations")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_leases_expiring" "gauge" "Leases expiring within threshold")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_lease_longest_seconds" "gauge" "Remaining time on the longest lease")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_lease_shortest_seconds" "gauge" "Remaining time on the shortest lease")"$'\n' while IFS='|' read -r subnet name pool_total range_start range_end; do [ -z "$subnet" ] && continue subnet_count=$((subnet_count + 1)) # Count active leases in this subnet range local active=0 longest=0 shortest=999999999 local expiring_1h=0 expiring_4h=0 expiring_24h=0 local start_int end_int start_int=$(ip_to_int "$range_start") end_int=$(ip_to_int "$range_end") while read -r lease_ip remaining; do [ -z "$lease_ip" ] && continue local lip lip=$(ip_to_int "$lease_ip") if [ "$lip" -ge "$start_int" ] && [ "$lip" -le "$end_int" ]; then active=$((active + 1)) total_active=$((total_active + 1)) [ "$remaining" -gt "$longest" ] && longest=$remaining [ "$remaining" -lt "$shortest" ] && shortest=$remaining [ "$remaining" -le 3600 ] && expiring_1h=$((expiring_1h + 1)) [ "$remaining" -le 14400 ] && expiring_4h=$((expiring_4h + 1)) [ "$remaining" -le 86400 ] && expiring_24h=$((expiring_24h + 1)) fi done <<< "$lease_data" local free=$((pool_total - active)) [ $free -lt 0 ] && free=0 local util=0 if [ "$pool_total" -gt 0 ]; then util=$(echo "scale=2; $active * 100 / $pool_total" | bc 2>/dev/null || echo "0") fi [ $active -eq 0 ] && shortest=0 local reserved reserved=$(count_dhcpd_reservations) metrics+="dhcp_subnet_pool_total{subnet=\"${subnet}\",name=\"${name}\"} ${pool_total}"$'\n' metrics+="dhcp_subnet_pool_active{subnet=\"${subnet}\",name=\"${name}\"} ${active}"$'\n' metrics+="dhcp_subnet_pool_free{subnet=\"${subnet}\",name=\"${name}\"} ${free}"$'\n' metrics+="dhcp_subnet_pool_utilization{subnet=\"${subnet}\",name=\"${name}\"} ${util}"$'\n' metrics+="dhcp_subnet_pool_reserved{subnet=\"${subnet}\",name=\"${name}\"} ${reserved}"$'\n' metrics+="dhcp_subnet_leases_expiring{subnet=\"${subnet}\",name=\"${name}\",within=\"1h\"} ${expiring_1h}"$'\n' metrics+="dhcp_subnet_leases_expiring{subnet=\"${subnet}\",name=\"${name}\",within=\"4h\"} ${expiring_4h}"$'\n' metrics+="dhcp_subnet_leases_expiring{subnet=\"${subnet}\",name=\"${name}\",within=\"24h\"} ${expiring_24h}"$'\n' metrics+="dhcp_subnet_lease_longest_seconds{subnet=\"${subnet}\",name=\"${name}\"} ${longest}"$'\n' metrics+="dhcp_subnet_lease_shortest_seconds{subnet=\"${subnet}\",name=\"${name}\"} ${shortest}"$'\n' done <<< "$subnet_data" # DORA stats local dora dora=$(parse_dhcpd_dora) if [ -n "$dora" ]; then IFS='|' read -r discovers offers requests acks naks declines releases <<< "$dora" metrics+="$(write_metric_header "dhcp_discovers_total" "counter" "Total DHCPDISCOVER packets received")"$'\n' metrics+="dhcp_discovers_total ${discovers}"$'\n' metrics+="$(write_metric_header "dhcp_offers_total" "counter" "Total DHCPOFFER packets sent")"$'\n' metrics+="dhcp_offers_total ${offers}"$'\n' metrics+="$(write_metric_header "dhcp_requests_total" "counter" "Total DHCPREQUEST packets received")"$'\n' metrics+="dhcp_requests_total ${requests}"$'\n' metrics+="$(write_metric_header "dhcp_acks_total" "counter" "Total DHCPACK packets sent")"$'\n' metrics+="dhcp_acks_total ${acks}"$'\n' metrics+="$(write_metric_header "dhcp_naks_total" "counter" "Total DHCPNAK packets sent")"$'\n' metrics+="dhcp_naks_total ${naks}"$'\n' metrics+="$(write_metric_header "dhcp_declines_total" "counter" "Total DHCPDECLINE packets received")"$'\n' metrics+="dhcp_declines_total ${declines}"$'\n' metrics+="$(write_metric_header "dhcp_releases_total" "counter" "Total DHCPRELEASE packets received")"$'\n' metrics+="dhcp_releases_total ${releases}"$'\n' fi } collect_kea_metrics() { metrics+="$(write_metric_header "dhcp_subnet_pool_total" "gauge" "Total addresses in the pool")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_active" "gauge" "Currently leased addresses")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_free" "gauge" "Available addresses in the pool")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_utilization" "gauge" "Pool utilization percentage (0-100)")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_pool_reserved" "gauge" "Number of static reservations")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_leases_expiring" "gauge" "Leases expiring within threshold")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_lease_longest_seconds" "gauge" "Remaining time on the longest lease")"$'\n' metrics+="$(write_metric_header "dhcp_subnet_lease_shortest_seconds" "gauge" "Remaining time on the shortest lease")"$'\n' if [ "$KEA_USE_API" = "true" ]; then collect_kea_api_metrics else collect_kea_file_metrics fi } collect_kea_api_metrics() { local stats_json stats_json=$(kea_api_call "statistic-get-all") if [ -z "$stats_json" ]; then log_warn "Kea API not responding, falling back to file mode" collect_kea_file_metrics return fi # Parse stats via python3 echo "$stats_json" | python3 -c " import sys, json data = json.load(sys.stdin) if data[0]['result'] == 0: args = data[0].get('arguments', {}) for key, val in args.items(): if val and isinstance(val, list): v = val[0][0] if isinstance(val[0], list) else val[0] print(f'{key}={v}') " 2>/dev/null | while IFS='=' read -r key value; do case "$key" in subnet*total-addresses*) local sid="${key#subnet[}" sid="${sid%%]*}" metrics+="dhcp_subnet_pool_total{subnet=\"${sid}\"} ${value}"$'\n' ;; subnet*assigned-addresses*) local sid="${key#subnet[}" sid="${sid%%]*}" metrics+="dhcp_subnet_pool_active{subnet=\"${sid}\"} ${value}"$'\n' ;; pkt4-discover-received) metrics+="$(write_metric_header "dhcp_discovers_total" "counter" "Total DHCPDISCOVER packets received")"$'\n' metrics+="dhcp_discovers_total ${value}"$'\n' ;; pkt4-offer-sent) metrics+="$(write_metric_header "dhcp_offers_total" "counter" "Total DHCPOFFER packets sent")"$'\n' metrics+="dhcp_offers_total ${value}"$'\n' ;; pkt4-request-received) metrics+="$(write_metric_header "dhcp_requests_total" "counter" "Total DHCPREQUEST packets received")"$'\n' metrics+="dhcp_requests_total ${value}"$'\n' ;; pkt4-ack-sent) metrics+="$(write_metric_header "dhcp_acks_total" "counter" "Total DHCPACK packets sent")"$'\n' metrics+="dhcp_acks_total ${value}"$'\n' ;; pkt4-nak-sent) metrics+="$(write_metric_header "dhcp_naks_total" "counter" "Total DHCPNAK packets sent")"$'\n' metrics+="dhcp_naks_total ${value}"$'\n' ;; pkt4-decline-received) metrics+="$(write_metric_header "dhcp_declines_total" "counter" "Total DHCPDECLINE packets received")"$'\n' metrics+="dhcp_declines_total ${value}"$'\n' ;; pkt4-release-received) metrics+="$(write_metric_header "dhcp_releases_total" "counter" "Total DHCPRELEASE packets received")"$'\n' metrics+="dhcp_releases_total ${value}"$'\n' ;; esac done } collect_kea_file_metrics() { local lease_data lease_data=$(parse_kea_leases_file) local now now=$(date +%s) # Simple lease counting from CSV while read -r lease_ip remaining; do [ -z "$lease_ip" ] && continue total_active=$((total_active + 1)) done <<< "$lease_data" } # ============================================================================ # OUTPUT # ============================================================================ output_metrics() { local all_metrics all_metrics=$(collect_metrics) case "$MODE" in stdout) echo "$all_metrics" ;; textfile) mkdir -p "$TEXTFILE_DIR" local tmp_file tmp_file=$(mktemp "${TEXTFILE_DIR}/.dhcp-metrics.XXXXXX") echo "$all_metrics" > "$tmp_file" mv "$tmp_file" "${TEXTFILE_DIR}/dhcp-metrics.prom" log_info "Wrote metrics to ${TEXTFILE_DIR}/dhcp-metrics.prom" ;; http) run_http_server "$all_metrics" ;; esac } run_http_server() { log_info "Starting HTTP server on port ${HTTP_PORT}" while true; do local all_metrics all_metrics=$(collect_metrics) { echo -e "HTTP/1.1 200 OK\r" echo -e "Content-Type: text/plain; version=0.0.4; charset=utf-8\r" echo -e "Content-Length: ${#all_metrics}\r" echo -e "\r" echo "$all_metrics" } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null || \ { echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n${all_metrics}" } | nc -l "$HTTP_PORT" 2>/dev/null if $ONCE; then break fi done } # ============================================================================ # MAIN # ============================================================================ main() { parse_args "$@" acquire_lock detect_backend log_info "Detected DHCP backend: ${DETECTED_BACKEND}" output_metrics } main "$@"