diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/README.md b/README.md index 6197315..d994972 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,14 @@ Wenn ein Client eine bestimmte Domain zu oft anfragt (z.B. >30x pro Minute), wir ## Features - Automatische Erkennung und Sperre bei Rate-Limit-Verstößen +- **Subdomain-Flood-Erkennung** — erkennt Random-Subdomain-Attacken (z.B. `abc123.microsoft.com`, `xyz456.microsoft.com`, ...) - **Progressive Sperren (Recidive)** — Wiederholungstäter werden stufenweise länger gesperrt (wie bei fail2ban) - Unterstützt **alle DNS-Protokolle**: DNS (53), DoH (443), DoT (853), DoQ (784/853/8853) - **IPv4 + IPv6** - Eigene iptables Chain — greift nicht in bestehende Regeln ein - Automatisches Entsperren nach konfigurierbarer Dauer - **Externe Blocklisten** — IP-Adressen von externen Textdateien (URLs) laden und automatisch sperren +- **AbuseIPDB Reporting** — permanent gesperrte IPs automatisch an AbuseIPDB melden - **Ban-History** — lückenlose Protokollierung aller Sperren/Entsperrungen mit Zeitstempel - Whitelist für vertrauenswürdige IPs - Dry-Run Modus zum gefahrlosen Testen diff --git a/adguard-shield.conf b/adguard-shield.conf index 1a9fb04..90af6cd 100644 --- a/adguard-shield.conf +++ b/adguard-shield.conf @@ -21,6 +21,22 @@ RATE_LIMIT_WINDOW=60 # Wie oft das Script die Logs prüft (in Sekunden) CHECK_INTERVAL=10 +# --- Subdomain-Flood-Erkennung (Random Subdomain Attack) --- +# Erkennt Bots/Clients die massenhaft zufällige Subdomains einer Domain abfragen +# Beispiel: abc123.microsoft.com, xyz456.microsoft.com, ... +# Dabei wird pro Client gezählt, wie viele EINDEUTIGE Subdomains einer +# Basisdomain (z.B. microsoft.com) im Zeitfenster aufgerufen werden. + +# Subdomain-Flood-Erkennung aktivieren +SUBDOMAIN_FLOOD_ENABLED=true + +# Maximale Anzahl eindeutiger Subdomains pro Basisdomain pro Client im Zeitfenster +# Beispiel: 50 = ein Client darf max. 50 verschiedene Subdomains von microsoft.com abfragen +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 + +# Zeitfenster in Sekunden für die Subdomain-Flood-Erkennung (60 = 1 Minute) +SUBDOMAIN_FLOOD_WINDOW=60 + # --- Sperr-Einstellungen --- # Wie lange ein Client gesperrt wird (in Sekunden, 3600 = 1 Stunde) BAN_DURATION=3600 @@ -32,6 +48,8 @@ IPTABLES_CHAIN="ADGUARD_SHIELD" # Port 53 = DNS (UDP + TCP) # Port 443 = DNS-over-HTTPS (DoH) # Port 853 = DNS-over-TLS (tls://...:853) / DNS-over-QUIC (quic://...:853) +# Hinweis: Das verwendete Protokoll (DNS/DoH/DoT/DoQ) wird automatisch +# aus der AdGuard Home API erkannt und in Logs/History angezeigt. BLOCKED_PORTS="53 443 853" # --- Whitelist --- @@ -118,6 +136,19 @@ PROGRESSIVE_BAN_MAX_LEVEL=5 # (86400 = 24 Stunden, 604800 = 7 Tage) PROGRESSIVE_BAN_RESET_AFTER=86400 +# --- AbuseIPDB Reporting (optional) --- +# Meldet permanent gesperrte IPs automatisch an AbuseIPDB +# Nur bei PERMANENTEN Sperren wird ein Report gesendet. +ABUSEIPDB_ENABLED=false + +# AbuseIPDB API-Key (https://www.abuseipdb.com/account/api) +ABUSEIPDB_API_KEY="" + +# Kategorien für den Report (kommagetrennt) +# 4 = DDoS Attack +# Siehe: https://www.abuseipdb.com/categories +ABUSEIPDB_CATEGORIES="4" + # --- Erweiterte Einstellungen --- # Pfad zur State-Datei (speichert aktive Sperren) STATE_DIR="/var/lib/adguard-shield" diff --git a/adguard-shield.sh b/adguard-shield.sh index 70c7995..66caa07 100644 --- a/adguard-shield.sh +++ b/adguard-shield.sh @@ -5,11 +5,11 @@ # # Autor: Patrick Asmus # E-Mail: support@techniverse.net -# Datum: 2026-03-03 +# Datum: 2026-03-04 # Lizenz: MIT ############################################################################### -VERSION="0.3.1" +VERSION="0.4.0" set -euo pipefail @@ -79,23 +79,25 @@ log_ban_history() { local count="${4:-}" local reason="${5:-}" local duration="${6:-}" + local protocol="${7:-}" local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" # Header schreiben falls Datei neu ist if [[ ! -f "$BAN_HISTORY_FILE" ]]; then echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE" - echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND" >> "$BAN_HISTORY_FILE" - echo "#───────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" + echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" + echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" fi if [[ -z "$duration" && "$action" == "BAN" ]]; then duration="${BAN_DURATION}s" fi [[ -z "$duration" ]] && duration="-" + [[ -z "$protocol" ]] && protocol="-" - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "$duration" "${reason:-rate-limit}" \ + printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ + "$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "$duration" "$protocol" "${reason:-rate-limit}" \ >> "$BAN_HISTORY_FILE" } @@ -111,8 +113,8 @@ get_offense_level() { fi local level last_offense now reset_after - level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2) - last_offense=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2) + level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true) + last_offense=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true) now=$(date '+%s') reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" @@ -141,7 +143,7 @@ increment_offense_level() { # Erstes Vergehen merken (bevor Datei überschrieben wird) local first_offense - first_offense=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2) + first_offense=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) [[ -z "$first_offense" ]] && first_offense="$now_readable" cat > "$offense_file" << EOF @@ -213,6 +215,74 @@ reset_offense_level() { rm -f "$offense_file" } +# ─── Protokoll-Erkennung ───────────────────────────────────────────────────── +# Wandelt AdGuard Home client_proto Werte in lesbare Protokoll-Namen um +# API-Werte: "" = Plain DNS, "doh" = DNS-over-HTTPS, "dot" = DNS-over-TLS, +# "doq" = DNS-over-QUIC, "dnscrypt" = DNSCrypt +format_protocol() { + local proto="$1" + case "${proto,,}" in + doh) echo "DoH" ;; + dot) echo "DoT" ;; + doq) echo "DoQ" ;; + dnscrypt) echo "DNSCrypt" ;; + ""|dns) echo "DNS" ;; + *) echo "${proto:-DNS}" ;; + esac +} + +# ─── AbuseIPDB Reporting ───────────────────────────────────────────────────── +# Meldet eine IP an AbuseIPDB (nur bei permanenten Sperren) +report_to_abuseipdb() { + local client_ip="$1" + local domain="$2" + local count="$3" + local reason="${4:-rate-limit}" + + if [[ "${ABUSEIPDB_ENABLED:-false}" != "true" ]]; then + return 0 + fi + + if [[ -z "${ABUSEIPDB_API_KEY:-}" ]]; then + log "WARN" "AbuseIPDB: API-Key nicht konfiguriert (ABUSEIPDB_API_KEY ist leer)" + return 1 + fi + + # Kommentar für AbuseIPDB erstellen (englisch) + local comment + if [[ "$reason" == "subdomain-flood" ]]; then + comment="DNS flooding on our DNS server: ${count}x ${domain} (random subdomain attack). Permanently banned by AdGuard Shield." + else + comment="DNS flooding on our DNS server: ${count}x ${domain}. Permanently banned by AdGuard Shield." + fi + + local categories="${ABUSEIPDB_CATEGORIES:-4}" + + log "INFO" "AbuseIPDB: Melde IP $client_ip (${comment})" + + local http_code + http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + --connect-timeout 10 \ + --max-time 15 \ + -X POST "https://api.abuseipdb.com/api/v2/report" \ + -H "Key: ${ABUSEIPDB_API_KEY}" \ + -H "Accept: application/json" \ + --data-urlencode "ip=${client_ip}" \ + --data-urlencode "categories=${categories}" \ + --data-urlencode "comment=${comment}" \ + 2>/dev/null) || true + + if [[ "$http_code" == "200" || "$http_code" == "429" ]]; then + if [[ "$http_code" == "429" ]]; then + log "WARN" "AbuseIPDB: Rate-Limit erreicht für $client_ip (HTTP 429) – Report wird später erneut versucht" + else + log "INFO" "AbuseIPDB: IP $client_ip erfolgreich gemeldet (HTTP $http_code)" + fi + else + log "ERROR" "AbuseIPDB: Meldung fehlgeschlagen für $client_ip (HTTP ${http_code:-timeout})" + fi +} + # ─── Verzeichnisse erstellen ────────────────────────────────────────────────── init_directories() { mkdir -p "$STATE_DIR" @@ -296,6 +366,9 @@ ban_client() { local client_ip="$1" local domain="$2" local count="$3" + local reason="${4:-rate-limit}" + local window="${5:-$RATE_LIMIT_WINDOW}" + local protocol="${6:-DNS}" # Prüfen ob bereits gesperrt local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" @@ -333,18 +406,18 @@ ban_client() { if [[ "$DRY_RUN" == "true" ]]; then if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display} [Stufe $offense_level]" + log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${window}s via $protocol) für ${duration_display} [Stufe $offense_level] [${reason}]" else - log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s)" + log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${window}s via $protocol) [${reason}]" fi - log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run" "${duration_display}" + log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run (${reason})" "${duration_display}" "$protocol" return 0 fi if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display} [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]" + log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${window}s via $protocol) für ${duration_display} [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}] [${reason}]" else - log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display}" + log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${window}s via $protocol) für ${duration_display} [${reason}]" fi # IPv4 oder IPv6 erkennen @@ -367,16 +440,23 @@ BAN_UNTIL=$ban_until_display BAN_DURATION=${effective_duration} OFFENSE_LEVEL=$offense_level IS_PERMANENT=$is_permanent +REASON=$reason +PROTOCOL=$protocol EOF # Ban-History Eintrag local history_duration="${duration_display}" [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]] && history_duration="${duration_display} (Stufe ${offense_level})" - log_ban_history "BAN" "$client_ip" "$domain" "$count" "rate-limit" "$history_duration" + log_ban_history "BAN" "$client_ip" "$domain" "$count" "$reason" "$history_duration" "$protocol" # Benachrichtigung senden if [[ "$NOTIFY_ENABLED" == "true" ]]; then - send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" + send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" "$reason" "$window" "$protocol" + fi + + # AbuseIPDB Report (nur bei permanenter Sperre) + if [[ "$is_permanent" == "true" ]]; then + report_to_abuseipdb "$client_ip" "$domain" "$count" "$reason" & fi } @@ -386,11 +466,14 @@ unban_client() { local reason="${2:-expired}" local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" - # Domain aus State lesen bevor wir löschen + # Domain und Protokoll aus State lesen bevor wir löschen local domain="-" + local protocol="-" if [[ -f "$state_file" ]]; then - domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2) + domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2 || true) + protocol=$(grep '^PROTOCOL=' "$state_file" | cut -d= -f2 || true) fi + [[ -z "$protocol" ]] && protocol="-" log "INFO" "ENTSPERRE Client: $client_ip ($reason)" @@ -403,7 +486,7 @@ unban_client() { rm -f "$state_file" # Ban-History Eintrag - log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason" + log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason" "-" "$protocol" if [[ "$NOTIFY_ENABLED" == "true" ]]; then send_notification "unban" "$client_ip" "" "" @@ -419,11 +502,11 @@ check_expired_bans() { [[ -f "$state_file" ]] || continue local ban_until_epoch - ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2) + ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2 || true) local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) + client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) local is_permanent - is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2) + is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2 || true) # Permanente Sperren nicht automatisch aufheben if [[ "$is_permanent" == "true" || "$ban_until_epoch" == "0" ]]; then @@ -445,20 +528,26 @@ send_notification() { local count="$4" local offense_level="${5:-}" local duration_display="${6:-}" + local reason="${7:-rate-limit}" + local window="${8:-$RATE_LIMIT_WINDOW}" + local protocol="${9:-DNS}" # Ntfy benötigt keine Webhook-URL (nutzt NTFY_SERVER_URL + NTFY_TOPIC) if [[ "$NOTIFY_TYPE" != "ntfy" && -z "$NOTIFY_WEBHOOK_URL" ]]; then return fi + local reason_label="Rate-Limit" + [[ "$reason" == "subdomain-flood" ]] && reason_label="Subdomain-Flood" + local message if [[ "$action" == "ban" ]]; then if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -n "$offense_level" ]]; then - message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${RATE_LIMIT_WINDOW}s). Sperre für **${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]." + message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s via **$protocol**, ${reason_label}). Sperre für **${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]." else local simple_dur simple_dur=$(format_duration "${BAN_DURATION}") - message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${RATE_LIMIT_WINDOW}s). Sperre für ${simple_dur}." + message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s via **$protocol**, ${reason_label}). Sperre für ${simple_dur}." fi elif [[ "$action" == "service_start" ]]; then message="🟢 AdGuard Shield v${VERSION} wurde gestartet." @@ -580,10 +669,11 @@ analyze_queries() { entry_count=$(echo "$api_response" | jq '.data // [] | length' 2>/dev/null || echo "0") log "INFO" "API-Abfrage: ${entry_count} Einträge erhalten, prüfe Zeitfenster ${RATE_LIMIT_WINDOW}s..." - # Extrahiere Client-IP + Domain Paare aus dem Zeitfenster + # Extrahiere Client-IP + Domain + Protokoll Paare aus dem Zeitfenster # und zähle die Häufigkeit pro (client, domain) Kombination # Unterstützt .question.name (alte API) und .question.host (neue API) # Unterstützt Timestamps mit UTC ("Z") und Zeitzonen-Offset ("+01:00") + # Protokoll: client_proto aus der API → ""/dns = Plain DNS, doh, dot, doq, dnscrypt local violations="" violations=$(echo "$api_response" | jq -r --argjson window_start "$window_start" ' # ISO 8601 Timestamp zu Unix-Epoch konvertieren @@ -612,18 +702,20 @@ analyze_queries() { select((.time | to_epoch) >= $window_start) | { client: (.client // .client_info.ip // "unknown"), - domain: ((.question.name // .question.host // "unknown") | rtrimstr(".")) + domain: ((.question.name // .question.host // "unknown") | rtrimstr(".")), + proto: (.client_proto // "") } ] | group_by(.client + "|" + .domain) | map({ client: .[0].client, domain: .[0].domain, - count: length + count: length, + protocols: ([.[].proto | if . == "" then "dns" else . end] | unique | join(",")) }) | .[] | select(.count > 0) | - "\(.client)|\(.domain)|\(.count)" + "\(.client)|\(.domain)|\(.count)|\(.protocols)" ') || { log "ERROR" "jq Analyse fehlgeschlagen - API-Antwort-Format prüfen (ist AdGuard Home erreichbar?)" return @@ -635,22 +727,156 @@ analyze_queries() { fi # Prüfe jede Kombination gegen das Limit - while IFS='|' read -r client domain count; do + while IFS='|' read -r client domain count protocols; do [[ -z "$client" || -z "$domain" || -z "$count" ]] && continue - log "INFO" "Client: $client, Domain: $domain, Anfragen: $count/$RATE_LIMIT_MAX_REQUESTS" + # Protokoll-Namen formatieren für die Anzeige + local proto_display="" + if [[ -n "$protocols" ]]; then + local -a proto_parts=() + IFS=',' read -ra raw_protos <<< "$protocols" + for p in "${raw_protos[@]}"; do + proto_parts+=("$(format_protocol "$p")") + done + proto_display=$(IFS=','; echo "${proto_parts[*]}") + else + proto_display="DNS" + fi + + log "INFO" "Client: $client, Domain: $domain, Anfragen: $count/$RATE_LIMIT_MAX_REQUESTS, Protokoll: $proto_display" if [[ "$count" -gt "$RATE_LIMIT_MAX_REQUESTS" ]]; then if is_whitelisted "$client"; then - log "INFO" "Client $client ist auf der Whitelist - keine Sperre (${count}x $domain)" + log "INFO" "Client $client ist auf der Whitelist - keine Sperre (${count}x $domain via $proto_display)" continue fi - ban_client "$client" "$domain" "$count" + ban_client "$client" "$domain" "$count" "rate-limit" "$RATE_LIMIT_WINDOW" "$proto_display" fi done <<< "$violations" } +# ─── Subdomain-Flood-Erkennung ────────────────────────────────────────────── +# Erkennt Random-Subdomain-Attacken: Bots die massenhaft zufällige Subdomains +# einer Domain abfragen (z.B. abc123.microsoft.com, xyz456.microsoft.com, ...) +# Zählt eindeutige Subdomains pro Basisdomain und Client im Zeitfenster +analyze_subdomain_flood() { + local api_response="$1" + + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" != "true" ]]; then + return + fi + + local now_epoch + now_epoch=$(date '+%s') + local window="${SUBDOMAIN_FLOOD_WINDOW:-60}" + local window_start=$((now_epoch - window)) + local max_unique="${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50}" + + log "DEBUG" "Subdomain-Flood-Prüfung: max ${max_unique} eindeutige Subdomains pro Basisdomain in ${window}s" + + # jq-Analyse: Gruppiere nach Client + Basisdomain, zähle eindeutige Subdomains + local violations="" + violations=$(echo "$api_response" | jq -r --argjson window_start "$window_start" --argjson max_unique "$max_unique" ' + # Basisdomain extrahieren (eTLD+1) + # Behandelt gängige Multi-Part-TLDs wie .co.uk, .com.au, .co.jp etc. + def base_domain: + split(".") | + if length <= 2 then join(".") + elif ((.[-2:] | join(".")) | test("^(co|com|net|org|gov|edu|ac|gv|ne|or|go)\\.[a-z]{2,3}$")) then + if length >= 3 then .[-3:] | join(".") else join(".") end + else + .[-2:] | join(".") + end; + + # ISO 8601 Timestamp zu Unix-Epoch konvertieren + def to_epoch: + sub("\\.[0-9]+(?=[+-Z])"; "") | + if endswith("Z") then + fromdateiso8601 + elif test("[+-][0-9]{2}:[0-9]{2}$") then + (.[:-6]) as $base | + (.[-6:-5]) as $sign | + (.[-5:-3] | tonumber) as $h | + (.[-2:] | tonumber) as $m | + ($base + "Z" | fromdateiso8601) + + (if $sign == "+" then -1 else 1 end * ($h * 3600 + $m * 60)) + else + fromdateiso8601 + end; + + .data // [] | + [.[] | + select(.time != null) | + select((.time | to_epoch) >= $window_start) | + ((.question.name // .question.host // "unknown") | rtrimstr(".")) as $domain | + ($domain | base_domain) as $base | + { + client: (.client // .client_info.ip // "unknown"), + domain: $domain, + base_domain: $base, + proto: (.client_proto // "") + } + ] | + # Nur Einträge mit echten Subdomains (domain != base_domain) + [.[] | select(.domain != .base_domain)] | + group_by(.client + "|" + .base_domain) | + map({ + client: .[0].client, + base_domain: .[0].base_domain, + unique_subdomains: ([.[].domain] | unique | length), + total_queries: length, + example_domains: ([.[].domain] | unique | .[0:3] | join(", ")), + protocols: ([.[].proto | if . == "" then "dns" else . end] | unique | join(",")) + }) | + .[] | + select(.unique_subdomains > $max_unique) | + "\(.client)|\(.base_domain)|\(.unique_subdomains)|\(.total_queries)|\(.example_domains)|\(.protocols)" + ') || { + log "ERROR" "jq Subdomain-Flood-Analyse fehlgeschlagen" + return + } + + if [[ -z "$violations" ]]; then + log "DEBUG" "Keine Subdomain-Flood-Verstöße erkannt" + return + fi + + # Gefundene Verstöße verarbeiten + while IFS='|' read -r client base_domain unique_count total_count examples protocols; do + [[ -z "$client" || -z "$base_domain" || -z "$unique_count" ]] && continue + + # Protokoll-Namen formatieren + local proto_display="" + if [[ -n "$protocols" ]]; then + local -a proto_parts=() + IFS=',' read -ra raw_protos <<< "$protocols" + for p in "${raw_protos[@]}"; do + proto_parts+=("$(format_protocol "$p")") + done + proto_display=$(IFS=','; echo "${proto_parts[*]}") + else + proto_display="DNS" + fi + + log "WARN" "Subdomain-Flood erkannt: $client → ${unique_count} eindeutige Subdomains von $base_domain (${total_count} Anfragen via $proto_display, z.B. $examples)" + + if is_whitelisted "$client"; then + log "INFO" "Client $client ist auf der Whitelist - keine Sperre (Subdomain-Flood: ${unique_count}x $base_domain via $proto_display)" + continue + fi + + # Prüfen ob bereits gesperrt + local state_file="${STATE_DIR}/${client//[:\/]/_}.ban" + if [[ -f "$state_file" ]]; then + log "DEBUG" "Client $client ist bereits gesperrt (Subdomain-Flood übersprungen)" + continue + fi + + ban_client "$client" "*.${base_domain}" "$unique_count" "subdomain-flood" "$window" "$proto_display" + done <<< "$violations" +} + # ─── Status anzeigen ───────────────────────────────────────────────────────── show_status() { echo "═══════════════════════════════════════════════════════════════" @@ -667,26 +893,53 @@ show_status() { echo "" fi + # Subdomain-Flood-Schutz Info + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" == "true" ]]; then + echo " 🌐 Subdomain-Flood-Schutz: AKTIV" + echo " Max eindeutige Subdomains: ${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50} pro Basisdomain" + echo " Zeitfenster: ${SUBDOMAIN_FLOOD_WINDOW:-60}s" + echo "" + fi + # Aktive Sperren local ban_count=0 if [[ -d "$STATE_DIR" ]]; then for state_file in "${STATE_DIR}"/*.ban; do [[ -f "$state_file" ]] || continue ban_count=$((ban_count + 1)) - local s_ip s_domain s_level s_perm s_dur s_until - s_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) - s_domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2) - s_level=$(grep '^OFFENSE_LEVEL=' "$state_file" | cut -d= -f2) - s_perm=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2) - s_dur=$(grep '^BAN_DURATION=' "$state_file" | cut -d= -f2) - s_until=$(grep '^BAN_UNTIL=' "$state_file" | cut -d= -f2) + local s_ip s_domain s_level s_perm s_dur s_until s_reason s_count s_proto + s_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) + s_domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2 || true) + s_level=$(grep '^OFFENSE_LEVEL=' "$state_file" | cut -d= -f2 || true) + s_perm=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2 || true) + s_dur=$(grep '^BAN_DURATION=' "$state_file" | cut -d= -f2 || true) + s_until=$(grep '^BAN_UNTIL=' "$state_file" | cut -d= -f2 || true) + s_reason=$(grep '^REASON=' "$state_file" | cut -d= -f2 || true) + s_count=$(grep '^COUNT=' "$state_file" | cut -d= -f2 || true) + s_proto=$(grep '^PROTOCOL=' "$state_file" | cut -d= -f2 || true) + s_reason="${s_reason:-rate-limit}" + s_proto="${s_proto:-?}" + + local reason_tag="" + [[ "$s_reason" == "subdomain-flood" ]] && reason_tag=" (Subdomain-Flood)" + + local count_info="" + if [[ -n "$s_count" && "$s_count" != "-" ]]; then + if [[ "$s_reason" == "subdomain-flood" ]]; then + count_info=", ${s_count} Subdomains" + else + count_info=", ${s_count} Anfragen" + fi + fi + + local proto_tag=" via ${s_proto}" if [[ "$s_perm" == "true" ]]; then - echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT, Stufe ${s_level:-?}]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT, Stufe ${s_level:-?}${count_info}${proto_tag}]${reason_tag}" elif [[ -n "$s_level" && "$s_level" -gt 0 ]]; then - echo " 🚫 Gesperrt: $s_ip → $s_domain [Stufe ${s_level}, $(format_duration "${s_dur:-$BAN_DURATION}"), bis $s_until]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [Stufe ${s_level}, $(format_duration "${s_dur:-$BAN_DURATION}"), bis $s_until${count_info}${proto_tag}]${reason_tag}" else - echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until${count_info}${proto_tag}]${reason_tag}" fi done fi @@ -705,9 +958,9 @@ show_status() { for offense_file in "${STATE_DIR}"/*.offenses; do [[ -f "$offense_file" ]] || continue local o_ip o_level o_last - o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2) - o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2) - o_last=$(grep '^LAST_OFFENSE=' "$offense_file" | cut -d= -f2) + o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true) + o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true) + o_last=$(grep '^LAST_OFFENSE=' "$offense_file" | cut -d= -f2 || true) offense_count=$((offense_count + 1)) local next_dur next_dur=$(calculate_ban_duration "$((o_level + 1))") @@ -781,7 +1034,7 @@ flush_all_bans() { for state_file in "${STATE_DIR}"/*.ban; do [[ -f "$state_file" ]] || continue local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) + client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) unban_client "$client_ip" "manual-flush" done @@ -798,7 +1051,7 @@ flush_all_offenses() { for offense_file in "${STATE_DIR}"/*.offenses; do [[ -f "$offense_file" ]] || continue local o_ip - o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2) + o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true) log "INFO" "Offense-Zähler zurückgesetzt: $o_ip" rm -f "$offense_file" count=$((count + 1)) @@ -855,6 +1108,16 @@ main_loop() { else log "INFO" " Progressive Sperren: deaktiviert" fi + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" == "true" ]]; then + log "INFO" " Subdomain-Flood-Schutz: AKTIV (max ${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50} Subdomains/${SUBDOMAIN_FLOOD_WINDOW:-60}s)" + else + log "INFO" " Subdomain-Flood-Schutz: deaktiviert" + fi + if [[ "${ABUSEIPDB_ENABLED:-false}" == "true" ]]; then + log "INFO" " AbuseIPDB Reporting: AKTIV (Kategorien: ${ABUSEIPDB_CATEGORIES:-4})" + else + log "INFO" " AbuseIPDB Reporting: deaktiviert" + fi log "INFO" "═══════════════════════════════════════════════════════════" # Service-Start-Benachrichtigung senden @@ -873,6 +1136,7 @@ main_loop() { local api_response if api_response=$(query_adguard_log); then analyze_queries "$api_response" + analyze_subdomain_flood "$api_response" fi sleep "$CHECK_INTERVAL" @@ -987,22 +1251,30 @@ case "${1:-start}" in cat << USAGE AdGuard Shield v${VERSION} -Nutzung: $0 {start|stop|status|history|flush|unban|reset-offenses|test|dry-run|blocklist-status|blocklist-sync|blocklist-flush} +Service-Steuerung (empfohlen): + sudo systemctl start adguard-shield + sudo systemctl stop adguard-shield + sudo systemctl restart adguard-shield + sudo systemctl status adguard-shield -Befehle: - start Startet den Monitor (inkl. Blocklist-Worker) - stop Stoppt den Monitor +Nutzung: $0 {status|history|flush|unban|reset-offenses|test|dry-run|blocklist-status|blocklist-sync|blocklist-flush} + +Verwaltungsbefehle: status Zeigt aktive Sperren, Regeln und Wiederholungstäter history [N] Zeigt die letzten N Ban-Einträge (Standard: 50) flush Hebt alle Sperren auf unban IP Entsperrt eine bestimmte IP-Adresse reset-offenses [IP] Setzt Offense-Zähler zurück (alle oder eine bestimmte IP) test Testet die Verbindung zur AdGuard Home API - dry-run Startet im Testmodus (keine echten Sperren) + dry-run Startet im Testmodus (keine echten Sperren, Vordergrund!) blocklist-status Zeigt Status der externen Blocklisten blocklist-sync Einmalige Synchronisation der externen Blocklisten blocklist-flush Entfernt alle Sperren der externen Blocklisten +Interne Befehle (nicht direkt verwenden — nur über systemd): + start Startet den Monitor im Vordergrund + stop Stoppt den Monitor + Konfiguration: $CONFIG_FILE Log-Datei: $LOG_FILE Ban-History: $BAN_HISTORY_FILE diff --git a/doc/architektur.md b/doc/architektur.md index 35ff0ca..03c33ca 100644 --- a/doc/architektur.md +++ b/doc/architektur.md @@ -29,6 +29,8 @@ ## Ablauf einer Sperre +### Rate-Limit-Sperre + 1. Client `192.168.1.50` fragt `microsoft.com` 45x in 60 Sekunden an 2. Monitor fragt die AdGuard Home API alle 10 Sekunden ab (`/control/querylog`) 3. Die Anfragen werden pro Client+Domain-Kombination gezählt @@ -42,6 +44,18 @@ 11. Log-Eintrag + optionale Webhook-Benachrichtigung 12. Nach Ablauf der (progressiven) Sperrdauer: automatische Entsperrung + History-Eintrag +### Subdomain-Flood-Sperre (Random Subdomain Attack) + +1. Client `10.0.0.99` fragt `abc123.microsoft.com`, `xyz456.microsoft.com`, ... ab +2. Monitor extrahiert die **Basisdomain** (`microsoft.com`) aus jeder Anfrage +3. Pro Client wird gezählt, wie viele **eindeutige Subdomains** einer Basisdomain im Zeitfenster abgefragt wurden +4. Monitor erkennt: 63 eindeutige Subdomains > 50 (Schwellwert überschritten) +5. Prüfung: Ist der Client auf der Whitelist? → Nein +6. Sperre wird ausgeführt mit Domain `*.microsoft.com` und Grund `subdomain-flood` +7. Progressive Sperren greifen auch hier — Wiederholungstäter werden stufenweise länger gesperrt + +> **Hinweis:** Die Subdomain-Flood-Erkennung hat ein eigenes Zeitfenster (`SUBDOMAIN_FLOOD_WINDOW`) und einen eigenen Schwellwert (`SUBDOMAIN_FLOOD_MAX_UNIQUE`), unabhängig von den Rate-Limit-Einstellungen. + ## iptables Strategie Das Tool erstellt eine eigene Chain `ADGUARD_SHIELD`: @@ -87,6 +101,7 @@ BAN_UNTIL=2026-03-03 15:30:00 BAN_DURATION=3600 OFFENSE_LEVEL=1 IS_PERMANENT=false +REASON=rate-limit ``` Zusätzlich wird für jede IP ein Offense-Tracker gespeichert: @@ -185,7 +200,9 @@ ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | Grund | Bedeutung | |-------|----------| | `rate-limit` | Automatische Sperre wegen Limit-Überschreitung | +| `subdomain-flood` | Sperre wegen zu vieler eindeutiger Subdomains einer Basisdomain | | `dry-run` | Im Dry-Run erkannt (nicht wirklich gesperrt) | +| `dry-run (subdomain-flood)` | Subdomain-Flood im Dry-Run erkannt | | `expired` | Automatisch entsperrt nach Ablauf der Sperrdauer | | `expired-cron` | Entsperrt durch den Cron-Job (`unban-expired.sh`) | | `manual` | Manuell entsperrt per `unban`-Befehl | diff --git a/doc/befehle.md b/doc/befehle.md index 72a736b..9756577 100644 --- a/doc/befehle.md +++ b/doc/befehle.md @@ -34,6 +34,18 @@ Beim Update passiert automatisch: 5. Der systemd Service wird per `daemon-reload` neu geladen 6. Der Service wird automatisch neu gestartet (falls er lief) +### API-Verbindungstest nach Installation + +Nach der Installation wird automatisch ein **zweistufiger Verbindungstest** durchgeführt: + +1. **Base-URL Erreichbarkeit** — Prüft ob die konfigurierte `ADGUARD_URL` erreichbar ist (DNS, TCP, HTTP). Bei Fehlern werden spezifische Hinweise angezeigt (z.B. DNS-Fehler, Timeout, SSL-Problem). +2. **API-Authentifizierung** — Testet ob die hinterlegten Zugangsdaten (`ADGUARD_USER` / `ADGUARD_PASS`) korrekt sind, indem der API-Endpunkt `/control/querylog` abgefragt wird. + +> **Hinweis:** Dieser Test kann auch jederzeit manuell ausgeführt werden: +> ```bash +> sudo /opt/adguard-shield/adguard-shield.sh test +> ``` + ### Voraussetzungen Folgende Pakete werden bei der Installation automatisch installiert (via `apt`): @@ -43,15 +55,31 @@ Folgende Pakete werden bei der Installation automatisch installiert (via `apt`): - `gawk` — Textverarbeitung - `systemd` — Service-Management -## Monitor (Hauptscript) +## systemd Service + +AdGuard Shield wird als systemd Service betrieben. **Zum Starten, Stoppen und Neustarten immer `systemctl` verwenden:** ```bash -# Starten -sudo /opt/adguard-shield/adguard-shield.sh start +# Start / Stop / Restart +sudo systemctl start adguard-shield +sudo systemctl stop adguard-shield +sudo systemctl restart adguard-shield -# Stoppen -sudo /opt/adguard-shield/adguard-shield.sh stop +# Status +sudo systemctl status adguard-shield +# Autostart aktivieren / deaktivieren +sudo systemctl enable adguard-shield +sudo systemctl disable adguard-shield +``` + +> **Hinweis:** Der Service wird bei der Installation automatisch für den Autostart beim Booten aktiviert. Nach einem Update wird der Service automatisch neu gestartet — ein manueller Neustart ist nicht nötig. + +## Monitor — Verwaltungsbefehle + +Die folgenden Befehle dienen der **Verwaltung und Diagnose** und können jederzeit ausgeführt werden, auch während der Service läuft: + +```bash # Status + aktive Sperren anzeigen sudo /opt/adguard-shield/adguard-shield.sh status @@ -70,7 +98,7 @@ sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100 # API-Verbindung testen sudo /opt/adguard-shield/adguard-shield.sh test -# Dry-Run (nur loggen, nichts sperren) +# Dry-Run (nur loggen, nichts sperren — läuft im Vordergrund!) sudo /opt/adguard-shield/adguard-shield.sh dry-run # Offense-Zähler für alle IPs zurücksetzen (Progressive Sperren) @@ -89,6 +117,8 @@ sudo /opt/adguard-shield/adguard-shield.sh blocklist-sync sudo /opt/adguard-shield/adguard-shield.sh blocklist-flush ``` +> **⚠ Wichtig:** Zum Starten und Stoppen des Monitors **nicht** `adguard-shield.sh start` bzw. `stop` verwenden! Diese Befehle starten den Prozess im **Vordergrund** — die Ausgabe wird live angezeigt und `Strg+C` beendet den gesamten Prozess. Stattdessen immer `sudo systemctl start/stop/restart adguard-shield` nutzen. + ## iptables Helper Für die manuelle Verwaltung der Firewall-Regeln: @@ -138,26 +168,6 @@ sudo /opt/adguard-shield/external-blocklist-worker.sh status sudo /opt/adguard-shield/external-blocklist-worker.sh flush ``` -## systemd Service - -Der Service wird bei der Installation automatisch für den **Autostart beim Booten** aktiviert. - -```bash -# Start / Stop / Restart -sudo systemctl start adguard-shield -sudo systemctl stop adguard-shield -sudo systemctl restart adguard-shield - -# Status -sudo systemctl status adguard-shield - -# Autostart aktivieren / deaktivieren -sudo systemctl enable adguard-shield -sudo systemctl disable adguard-shield -``` - -> **Hinweis:** Nach einem Update wird der Service automatisch neu gestartet. Ein manueller Neustart ist nicht nötig. - ## Logs ```bash @@ -186,6 +196,135 @@ sudo crontab -e */5 * * * * /opt/adguard-shield/unban-expired.sh ``` +## DNS-Abfragen zum Testen (von einem Linux-Client) + +> **⚠ WARNUNG — Bitte unbedingt lesen:** +> +> Die folgenden Befehle dienen **ausschließlich zu Testzwecken**, um die eigene AdGuard-Shield-Installation zu überprüfen. Sie simulieren erhöhtes DNS-Aufkommen und können dazu genutzt werden, die Erkennungs- und Sperrmechanismen zu validieren. +> +> **DNS-Flooding ist illegal!** Das massenhafte Senden von DNS-Anfragen an fremde Server oder Infrastruktur ohne ausdrückliche Genehmigung kann als **Denial-of-Service-Angriff (DoS)** gewertet werden und ist in den meisten Ländern **strafbar**. Die Konsequenzen reichen von Abmahnungen über Strafanzeigen bis hin zu empfindlichen Geld- und Freiheitsstrafen. +> +> **Diese Befehle dürfen nur gegen den eigenen DNS-Server in einer kontrollierten Testumgebung eingesetzt werden.** Die Nutzung gegen fremde Server ist ausdrücklich untersagt. Jede Verantwortung liegt beim Anwender. + +### Voraussetzungen + +Die folgenden Tools müssen auf dem **Linux-Client** installiert sein (nicht auf dem Server): + +```bash +# Für DNS-Abfragen (dig) +sudo apt install dnsutils + +# Für DoH-Abfragen (curl) +sudo apt install curl + +# Für DoT-Abfragen (knotc) +sudo apt install knot-dnsutils + +# Für DoQ-Abfragen +# https://github.com/natesales/q — Releases herunterladen oder via Go installieren: +go install github.com/natesales/q@latest +``` + +> **Hinweis:** In den folgenden Befehlen muss die IP-Adresse `203.0.113.50` durch die **eigene DNS-Server-IP** und `microsoft.com` durch die gewünschte **Ziel-Domain** ersetzt werden. + +--- + +### Klassisches DNS (Port 53/UDP) + +#### Direkte Abfragen (gleiche Domain, viele Anfragen) + +200 parallele DNS-Anfragen für dieselbe Domain — jede mit einem zufälligen DNS-Cookie, um Caching zu umgehen: + +```bash +for i in {1..200}; do \ + dig @203.0.113.50 microsoft.com +short +cookie=$(openssl rand -hex 8) > /dev/null & \ +done; wait +``` + +#### Zufällige Subdomain-Abfragen (NXDOMAIN-Flood) + +200 parallele Anfragen mit zufällig generierten Subdomains — simuliert typisches Verhalten von DNS-basierten Angriffen: + +```bash +for i in {1..200}; do \ + dig @203.0.113.50 $(openssl rand -hex 6).microsoft.com +short > /dev/null & \ +done; wait +``` + +--- + +### DNS over HTTPS (DoH) + +DoH-Anfragen werden über HTTPS (Port 443) gesendet. Die meisten AdGuard-Home-Instanzen bieten DoH unter `/dns-query` an: + +#### Direkte Abfragen via DoH + +```bash +for i in {1..200}; do \ + curl -s -H "accept: application/dns-json" \ + "https://203.0.113.50/dns-query?name=microsoft.com&type=A" > /dev/null & \ +done; wait +``` + +#### Zufällige Subdomain-Abfragen via DoH + +```bash +for i in {1..200}; do \ + curl -s -H "accept: application/dns-json" \ + "https://203.0.113.50/dns-query?name=$(openssl rand -hex 6).microsoft.com&type=A" > /dev/null & \ +done; wait +``` + +> **Hinweis:** Falls der Server ein selbstsigniertes Zertifikat verwendet, muss `-k` (unsicherer Modus) an `curl` angehängt werden. + +--- + +### DNS over TLS (DoT) + +DoT verwendet TLS über Port 853. Mit `kdig` (aus dem Paket `knot-dnsutils`): + +#### Direkte Abfragen via DoT + +```bash +for i in {1..200}; do \ + kdig @203.0.113.50 microsoft.com +tls +short > /dev/null & \ +done; wait +``` + +#### Zufällige Subdomain-Abfragen via DoT + +```bash +for i in {1..200}; do \ + kdig @203.0.113.50 $(openssl rand -hex 6).microsoft.com +tls +short > /dev/null & \ +done; wait +``` + +--- + +### DNS over QUIC (DoQ) + +DoQ verwendet das QUIC-Protokoll über Port 853/UDP. Mit dem Tool [`q`](https://github.com/natesales/q): + +#### Direkte Abfragen via DoQ + +```bash +for i in {1..200}; do \ + q microsoft.com A @quic://203.0.113.50 --short > /dev/null & \ +done; wait +``` + +#### Zufällige Subdomain-Abfragen via DoQ + +```bash +for i in {1..200}; do \ + q $(openssl rand -hex 6).microsoft.com A @quic://203.0.113.50 --short > /dev/null & \ +done; wait +``` + +--- + +> **⚠ Abschließender Hinweis:** Alle oben genannten Befehle sind **ausschließlich für das Testen der eigenen Infrastruktur** gedacht. Wer diese Befehle gegen fremde DNS-Server oder Dienste einsetzt, macht sich unter Umständen **strafbar**. Sei verantwortungsvoll — teste nur, was dir gehört. + ## Hilfe Alle verfügbaren Befehle und Optionen des Installers anzeigen: diff --git a/doc/konfiguration.md b/doc/konfiguration.md index 9c33da6..9540d8b 100644 --- a/doc/konfiguration.md +++ b/doc/konfiguration.md @@ -37,6 +37,39 @@ Dadurch muss der Benutzer bei Updates die Konfiguration nicht manuell austausche | `CHECK_INTERVAL` | `10` | Wie oft die Logs geprüft werden (Sekunden) | | `API_QUERY_LIMIT` | `500` | Anzahl API-Einträge pro Abfrage (max 5000) | +### Subdomain-Flood-Erkennung (Random Subdomain Attack) + +Erkennt Bot-Angriffe, bei denen massenhaft zufällige Subdomains einer Domain abgefragt werden (z.B. `abc123.microsoft.com`, `xyz456.microsoft.com`, ...). Dabei wird pro Client gezählt, wie viele **eindeutige** Subdomains einer Basisdomain (z.B. `microsoft.com`) im Zeitfenster aufgerufen werden. + +| Parameter | Standard | Beschreibung | +|-----------|----------|--------------| +| `SUBDOMAIN_FLOOD_ENABLED` | `true` | Subdomain-Flood-Erkennung aktivieren | +| `SUBDOMAIN_FLOOD_MAX_UNIQUE` | `50` | Max. eindeutige Subdomains pro Basisdomain/Client im Zeitfenster | +| `SUBDOMAIN_FLOOD_WINDOW` | `60` | Zeitfenster in Sekunden | + +#### Wie funktioniert die Erkennung? + +1. Aus jeder DNS-Anfrage wird die **Basisdomain** extrahiert (z.B. `microsoft.com` aus `abc.microsoft.com`) +2. Pro Client wird gezählt, wie viele **verschiedene** Subdomains einer Basisdomain im Zeitfenster abgefragt wurden +3. Überschreitet die Anzahl eindeutiger Subdomains den Schwellwert, wird der Client gesperrt + +#### Beispiel + +Ein Bot fragt innerhalb von 60 Sekunden folgende Domains ab: + +``` +hbidcw.microsoft.com +ftdzewf.microsoft.com +xk9z3a.microsoft.com +... (50+ verschiedene Subdomains) +``` + +→ Alle Anfragen haben die gleiche Basisdomain `microsoft.com`. Sobald mehr als 50 eindeutige Subdomains erkannt werden, wird der Client gesperrt. + +> **Hinweis:** Nur echte Subdomains werden gezählt. Anfragen direkt an `microsoft.com` (ohne Subdomain) lösen diese Erkennung nicht aus. Multi-Part-TLDs wie `.co.uk`, `.com.au` etc. werden korrekt behandelt. + +> **Tipp:** Der Schwellwert `SUBDOMAIN_FLOOD_MAX_UNIQUE` sollte hoch genug sein, um legitime Clients nicht zu stören (z.B. CDNs nutzen oft viele Subdomains). Ein Wert von 50–100 ist in den meisten Fällen sinnvoll. + ### Sperr-Einstellungen | Parameter | Standard | Beschreibung | @@ -109,7 +142,44 @@ Ermöglicht das Einbinden externer IP-Blocklisten (z.B. gehostete Textdateien mi | `EXTERNAL_BLOCKLIST_BAN_DURATION` | `0` | Sperrdauer in Sekunden (0 = permanent bis IP aus Liste entfernt) | | `EXTERNAL_BLOCKLIST_AUTO_UNBAN` | `true` | IPs automatisch entsperren wenn aus Liste entfernt | | `EXTERNAL_BLOCKLIST_CACHE_DIR` | `/var/lib/adguard-shield/external-blocklist` | Lokaler Cache für heruntergeladene Listen | +### AbuseIPDB Reporting +Meldet permanent gesperrte IPs automatisch an [AbuseIPDB](https://www.abuseipdb.com/). Damit wird die IP in einer öffentlichen Datenbank als missbräuchlich markiert und andere Administratoren können davon profitieren. + +> **Wichtig:** Es werden **nur permanent gesperrte IPs** gemeldet — also erst wenn die maximale Progressive-Ban-Stufe erreicht ist. Einzelne temporäre Sperren lösen keinen AbuseIPDB-Report aus. + +| Parameter | Standard | Beschreibung | +|-----------|----------|---------------| +| `ABUSEIPDB_ENABLED` | `false` | AbuseIPDB-Reporting aktivieren | +| `ABUSEIPDB_API_KEY` | *(leer)* | API-Key von [abuseipdb.com/account/api](https://www.abuseipdb.com/account/api) | +| `ABUSEIPDB_CATEGORIES` | `4` | Report-Kategorien (4 = DDoS Attack). Siehe [Kategorien](https://www.abuseipdb.com/categories) | + +#### AbuseIPDB einrichten + +1. Erstelle einen kostenlosen Account auf [abuseipdb.com](https://www.abuseipdb.com/) +2. Erstelle einen API-Key unter [Account → API](https://www.abuseipdb.com/account/api) +3. Aktiviere das Reporting in der Konfiguration: + +```bash +ABUSEIPDB_ENABLED=true +ABUSEIPDB_API_KEY="dein-api-key-hier" +ABUSEIPDB_CATEGORIES="4" +``` + +4. Service neustarten: + +```bash +sudo systemctl restart adguard-shield +``` + +#### Was wird gemeldet? + +Der Report an AbuseIPDB enthält (auf Englisch): + +- **Bei Rate-Limit:** `DNS flooding on our DNS server: 100x microsoft.com. Permanently banned by AdGuard Shield.` +- **Bei Subdomain-Flood:** `DNS flooding on our DNS server: 85x *.microsoft.com (random subdomain attack). Permanently banned by AdGuard Shield.` + +Die Kategorie `4` (DDoS Attack) wird standardmäßig verwendet. Weitere Kategorien können kommagetrennt angegeben werden (z.B. `"4,15"`). #### Externe Blocklist einrichten 1. Erstelle eine Textdatei auf einem Webserver mit einer IP pro Zeile: @@ -151,6 +221,29 @@ Bei einem Rate-Limit-Verstoß werden **alle** DNS-Protokoll-Ports für den Clien | 853 | TCP | DNS-over-TLS (`tls://dns1.techniverse.net:853`) | | 853 | UDP | DNS-over-QUIC (`quic://dns1.techniverse.net:853`) | +## Protokoll-Erkennung + +AdGuard Shield erkennt **automatisch**, welches DNS-Protokoll ein Client verwendet. Diese Information wird aus dem Feld `client_proto` der AdGuard Home Query Log API extrahiert und an folgenden Stellen angezeigt: + +- **Log-Datei**: Jede Anfrage wird mit dem verwendeten Protokoll geloggt +- **Ban-History**: Die Protokoll-Spalte zeigt, über welches Protokoll die Anfragen kamen +- **Status-Anzeige**: Aktive Sperren zeigen das verwendete Protokoll an +- **Benachrichtigungen**: Push-Nachrichten enthalten das Protokoll + +### Unterstützte Protokolle + +| API-Wert | Anzeige | Beschreibung | +|----------|---------|-------------| +| *(leer)* | `DNS` | Klassisches DNS über UDP/TCP (Port 53) | +| `doh` | `DoH` | DNS-over-HTTPS (Port 443) | +| `dot` | `DoT` | DNS-over-TLS (Port 853) | +| `doq` | `DoQ` | DNS-over-QUIC (Port 853/UDP) | +| `dnscrypt` | `DNSCrypt` | DNSCrypt-Protokoll | + +Verwendet ein Client mehrere Protokolle gleichzeitig (z.B. DoH und DNS), werden alle erkannten Protokolle kommagetrennt angezeigt (z.B. `DNS,DoH`). + +> **Wichtig:** Alle Protokolle werden gleichermaßen überwacht und gegen das Rate-Limit geprüft. Ein DoH-Flood wird genauso erkannt und gesperrt wie ein klassischer DNS-Flood – die Erkennung basiert auf den AdGuard Home Logdaten, nicht auf Netzwerk-Traffic. + ## Whitelist richtig pflegen Die Whitelist sollte mindestens enthalten: diff --git a/doc/tipps-und-troubleshooting.md b/doc/tipps-und-troubleshooting.md index bad4668..204dc3b 100644 --- a/doc/tipps-und-troubleshooting.md +++ b/doc/tipps-und-troubleshooting.md @@ -25,10 +25,84 @@ sudo /opt/adguard-shield/adguard-shield.sh test - Falsche Zugangsdaten (`ADGUARD_USER` / `ADGUARD_PASS`) - AdGuard Home läuft nicht - Firewall blockiert lokale Verbindung +- DNS-Auflösung des Hostnames fehlgeschlagen +- SSL/TLS-Zertifikatfehler (bei HTTPS) -**Lösung:** URL manuell testen: +#### Schritt-für-Schritt Diagnose + +**1. Base-URL Erreichbarkeit prüfen (ohne Auth):** ```bash -curl -s -u admin:passwort http://127.0.0.1:3000/control/querylog?limit=1 +# Vollständige Diagnose mit HTTP-Headern und Verbindungsdetails +curl -ikv https://dns1.domain.com 2>&1 + +# Nur HTTP-Statuscode prüfen (schnell) +curl -s -o /dev/null -w "%{http_code}\n" -k https://dns1.domain.com +``` + +> `-i` zeigt HTTP-Response-Header, `-k` ignoriert SSL-Fehler, `-v` zeigt Verbindungsdetails (DNS, TLS-Handshake, etc.) + +**2. DNS-Auflösung testen:** +```bash +# Hostname auflösen +dig +short dns1.domain.com + +# Oder mit nslookup +nslookup dns1.domain.com +``` + +**3. Port-Erreichbarkeit testen:** +```bash +# TCP-Verbindung zum Port prüfen (z.B. Port 3000) +nc -zv 127.0.0.1 3000 + +# Oder mit curl +curl -v telnet://127.0.0.1:3000 +``` + +**4. API-Endpunkt mit Authentifizierung testen:** +```bash +# Query-Log abfragen (mit Auth + Response-Header) +curl -i -u admin:passwort https://dns1.domain.com/control/querylog?limit=1 + +# Nur HTTP-Status zurückgeben +curl -s -o /dev/null -w "%{http_code}\n" -u admin:passwort https://dns1.domain.com/control/querylog?limit=1 +``` + +**5. AdGuard Home Status-API prüfen:** +```bash +# Allgemeinen Status abfragen (benötigt keine Auth) +curl -ik https://dns1.domain.com/control/status +``` + +#### Typische Fehlercodes + +| HTTP-Code | Bedeutung | Lösung | +|-----------|-----------|--------| +| `000` | Keine Verbindung | Host nicht erreichbar, DNS-Fehler oder Firewall | +| `200` | Erfolg | Alles in Ordnung ✅ | +| `301/302` | Weiterleitung | URL prüfen — evtl. fehlt `https://` oder Port | +| `401` | Nicht autorisiert | `ADGUARD_USER` / `ADGUARD_PASS` prüfen | +| `403` | Zugriff verweigert | Zugangsdaten oder IP-Beschränkung in AdGuard Home | +| `404` | Nicht gefunden | URL falsch oder AdGuard Home Version zu alt | +| `502/503` | Service nicht verfügbar | AdGuard Home läuft nicht oder wird gerade neu gestartet | + +#### curl Exit-Codes + +| Exit-Code | Bedeutung | +|-----------|-----------| +| `6` | DNS-Auflösung fehlgeschlagen — Hostname prüfen | +| `7` | Verbindung abgelehnt — Läuft AdGuard Home? Port korrekt? | +| `28` | Timeout — Host nicht erreichbar oder Firewall blockiert | +| `35` | SSL/TLS-Handshake fehlgeschlagen | +| `51` | SSL-Zertifikat: Hostname stimmt nicht überein | +| `60` | SSL-Zertifikat: nicht vertrauenswürdig (selbstsigniert?) | + +> **Tipp:** Bei selbstsignierten Zertifikaten `-k` an curl anhängen, um SSL-Fehler zu ignorieren. AdGuard Shield verwendet intern automatisch `-k` bei der API-Kommunikation. + +**Lösung:** URL und Zugangsdaten in der Konfiguration anpassen: +```bash +sudo nano /opt/adguard-shield/adguard-shield.conf +sudo systemctl restart adguard-shield ``` ### iptables-Fehler: "Permission denied" @@ -87,6 +161,30 @@ Das ist normal — iptables-Regeln sind flüchtig. Der **Service** erstellt die - `RATE_LIMIT_WINDOW` vergrößern (z.B. 120 Sekunden) - Windows-Clients fragen manche Domains von Natur aus sehr oft an — Whitelist nutzen +### Subdomain-Flood-Erkennung sperrt legitime Clients + +Manche Dienste (z.B. CDNs, Cloud-Dienste, Microsoft 365) nutzen von Natur aus viele verschiedene Subdomains. Falls ein legitimer Client fälschlicherweise durch die Subdomain-Flood-Erkennung gesperrt wird: + +1. Client sofort entsperren: + ```bash + sudo /opt/adguard-shield/adguard-shield.sh unban + ``` +2. Schwellwert erhöhen — z.B. von 50 auf 100 oder 150: + ```bash + SUBDOMAIN_FLOOD_MAX_UNIQUE=100 + ``` +3. Zeitfenster vergrößern — z.B. auf 120 Sekunden: + ```bash + SUBDOMAIN_FLOOD_WINDOW=120 + ``` +4. Oder die IP zur Whitelist hinzufügen +5. Im Zweifelsfall die Erkennung temporär deaktivieren: + ```bash + SUBDOMAIN_FLOOD_ENABLED=false + ``` + +> **Tipp:** Im Dry-Run-Modus (`sudo /opt/adguard-shield/adguard-shield.sh dry-run`) kann man beobachten, welche Clients die Subdomain-Flood-Erkennung auslösen würden, ohne sie wirklich zu sperren. + ### Monitor startet nicht (PID-File) ```bash diff --git a/external-blocklist-worker.sh b/external-blocklist-worker.sh index 3e2c793..91e61d6 100644 --- a/external-blocklist-worker.sh +++ b/external-blocklist-worker.sh @@ -53,15 +53,15 @@ log_ban_history() { if [[ ! -f "$BAN_HISTORY_FILE" ]]; then echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE" - echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND" >> "$BAN_HISTORY_FILE" - echo "#───────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" + echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" + echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" fi local duration="permanent" [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] && duration="${EXTERNAL_BLOCKLIST_BAN_DURATION}s" - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "-" "-" "$duration" "$reason" \ + printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ + "$timestamp" "$action" "$client_ip" "-" "-" "$duration" "-" "$reason" \ >> "$BAN_HISTORY_FILE" } diff --git a/install.sh b/install.sh index ebb86c7..856f242 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ # Lizenz: MIT ############################################################################### -VERSION="0.3.1" +VERSION="0.4.0" set -euo pipefail @@ -40,6 +40,11 @@ print_header() { echo -e "${GREEN} Version: ${VERSION}${NC}" echo -e "${BLUE} Autor: Patrick Asmus${NC}" echo -e "${BLUE} E-Mail: support@techniverse.net${NC}" + echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}" + echo -e "${BLUE} Web: https://www.patrick-asmus.de${NC}" + echo -e "${BLUE} Blog: https://www.cleveradmin.de${NC}" + echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}" + echo -e "${BLUE} Repo: https://git.techniverse.net/scriptos/adguard-shield${NC}" echo "" echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" echo "" @@ -77,8 +82,15 @@ print_help() { echo -e " ${CYAN}sudo bash install.sh uninstall${NC} # Deinstallation" echo -e " ${CYAN}sudo bash install.sh status${NC} # Status prüfen" echo "" + echo -e "${BOLD}Service-Befehle:${NC}" + echo -e " ${CYAN}sudo systemctl start adguard-shield${NC} # Service starten" + echo -e " ${CYAN}sudo systemctl stop adguard-shield${NC} # Service stoppen" + echo -e " ${CYAN}sudo systemctl restart adguard-shield${NC} # Service neustarten" + echo -e " ${CYAN}sudo systemctl status adguard-shield${NC} # Service-Status" + echo -e " ${CYAN}sudo journalctl -u adguard-shield -f${NC} # Logs live verfolgen" + echo "" echo -e "${BOLD}Monitor-Befehle (nach Installation):${NC}" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh start${NC} # Monitor starten" + echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh start${NC} # Monitor im Vordergrund starten" echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh stop${NC} # Monitor stoppen" echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh status${NC} # Status & aktive Sperren" echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh history${NC} # Ban-History anzeigen" @@ -97,13 +109,6 @@ print_help() { echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh save${NC} # Regeln speichern" echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh restore${NC} # Regeln wiederherstellen" echo "" - echo -e "${BOLD}Service-Befehle:${NC}" - echo -e " ${CYAN}sudo systemctl start adguard-shield${NC} # Service starten" - echo -e " ${CYAN}sudo systemctl stop adguard-shield${NC} # Service stoppen" - echo -e " ${CYAN}sudo systemctl restart adguard-shield${NC} # Service neustarten" - echo -e " ${CYAN}sudo systemctl status adguard-shield${NC} # Service-Status" - echo -e " ${CYAN}sudo journalctl -u adguard-shield -f${NC} # Logs live verfolgen" - echo "" echo -e "${BOLD}Voraussetzungen:${NC}" echo " - Linux Server (Debian/Ubuntu empfohlen)" echo " - Root-Zugriff (sudo)" @@ -301,7 +306,7 @@ migrate_config() { echo -n "$current_comment_block" >> "$existing_conf" echo "$line" >> "$existing_conf" echo -e " ➕ Neuer Parameter hinzugefügt: ${GREEN}$key${NC}" - ((new_keys_added++)) + new_keys_added=$((new_keys_added + 1)) fi fi @@ -391,17 +396,60 @@ test_connection() { source "$INSTALL_DIR/adguard-shield.conf" - local response - response=$(curl -s -o /dev/null -w "%{http_code}" \ - -u "${ADGUARD_USER}:${ADGUARD_PASS}" \ - --connect-timeout 5 \ - "${ADGUARD_URL}/control/querylog?limit=1" 2>/dev/null) + # ── Schritt 1: Base-URL Erreichbarkeit prüfen ──────────────────────── + echo -e " ${CYAN}1)${NC} Prüfe Erreichbarkeit von ${BOLD}${ADGUARD_URL}${NC} ..." - if [[ "$response" == "200" ]]; then - echo -e " ✅ Verbindung erfolgreich! (HTTP $response)" + local base_http_code + local base_curl_exit + base_http_code=$(curl -s -o /dev/null -w "%{http_code}" \ + --connect-timeout 5 --max-time 10 \ + -k "${ADGUARD_URL}" 2>/dev/null) || base_curl_exit=$? + base_curl_exit=${base_curl_exit:-0} + + if [[ "$base_curl_exit" -ne 0 ]]; then + # curl konnte keine Verbindung aufbauen + echo -e " ❌ Base-URL nicht erreichbar! (curl Exit-Code: $base_curl_exit)" + case "$base_curl_exit" in + 6) echo -e " ${YELLOW}→ DNS-Auflösung fehlgeschlagen. Hostname prüfen!${NC}" ;; + 7) echo -e " ${YELLOW}→ Verbindung abgelehnt. Läuft AdGuard Home? Port korrekt?${NC}" ;; + 28) echo -e " ${YELLOW}→ Timeout. Host nicht erreichbar oder Firewall blockiert.${NC}" ;; + 35|51|60) echo -e " ${YELLOW}→ SSL/TLS-Fehler. Zertifikat oder HTTPS-Konfiguration prüfen.${NC}" ;; + *) echo -e " ${YELLOW}→ Unbekannter Fehler. Manuell testen: curl -v ${ADGUARD_URL}${NC}" ;; + esac + echo "" + echo -e " ${YELLOW}Troubleshooting:${NC}" + echo -e " curl -ikv ${ADGUARD_URL}" + echo "" + return 1 + fi + + if [[ "$base_http_code" == "000" ]]; then + echo -e " ❌ Base-URL nicht erreichbar (keine HTTP-Antwort)" + echo -e " ${YELLOW}→ Manuell testen: curl -ikv ${ADGUARD_URL}${NC}" + echo "" + return 1 + fi + + echo -e " ✅ Base-URL erreichbar (HTTP $base_http_code)" + + # ── Schritt 2: API-Endpunkt mit Authentifizierung testen ───────────── + echo -e " ${CYAN}2)${NC} Teste API-Authentifizierung ..." + + local api_response + api_response=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "${ADGUARD_USER}:${ADGUARD_PASS}" \ + --connect-timeout 5 --max-time 10 \ + -k "${ADGUARD_URL}/control/querylog?limit=1" 2>/dev/null) + + if [[ "$api_response" == "200" ]]; then + echo -e " ✅ API-Authentifizierung erfolgreich! (HTTP $api_response)" + elif [[ "$api_response" == "401" || "$api_response" == "403" ]]; then + echo -e " ❌ Authentifizierung fehlgeschlagen (HTTP $api_response)" + echo -e " ${YELLOW}→ Benutzername oder Passwort falsch!${NC}" + echo -e " ${YELLOW}→ Prüfe ADGUARD_USER und ADGUARD_PASS in: $INSTALL_DIR/adguard-shield.conf${NC}" else - echo -e " ❌ Verbindung fehlgeschlagen (HTTP $response)" - echo -e " ${YELLOW}Bitte prüfe URL und Zugangsdaten in: $INSTALL_DIR/adguard-shield.conf${NC}" + echo -e " ❌ API-Verbindung fehlgeschlagen (HTTP $api_response)" + echo -e " ${YELLOW}→ Bitte prüfe URL und Zugangsdaten in: $INSTALL_DIR/adguard-shield.conf${NC}" fi echo "" } diff --git a/unban-expired.sh b/unban-expired.sh index c84cd40..a45184a 100644 --- a/unban-expired.sh +++ b/unban-expired.sh @@ -29,17 +29,20 @@ log_ban_history() { local domain="${3:-}" local count="${4:-}" local reason="${5:-}" + local protocol="${6:-}" local timestamp timestamp="$(date '+%Y-%m-%d %H:%M:%S')" if [[ ! -f "$BAN_HISTORY_FILE" ]]; then echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE" - echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND" >> "$BAN_HISTORY_FILE" - echo "#─────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" + echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" + echo "#────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" fi - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "-" "${reason:-expired}" \ + [[ -z "$protocol" ]] && protocol="-" + + printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ + "$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "-" "$protocol" "${reason:-expired}" \ >> "$BAN_HISTORY_FILE" } @@ -52,6 +55,7 @@ for state_file in "${STATE_DIR}"/*.ban; do client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2) is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2) + protocol=$(grep '^PROTOCOL=' "$state_file" | cut -d= -f2) # Permanente Sperren nicht automatisch aufheben if [[ "$is_permanent" == "true" || "$ban_until_epoch" == "0" ]]; then @@ -69,7 +73,7 @@ for state_file in "${STATE_DIR}"/*.ban; do fi # Ban-History Eintrag - log_ban_history "UNBAN" "$client_ip" "$domain" "-" "expired-cron" + log_ban_history "UNBAN" "$client_ip" "$domain" "-" "expired-cron" "${protocol:-}" rm -f "$state_file" unban_count=$((unban_count + 1))