#!/bin/bash ################################################################################ # Script Name: iptables-blocklist-metrics.sh # Version: 2.03 # Description: Prometheus exporter for iptables threat feed blocking metrics # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Changes in v2.03: # - Fixed blocked_total metric type from counter to gauge (rolling window) # - Fixed HTTP response headers (printf with proper \r\n termination) # - Fixed rule-stats grep prefix-matching wrong feed (word-boundary match) # - Cached iptables -L INPUT output once per scrape instead of per-feed # - Removed unused get_iptables_rule_stats helper function # - Used FEEDS_ARRAY instead of grep on config file for ipset status check # - Removed unnecessary sync call in textfile output path # # Changes in v2.02: # - Added journal data caching (single journalctl call instead of per-feed) # - Added feeds config caching into array # - Fixed ipset member counting to use Members: section # - Added SCRIPT_VERSION variable for version strings # - Added scrape timestamp metric # - Fixed hardcoded version in info metric and HTML page ################################################################################ # Ensure PATH includes sbin (for ipset/iptables when run from cron) export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" # # EXPORTED METRICS: # - iptables_blocklist_info - Exporter metadata # - iptables_blocklist_enabled_feeds - Count of enabled feeds # - iptables_blocklist_ipset_size - IPs per feed ipset (IPv4/v6) # - iptables_blocklist_blocked_total - Block counts per feed (1h, 24h) # - iptables_blocklist_effectiveness - Blocks per 1000 IPs (24h) # - iptables_blocklist_last_update_timestamp - Feed cache file mtime # - iptables_blocklist_cache_age_seconds - Age of feed cache files # - iptables_blocklist_file_size_bytes - Feed parsed file sizes # - iptables_blocklist_ip_version_ratio - IPv4 vs IPv6 distribution per feed # - iptables_blocklist_total_unique_ips - Total unique IPs across all feeds # - iptables_blocklist_total_rules - Total iptables rules # - iptables_blocklist_rule_packets - Packet counts from iptables rules # - iptables_blocklist_rule_bytes - Byte counts from iptables rules # - iptables_blocklist_conntrack_entries - Current conntrack entries # - iptables_blocklist_conntrack_max - Maximum conntrack entries # - iptables_blocklist_conntrack_usage_percent - Conntrack usage percentage # - iptables_blocklist_whitelist_size - Whitelist ipset sizes # - iptables_blocklist_exporter_runtime_seconds - Script execution time CONFIG_DIR="/etc/iptables-threats" CACHE_DIR="$CONFIG_DIR/cache" FEEDS_CONFIG="$CONFIG_DIR/feeds.conf" IPSET_PREFIX="iptables-feed" WHITELIST_IPSET="iptables-whitelist" WHITELIST_IPSET_V6="iptables-whitelist-v6" TEXTFILE_DIR="/var/lib/node_exporter" OUTPUT_FILE="" HTTP_MODE=false HTTP_PORT=9419 LOCK_FILE="/var/run/iptables-blocklist-metrics.lock" SCRIPT_VERSION="2.03" # Global cache variables JOURNAL_1H="" JOURNAL_24H="" FEEDS_ARRAY=() show_usage() { cat </dev/null | grep '\[THREAT' || echo "") JOURNAL_24H=$(timeout 30 journalctl -k --since "24 hours ago" 2>/dev/null | grep '\[THREAT' || echo "") } # Parse feeds config ONCE into array cache_feeds_config() { FEEDS_ARRAY=() if [ -f "$FEEDS_CONFIG" ]; then while IFS='|' read -r enabled name url type description; do [[ "$enabled" =~ ^#.*$ ]] && continue [[ -z "$enabled" ]] && continue FEEDS_ARRAY+=("$enabled|$name|$url|$type|$description") done < "$FEEDS_CONFIG" fi } get_ipset_size() { local ipset_name="$1" local count count=$(ipset list "$ipset_name" 2>/dev/null | sed -n '/^Members:$/,$p' | tail -n +2 | wc -l) echo "${count:-0}" } get_feed_blocks() { local feed="$1" local period="$2" local data case "$period" in "1 hour ago") data="$JOURNAL_1H" ;; "24 hours ago") data="$JOURNAL_24H" ;; *) echo 0; return ;; esac if [ -z "$data" ]; then echo 0; return; fi local count count=$(printf '%s' "$data" | grep -c "\[THREAT:${feed}\]" 2>/dev/null) echo "${count:-0}" } get_feed_blocks_v6() { local feed="$1" local period="$2" local data case "$period" in "1 hour ago") data="$JOURNAL_1H" ;; "24 hours ago") data="$JOURNAL_24H" ;; *) echo 0; return ;; esac if [ -z "$data" ]; then echo 0; return; fi local count count=$(printf '%s' "$data" | grep -c "\[THREAT-v6:${feed}\]" 2>/dev/null) echo "${count:-0}" } get_file_timestamp() { [ -f "$1" ] && stat -c %Y "$1" 2>/dev/null || echo "0" } get_file_size() { [ -f "$1" ] && stat -c %s "$1" 2>/dev/null || echo "0" } get_cache_age() { if [ -f "$1" ]; then echo $(($(date +%s) - $(stat -c %Y "$1" 2>/dev/null || echo 0))) else echo "0" fi } get_total_unique_ips() { local ip_version="$1" local count=0 if [ "$ip_version" = "4" ]; then count=$(cat "$CACHE_DIR/"*-v4.parsed 2>/dev/null | sort -u | wc -l 2>/dev/null) elif [ "$ip_version" = "6" ]; then count=$(cat "$CACHE_DIR/"*-v6.parsed 2>/dev/null | sort -u | wc -l 2>/dev/null) fi echo "${count:-0}" } get_conntrack_count() { if [ -f /proc/sys/net/netfilter/nf_conntrack_count ]; then cat /proc/sys/net/netfilter/nf_conntrack_count else echo "0" fi } get_conntrack_max() { if [ -f /proc/sys/net/netfilter/nf_conntrack_max ]; then cat /proc/sys/net/netfilter/nf_conntrack_max else echo "0" fi } get_ipset_memory() { local ipset_name="$1" local mem mem=$(ipset list "$ipset_name" -t 2>/dev/null | grep "Size in memory:" | awk '{print $4}') echo "${mem:-0}" } get_cache_disk_usage() { if [ -d "$CACHE_DIR" ]; then df -B1 "$CACHE_DIR" 2>/dev/null | tail -1 | awk '{print $3"|"$4"|"$5}' else echo "0|0|0%" fi } get_total_cache_size() { if [ -d "$CACHE_DIR" ]; then du -sb "$CACHE_DIR" 2>/dev/null | awk '{print $1}' else echo "0" fi } acquire_lock() { if [ -f "$LOCK_FILE" ]; then local pid pid=$(cat "$LOCK_FILE" 2>/dev/null) if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then echo "ERROR: Another instance is already running (PID: $pid)" >&2 exit 1 else echo "Removing stale lock file" >&2 rm -f "$LOCK_FILE" fi fi echo $$ > "$LOCK_FILE" trap cleanup EXIT INT TERM } cleanup() { rm -f "$LOCK_FILE" } generate_metrics() { local start_time start_time=$(date +%s) local iptables_input_stats iptables_input_stats=$(iptables -L INPUT -v -n -x 2>/dev/null) cat </dev/null | grep "^${IPSET_PREFIX}-"); do # Extract feed name and IP version local feed_name="${ipset_name#"${IPSET_PREFIX}"-}" local ip_version="4" if [[ "$feed_name" =~ -v6$ ]]; then feed_name="${feed_name%-v6}" ip_version="6" fi # Get status from config local status="disabled" if printf '%s\n' "${FEEDS_ARRAY[@]}" | grep -q "^1|${feed_name}|" 2>/dev/null; then status="enabled" fi local size size=$(get_ipset_size "$ipset_name") echo "iptables_blocklist_ipset_size{feed=\"$feed_name\",ip_version=\"$ip_version\",status=\"$status\"} $size" done cat </dev/null; then effectiveness_v4=$(awk "BEGIN {printf \"%.2f\", ($blocks_v4 / $ipset_size) * 1000}" 2>/dev/null || echo "0") effectiveness_v6=$(awk "BEGIN {printf \"%.2f\", ($blocks_v6 / $ipset_size) * 1000}" 2>/dev/null || echo "0") else effectiveness_v4="0" effectiveness_v6="0" fi echo "iptables_blocklist_effectiveness{feed=\"$name\",ip_version=\"4\"} $effectiveness_v4" echo "iptables_blocklist_effectiveness{feed=\"$name\",ip_version=\"6\"} $effectiveness_v6" done # Feed update/cache metrics cat </dev/null; then ratio_v4=$(awk "BEGIN {printf \"%.4f\", $v4_size / $total}" 2>/dev/null || echo "0") ratio_v6=$(awk "BEGIN {printf \"%.4f\", $v6_size / $total}" 2>/dev/null || echo "0") else ratio_v4="0" ratio_v6="0" fi echo "iptables_blocklist_ip_version_ratio{feed=\"$name\",version=\"4\"} $ratio_v4" echo "iptables_blocklist_ip_version_ratio{feed=\"$name\",version=\"6\"} $ratio_v6" done # Total metrics cat </dev/null | wc -l) # HELP iptables_blocklist_rule_packets Packet counts from iptables rules # TYPE iptables_blocklist_rule_packets counter EOF for feed_line in "${FEEDS_ARRAY[@]}"; do IFS='|' read -r enabled name url type description <<< "$feed_line" [ "$enabled" != "1" ] && continue local stats_log stats_drop packets_log bytes_log packets_drop bytes_drop stats_log=$(echo "$iptables_input_stats" | grep " ${IPSET_PREFIX}-${name} " | grep LOG | head -1 | awk '{print $1"|"$2}') stats_drop=$(echo "$iptables_input_stats" | grep " ${IPSET_PREFIX}-${name} " | grep DROP | head -1 | awk '{print $1"|"$2}') if [ -n "$stats_log" ]; then packets_log=$(echo "$stats_log" | cut -d'|' -f1) bytes_log=$(echo "$stats_log" | cut -d'|' -f2) echo "iptables_blocklist_rule_packets{feed=\"$name\",ip_version=\"4\",action=\"log\"} ${packets_log:-0}" fi if [ -n "$stats_drop" ]; then packets_drop=$(echo "$stats_drop" | cut -d'|' -f1) bytes_drop=$(echo "$stats_drop" | cut -d'|' -f2) echo "iptables_blocklist_rule_packets{feed=\"$name\",ip_version=\"4\",action=\"drop\"} ${packets_drop:-0}" fi done cat </dev/null; then conntrack_usage=$(awk "BEGIN {printf \"%.2f\", ($conntrack_count / $conntrack_max) * 100}" 2>/dev/null || echo "0") else conntrack_usage="0" fi # Cache disk metrics local disk_info cache_size disk_used disk_avail disk_pct disk_info=$(get_cache_disk_usage) cache_size=$(get_total_cache_size) disk_used=$(echo "$disk_info" | cut -d'|' -f1) disk_avail=$(echo "$disk_info" | cut -d'|' -f2) disk_pct=$(echo "$disk_info" | cut -d'|' -f3 | tr -d '%') cat </dev/null 2>&1; then echo "ERROR: netcat (nc) is required for HTTP mode" echo "Install with: yum install nmap-ncat (RHEL/CentOS)" echo " or: apt install netcat (Debian/Ubuntu)" exit 1 fi while true; do { read -r request if [[ "$request" =~ ^GET\ /metrics ]]; then printf "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4; charset=utf-8\r\n\r\n" cache_journal_data cache_feeds_config generate_metrics else printf "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n" echo "

iptables Blocklist Metrics Exporter v${SCRIPT_VERSION}

" echo "

Per-feed threat blocking statistics

" echo "

Metrics

" fi } | nc -l -p "$HTTP_PORT" -q 1 2>/dev/null done } main() { parse_args "$@" [ ! -d "$CONFIG_DIR" ] && { echo "ERROR: $CONFIG_DIR not found. Run iptables-blocklists.sh first" >&2; exit 1; } # Prevent multiple instances (skip for HTTP mode as it should run continuously) [ "$HTTP_MODE" != true ] && acquire_lock if [ "$HTTP_MODE" = true ]; then run_http_server elif [ -n "$OUTPUT_FILE" ]; then # Cache data before generating metrics cache_journal_data cache_feeds_config # Ensure output directory exists mkdir -p "$(dirname "$OUTPUT_FILE")" # Create temp file in /tmp (not in node_exporter directory!) # This prevents node_exporter from seeing partial writes local temp_file temp_file=$(mktemp /tmp/iptables_metrics.XXXXXX) # Generate metrics to temp file generate_metrics > "$temp_file" # FORCE NEW INODE: Delete old file first, then move # Some node_exporter versions cache file descriptors rm -f "$OUTPUT_FILE" # Move temp file to final location mv "$temp_file" "$OUTPUT_FILE" # Ensure node_exporter user can read it chmod 644 "$OUTPUT_FILE" else cache_journal_data cache_feeds_config generate_metrics fi } main "$@"