From 0d1f7db43b1ee46970d8febb8df2b476a5d35cc3 Mon Sep 17 00:00:00 2001 From: Patrick Asmus Date: Thu, 30 Apr 2026 15:39:26 +0200 Subject: [PATCH 1/2] feat: Migration auf sqlite3 --- README.md | 4 +- adguard-shield.conf | 2 +- adguard-shield.sh | 298 ++++++-------- db.sh | 641 ++++++++++++++++++++++++++++++ docs/architektur.md | 87 ++-- docs/befehle.md | 8 +- docs/konfiguration.md | 4 +- docs/report.md | 2 +- docs/tipps-und-troubleshooting.md | 4 +- docs/update.md | 9 +- external-blocklist-worker.sh | 110 ++--- external-whitelist-worker.sh | 95 ++--- geoip-worker.sh | 138 +++---- install.sh | 100 ++++- offense-cleanup-worker.sh | 62 +-- report-generator.sh | 239 ++--------- unban-expired.sh | 59 +-- uninstall.sh | 1 + 18 files changed, 1118 insertions(+), 745 deletions(-) create mode 100644 db.sh diff --git a/README.md b/README.md index 1a7cb9f..bcf2a6d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Das schützt klassische DNS-Anfragen genauso wie DoH, DoT und DoQ, ohne deine be - Linux-Server mit AdGuard Home - Root-Zugriff per `sudo` - Erreichbare AdGuard Home Web-API, standardmäßig `http://127.0.0.1:3000` -- `curl`, `jq`, `iptables`, `gawk` und `systemd` +- `curl`, `jq`, `iptables`, `gawk`, `sqlite3` und `systemd` Die benötigten Pakete werden vom Installer automatisch installiert. @@ -81,7 +81,7 @@ sudo systemctl status adguard-shield ```bash sudo bash install.sh # Interaktives Menü sudo bash install.sh install # Direkt installieren -sudo bash install.sh update # Update inkl. Konfigurations-Migration +sudo bash install.sh update # Update inkl. Konfig- & Datenbank-Migration sudo bash install.sh status # Installationsstatus prüfen sudo bash /opt/adguard-shield/uninstall.sh ``` diff --git a/adguard-shield.conf b/adguard-shield.conf index ffc2032..5266996 100644 --- a/adguard-shield.conf +++ b/adguard-shield.conf @@ -100,7 +100,7 @@ GEOIP_LICENSE_KEY="" # MaxMind GeoLite2 Key (optional, für Auto- GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang) # --- Erweiterte Einstellungen --- -STATE_DIR="/var/lib/adguard-shield" +STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db PID_FILE="/var/run/adguard-shield.pid" API_QUERY_LIMIT=500 # API-Einträge pro Abfrage (max 5000) DRY_RUN=false # true = nur loggen, nicht sperren diff --git a/adguard-shield.sh b/adguard-shield.sh index 72cbc6b..557c737 100644 --- a/adguard-shield.sh +++ b/adguard-shield.sh @@ -8,7 +8,7 @@ # Lizenz: MIT ############################################################################### -VERSION="v0.9.0" +VERSION="v1.0.0" set -euo pipefail @@ -26,10 +26,14 @@ fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# ─── Datenbank-Bibliothek laden ─────────────────────────────────────────────── +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" + # ─── Abhängigkeiten prüfen ──────────────────────────────────────────────────── check_dependencies() { local missing=() - for cmd in curl jq iptables ip6tables date; do + for cmd in curl jq iptables ip6tables date sqlite3; do if ! command -v "$cmd" &>/dev/null; then missing+=("$cmd") fi @@ -79,15 +83,6 @@ log_ban_history() { 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 | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" - echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" - fi if [[ -z "$duration" && "$action" == "BAN" ]]; then duration="${BAN_DURATION}s" @@ -95,65 +90,20 @@ log_ban_history() { [[ -z "$duration" ]] && duration="-" [[ -z "$protocol" ]] && protocol="-" - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "$duration" "$protocol" "${reason:-rate-limit}" \ - >> "$BAN_HISTORY_FILE" + db_history_add "$action" "$client_ip" "${domain:--}" "${count:--}" "${reason:-rate-limit}" "$duration" "$protocol" } # ─── Progressive Ban (Recidive) ───────────────────────────────────────────── -# Liest die aktuelle Offense-Stufe einer IP aus der Offense-Datei get_offense_level() { local client_ip="$1" - local offense_file="${STATE_DIR}/${client_ip//[:\/]/_}.offenses" - - if [[ ! -f "$offense_file" ]]; then - echo "0" - return - fi - - local level last_offense now reset_after - 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}" - - # Prüfen ob der Zähler abgelaufen ist (Reset nach Zeitraum ohne Vergehen) - if [[ -n "$last_offense" && $((now - last_offense)) -gt "$reset_after" ]]; then - log "INFO" "Progressive Ban: Offense-Zähler für $client_ip zurückgesetzt (>${reset_after}s ohne Vergehen)" - rm -f "$offense_file" - echo "0" - return - fi - - echo "${level:-0}" + local level + level=$(db_offense_get_level "$client_ip" "${PROGRESSIVE_BAN_RESET_AFTER:-86400}") + echo "$level" } -# Erhöht die Offense-Stufe einer IP und gibt die neue Stufe zurück increment_offense_level() { local client_ip="$1" - local offense_file="${STATE_DIR}/${client_ip//[:\/]/_}.offenses" - local current_level - current_level=$(get_offense_level "$client_ip") - local new_level=$((current_level + 1)) - local now - now=$(date '+%s') - local now_readable - now_readable=$(date '+%Y-%m-%d %H:%M:%S') - - # Erstes Vergehen merken (bevor Datei überschrieben wird) - local first_offense - 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 -CLIENT_IP=$client_ip -OFFENSE_LEVEL=$new_level -LAST_OFFENSE_EPOCH=$now -LAST_OFFENSE=$now_readable -FIRST_OFFENSE=$first_offense -EOF - - echo "$new_level" + db_offense_increment "$client_ip" } # Berechnet die Sperrdauer basierend auf der Offense-Stufe @@ -207,11 +157,9 @@ format_duration() { fi } -# Setzt den Offense-Zähler einer IP zurück reset_offense_level() { local client_ip="$1" - local offense_file="${STATE_DIR}/${client_ip//[:\/]/_}.offenses" - rm -f "$offense_file" + db_offense_delete "$client_ip" } # ─── Protokoll-Erkennung ───────────────────────────────────────────────────── @@ -307,12 +255,23 @@ report_to_abuseipdb() { fi } -# ─── Verzeichnisse erstellen ────────────────────────────────────────────────── +# ─── Verzeichnisse und Datenbank erstellen ─────────────────────────────────── init_directories() { mkdir -p "$STATE_DIR" mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$PID_FILE")" - mkdir -p "$(dirname "$BAN_HISTORY_FILE")" + + db_init + + # Migration von Flat-Files (einmalig beim ersten Start nach Update) + if [[ ! -f "$_DB_MIGRATION_MARKER" ]]; then + local migrated + migrated=$(db_migrate_from_files) + if [[ "${migrated:-0}" -gt 0 ]]; then + log "INFO" "SQLite-Migration abgeschlossen: $migrated Eintraege migriert" + log "INFO" "Backup der alten Dateien: ${STATE_DIR}/.backup_pre_sqlite/" + fi + fi } # ─── PID-Management ────────────────────────────────────────────────────────── @@ -354,15 +313,14 @@ is_whitelisted() { local ip="$1" IFS=',' read -ra wl_entries <<< "$WHITELIST" for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) # trim + entry=$(echo "$entry" | xargs) if [[ "$ip" == "$entry" ]]; then return 0 fi done - # Externe Whitelist prüfen (aufgelöste IPs aus dem Whitelist-Worker) - local ext_wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt" - if [[ -f "$ext_wl_file" ]] && grep -qxF "$ip" "$ext_wl_file" 2>/dev/null; then + # Externe Whitelist prüfen (SQLite) + if db_whitelist_contains "$ip"; then return 0 fi @@ -431,8 +389,7 @@ ban_client() { local protocol="${6:-DNS}" # Prüfen ob bereits gesperrt - local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" - if [[ -f "$state_file" ]]; then + if db_ban_exists "$client_ip"; then log "DEBUG" "Client $client_ip ist bereits gesperrt" return 0 fi @@ -496,20 +453,10 @@ ban_client() { iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - # State speichern - cat > "$state_file" << EOF -CLIENT_IP=$client_ip -DOMAIN=$domain -COUNT=$count -BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S') -BAN_UNTIL_EPOCH=$ban_until -BAN_UNTIL=$ban_until_display -BAN_DURATION=${effective_duration} -OFFENSE_LEVEL=$offense_level -IS_PERMANENT=$is_permanent -REASON=$reason -PROTOCOL=$protocol -EOF + # State in Datenbank speichern + local perm_int=0 + [[ "$is_permanent" == "true" ]] && perm_int=1 + db_ban_insert "$client_ip" "$domain" "$count" "$(date '+%Y-%m-%d %H:%M:%S')" "$ban_until" "$effective_duration" "$offense_level" "$perm_int" "$reason" "$protocol" "monitor" # Ban-History Eintrag local history_duration="${duration_display}" @@ -531,16 +478,17 @@ EOF unban_client() { local client_ip="$1" local reason="${2:-expired}" - local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" - # Domain und Protokoll aus State lesen bevor wir löschen + # Domain und Protokoll aus DB lesen bevor wir loeschen + local ban_data + ban_data=$(db_ban_get "$client_ip") local domain="-" local protocol="-" - if [[ -f "$state_file" ]]; then - domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2 || true) - protocol=$(grep '^PROTOCOL=' "$state_file" | cut -d= -f2 || true) + if [[ -n "$ban_data" ]]; then + IFS='|' read -r _ b_domain _ _ _ _ _ _ _ b_protocol _ _ _ <<< "$ban_data" + domain="${b_domain:--}" + protocol="${b_protocol:--}" fi - [[ -z "$protocol" ]] && protocol="-" log "INFO" "ENTSPERRE Client: $client_ip ($reason)" @@ -550,9 +498,8 @@ unban_client() { iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - rm -f "$state_file" + db_ban_delete "$client_ip" - # Ban-History Eintrag log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason" "-" "$protocol" if [[ "$NOTIFY_ENABLED" == "true" ]]; then @@ -562,29 +509,14 @@ unban_client() { # ─── Abgelaufene Sperren aufheben ─────────────────────────────────────────── check_expired_bans() { - local now - now=$(date '+%s') + local expired_ips + expired_ips=$(db_ban_get_expired) + [[ -z "$expired_ips" ]] && return - for state_file in "${STATE_DIR}"/*.ban; do - [[ -f "$state_file" ]] || continue - - local ban_until_epoch - 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 || true) - local is_permanent - 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 - log "DEBUG" "Client $client_ip ist permanent gesperrt – überspringe" - continue - fi - - if [[ -n "$ban_until_epoch" && "$now" -ge "$ban_until_epoch" ]]; then - unban_client "$client_ip" "expired" - fi - done + while IFS= read -r client_ip; do + [[ -z "$client_ip" ]] && continue + unban_client "$client_ip" "expired" + done <<< "$expired_ips" } # ─── Benachrichtigungen ───────────────────────────────────────────────────── @@ -995,8 +927,7 @@ analyze_subdomain_flood() { fi # Prüfen ob bereits gesperrt - local state_file="${STATE_DIR}/${client//[:\/]/_}.ban" - if [[ -f "$state_file" ]]; then + if db_ban_exists "$client"; then log "DEBUG" "Client $client ist bereits gesperrt (Subdomain-Flood übersprungen)" continue fi @@ -1054,22 +985,15 @@ show_status() { echo "" fi - # Aktive Sperren + # Aktive Sperren aus Datenbank local ban_count=0 - if [[ -d "$STATE_DIR" ]]; then - for state_file in "${STATE_DIR}"/*.ban; do - [[ -f "$state_file" ]] || continue + local all_bans + all_bans=$(db_ban_get_all) + + if [[ -n "$all_bans" ]]; then + while IFS='|' read -r s_ip s_domain s_count s_ban_time s_ban_until_epoch s_dur s_level s_perm_int s_reason s_proto s_source s_geoip_country s_geoip_mode; do + [[ -z "$s_ip" ]] && continue ban_count=$((ban_count + 1)) - 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:-?}" @@ -1078,7 +1002,7 @@ show_status() { [[ "$s_reason" == "dns-flood-watchlist" ]] && reason_tag=" (DNS-Flood-Watchlist)" local count_info="" - if [[ -n "$s_count" && "$s_count" != "-" ]]; then + if [[ -n "$s_count" && "$s_count" != "0" && "$s_count" != "-" ]]; then if [[ "$s_reason" == "subdomain-flood" ]]; then count_info=", ${s_count} Subdomains" else @@ -1088,16 +1012,23 @@ show_status() { local proto_tag=" via ${s_proto}" - if [[ "$s_perm" == "true" && "$s_reason" == "dns-flood-watchlist" ]]; then + local s_until_display + if [[ "$s_ban_until_epoch" == "0" || "$s_perm_int" == "1" ]]; then + s_until_display="PERMANENT" + else + s_until_display=$(date -d "@$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -r "$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?") + fi + + if [[ "$s_perm_int" == "1" && "$s_reason" == "dns-flood-watchlist" ]]; then echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT${count_info}${proto_tag}]${reason_tag}" - elif [[ "$s_perm" == "true" ]]; then + elif [[ "$s_perm_int" == "1" ]]; then 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${count_info}${proto_tag}]${reason_tag}" + echo " 🚫 Gesperrt: $s_ip → $s_domain [Stufe ${s_level}, $(format_duration "${s_dur:-$BAN_DURATION}"), bis $s_until_display${count_info}${proto_tag}]${reason_tag}" else - echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until${count_info}${proto_tag}]${reason_tag}" + echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until_display${count_info}${proto_tag}]${reason_tag}" fi - done + done <<< "$all_bans" fi echo "" @@ -1108,24 +1039,25 @@ show_status() { fi # Offense-Informationen anzeigen (Wiederholungstäter) - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -d "$STATE_DIR" ]]; then + if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then + local offense_data + offense_data=$(db_offense_get_all) local offense_count=0 local offense_output="" - 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 || 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))") - if [[ "$next_dur" -eq 0 ]]; then - offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: PERMANENT\n" - else - offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: $(format_duration "$next_dur")\n" - fi - done + + if [[ -n "$offense_data" ]]; then + while IFS='|' read -r o_ip o_level o_last_epoch o_last o_first; do + [[ -z "$o_ip" ]] && continue + offense_count=$((offense_count + 1)) + local next_dur + next_dur=$(calculate_ban_duration "$((o_level + 1))") + if [[ "$next_dur" -eq 0 ]]; then + offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: PERMANENT\n" + else + offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: $(format_duration "$next_dur")\n" + fi + done <<< "$offense_data" + fi if [[ $offense_count -gt 0 ]]; then echo "" @@ -1156,29 +1088,34 @@ show_history() { echo "═══════════════════════════════════════════════════════════════" echo "" - if [[ ! -f "$BAN_HISTORY_FILE" ]]; then + local total + total=$(db_history_count) + + if [[ "${total:-0}" -eq 0 ]]; then echo " Noch keine History vorhanden." - echo " Datei: $BAN_HISTORY_FILE" echo "" return fi - # Header zeigen - head -3 "$BAN_HISTORY_FILE" | sed 's/^/ /' + echo " # Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" + echo " #──────────────────────────────────────────────────────────────────────────────────────────────────" echo "" - # Letzte N Einträge (ohne Header-Zeilen) - grep -v '^#' "$BAN_HISTORY_FILE" | tail -n "$lines" | sed 's/^/ /' + local recent + recent=$(db_history_get_recent "$lines") + if [[ -n "$recent" ]]; then + while IFS='|' read -r ts action ip domain count duration protocol reason; do + printf " %-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ + "$ts" "$action" "$ip" "$domain" "$count" "$duration" "$protocol" "$reason" + done <<< "$recent" + fi echo "" - local total - total=$(grep -vc '^#' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0") - local bans - bans=$(grep -c '| BAN ' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0") - local unbans - unbans=$(grep -c '| UNBAN ' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0") + local bans unbans + bans=$(db_history_count_by_action "BAN") + unbans=$(db_history_count_by_action "UNBAN") echo " Gesamt: $total Einträge ($bans Sperren, $unbans Entsperrungen)" - echo " Datei: $BAN_HISTORY_FILE" + echo " Datenbank: $DB_FILE" echo "" echo "═══════════════════════════════════════════════════════════════" } @@ -1187,12 +1124,14 @@ show_history() { flush_all_bans() { log "INFO" "Alle Sperren werden aufgehoben..." - 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 || true) - unban_client "$client_ip" "manual-flush" - done + local all_ips + all_ips=$(db_query "SELECT client_ip FROM active_bans;") + if [[ -n "$all_ips" ]]; then + while IFS= read -r client_ip; do + [[ -z "$client_ip" ]] && continue + unban_client "$client_ip" "manual-flush" + done <<< "$all_ips" + fi # Chain leeren iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true @@ -1203,15 +1142,8 @@ flush_all_bans() { # ─── Alle Offense-Zähler zurücksetzen ──────────────────────────────────────── flush_all_offenses() { - local count=0 - 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 || true) - log "INFO" "Offense-Zähler zurückgesetzt: $o_ip" - rm -f "$offense_file" - count=$((count + 1)) - done + local count + count=$(db_offense_delete_all) log "INFO" "$count Offense-Zähler zurückgesetzt" echo "$count Offense-Zähler zurückgesetzt" } @@ -1630,7 +1562,7 @@ Interne Befehle (nicht direkt verwenden — nur über systemd): Konfiguration: $CONFIG_FILE Log-Datei: $LOG_FILE -Ban-History: $BAN_HISTORY_FILE +Datenbank: $DB_FILE State: $STATE_DIR USAGE diff --git a/db.sh b/db.sh new file mode 100644 index 0000000..6583ea4 --- /dev/null +++ b/db.sh @@ -0,0 +1,641 @@ +#!/bin/bash +############################################################################### +# AdGuard Shield - SQLite Datenbank-Bibliothek +# Zentrale Datenbankfunktionen fuer alle Scripte. +# Wird per "source db.sh" eingebunden. +# +# Autor: Patrick Asmus +# E-Mail: support@techniverse.net +# Lizenz: MIT +############################################################################### + +DB_FILE="${STATE_DIR}/adguard-shield.db" +DB_SCHEMA_VERSION=1 +_DB_MIGRATION_MARKER="${STATE_DIR}/.migration_v1_complete" + +# ─── SQL-Wert escapen (Single Quotes verdoppeln) ──────────────────────────── +_db_escape() { + echo "${1//\'/\'\'}" +} + +# ─── SQL ausfuehren (INSERT/UPDATE/DELETE) ─────────────────────────────────── +db_exec() { + sqlite3 "$DB_FILE" < 0 AND is_permanent = 0 AND ban_until_epoch <= $now;" +} + +db_ban_get_expired_by_source() { + local source=$(_db_escape "$1") + local now + now=$(date '+%s') + db_query "SELECT client_ip FROM active_bans WHERE source='$source' AND ban_until_epoch > 0 AND is_permanent = 0 AND ban_until_epoch <= $now;" +} + +db_ban_get_by_source() { + local source=$(_db_escape "$1") + db_query "SELECT client_ip FROM active_bans WHERE source='$source';" +} + +db_ban_count() { + db_query "SELECT COUNT(*) FROM active_bans;" +} + +db_ban_count_by_source() { + local source=$(_db_escape "$1") + db_query "SELECT COUNT(*) FROM active_bans WHERE source='$source';" +} + +db_ban_get_all() { + db_query "SELECT client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode FROM active_bans ORDER BY created_at DESC;" +} + +db_ban_get_by_reason() { + local reason=$(_db_escape "$1") + db_query "SELECT client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode FROM active_bans WHERE reason='$reason';" +} + +# ─── Offense-Funktionen ───────────────────────────────────────────────────── + +db_offense_get_level() { + local ip=$(_db_escape "$1") + local reset_after="${2:-86400}" + local now + now=$(date '+%s') + + local row + row=$(db_query "SELECT offense_level, last_offense_epoch FROM offense_tracking WHERE client_ip='$ip' LIMIT 1;") + + if [[ -z "$row" ]]; then + echo "0" + return + fi + + local level last_epoch + IFS='|' read -r level last_epoch <<< "$row" + + if [[ -n "$last_epoch" && $((now - last_epoch)) -gt "$reset_after" ]]; then + db_exec "DELETE FROM offense_tracking WHERE client_ip='$ip';" + echo "0" + return + fi + + echo "${level:-0}" +} + +db_offense_increment() { + local ip=$(_db_escape "$1") + local current_level + current_level=$(db_offense_get_level "$1" "${PROGRESSIVE_BAN_RESET_AFTER:-86400}") + local new_level=$((current_level + 1)) + local now + now=$(date '+%s') + local now_readable + now_readable=$(date '+%Y-%m-%d %H:%M:%S') + + local first_offense + first_offense=$(db_query "SELECT first_offense FROM offense_tracking WHERE client_ip='$ip' LIMIT 1;") + [[ -z "$first_offense" ]] && first_offense="$now_readable" + + db_exec "INSERT OR REPLACE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense, updated_at) VALUES ('$ip', $new_level, $now, '$now_readable', '$first_offense', '$now_readable');" + + echo "$new_level" +} + +db_offense_delete() { + local ip=$(_db_escape "$1") + db_exec "DELETE FROM offense_tracking WHERE client_ip='$ip';" +} + +db_offense_delete_all() { + local count + count=$(db_query "SELECT COUNT(*) FROM offense_tracking;") + db_exec "DELETE FROM offense_tracking;" + echo "${count:-0}" +} + +db_offense_delete_expired() { + local reset_after="${1:-86400}" + local now + now=$(date '+%s') + local cutoff=$((now - reset_after)) + + local expired + expired=$(db_query "SELECT client_ip, offense_level, last_offense_epoch FROM offense_tracking WHERE last_offense_epoch <= $cutoff;") + local count=0 + if [[ -n "$expired" ]]; then + count=$(echo "$expired" | wc -l) + db_exec "DELETE FROM offense_tracking WHERE last_offense_epoch <= $cutoff;" + fi + echo "$count" +} + +db_offense_get_all() { + db_query "SELECT client_ip, offense_level, last_offense_epoch, last_offense, first_offense FROM offense_tracking ORDER BY last_offense_epoch DESC;" +} + +db_offense_count() { + db_query "SELECT COUNT(*) FROM offense_tracking;" +} + +db_offense_count_expired() { + local reset_after="${1:-86400}" + local now + now=$(date '+%s') + local cutoff=$((now - reset_after)) + db_query "SELECT COUNT(*) FROM offense_tracking WHERE last_offense_epoch <= $cutoff;" +} + +# ─── Ban-History-Funktionen ───────────────────────────────────────────────── + +db_history_add() { + local action=$(_db_escape "$1") + local client_ip=$(_db_escape "$2") + local domain=$(_db_escape "${3:--}") + local count=$(_db_escape "${4:--}") + local reason=$(_db_escape "${5:--}") + local duration=$(_db_escape "${6:--}") + local protocol=$(_db_escape "${7:--}") + local now_epoch + now_epoch=$(date '+%s') + local now_text + now_text=$(date '+%Y-%m-%d %H:%M:%S') + + db_exec "INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES ($now_epoch, '$now_text', '$action', '$client_ip', '$domain', '$count', '$duration', '$protocol', '$reason');" +} + +db_history_cleanup() { + local retention_days="${1:-0}" + [[ "$retention_days" == "0" || -z "$retention_days" ]] && return + + local cutoff_epoch + cutoff_epoch=$(date -d "-${retention_days} days" '+%s' 2>/dev/null) + [[ -z "$cutoff_epoch" ]] && return + + local before after removed + before=$(db_query "SELECT COUNT(*) FROM ban_history;") + db_exec "DELETE FROM ban_history WHERE timestamp_epoch < $cutoff_epoch;" + after=$(db_query "SELECT COUNT(*) FROM ban_history;") + removed=$((before - after)) + echo "$removed" +} + +db_history_get_recent() { + local limit="${1:-50}" + db_query "SELECT timestamp_text, action, client_ip, domain, count, duration, protocol, reason FROM ban_history ORDER BY id DESC LIMIT $limit;" +} + +db_history_count() { + db_query "SELECT COUNT(*) FROM ban_history;" +} + +db_history_count_by_action() { + local action=$(_db_escape "$1") + db_query "SELECT COUNT(*) FROM ban_history WHERE action='$action';" +} + +db_history_stats_for_range() { + local start_epoch="$1" + local end_epoch="$2" + + db_query "SELECT + COALESCE(SUM(CASE WHEN action='BAN' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN action='UNBAN' THEN 1 ELSE 0 END), 0), + COALESCE(COUNT(DISTINCT CASE WHEN action='BAN' THEN client_ip END), 0), + COALESCE(SUM(CASE WHEN action='BAN' AND (duration LIKE '%PERMANENT%' OR duration LIKE '%permanent%') THEN 1 ELSE 0 END), 0) + FROM ban_history + WHERE timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch;" +} + +db_history_report_stats() { + local start_epoch="$1" + local end_epoch="$2" + local busiest_start="$3" + + db_query "SELECT + COALESCE(SUM(CASE WHEN action='BAN' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN action='UNBAN' THEN 1 ELSE 0 END), 0), + COALESCE(COUNT(DISTINCT CASE WHEN action='BAN' THEN client_ip END), 0), + COALESCE(SUM(CASE WHEN action='BAN' AND (duration LIKE '%PERMANENT%' OR duration LIKE '%permanent%') THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%rate%limit%' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%subdomain%flood%' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%external%blocklist%' THEN 1 ELSE 0 END), 0) + FROM ban_history + WHERE timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch;" +} + +db_history_busiest_day() { + local start_epoch="$1" + local end_epoch="$2" + + db_query "SELECT substr(timestamp_text, 1, 10), COUNT(*) + FROM ban_history + WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch + GROUP BY substr(timestamp_text, 1, 10) + ORDER BY COUNT(*) DESC + LIMIT 1;" +} + +db_history_top_ips() { + local start_epoch="$1" + local end_epoch="$2" + local limit="${3:-10}" + + db_query "SELECT COUNT(*), client_ip + FROM ban_history + WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch + GROUP BY client_ip + ORDER BY COUNT(*) DESC + LIMIT $limit;" +} + +db_history_top_domains() { + local start_epoch="$1" + local end_epoch="$2" + local limit="${3:-10}" + + db_query "SELECT COUNT(*), domain + FROM ban_history + WHERE action='BAN' AND domain != '-' AND domain != '' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch + GROUP BY domain + ORDER BY COUNT(*) DESC + LIMIT $limit;" +} + +db_history_protocol_stats() { + local start_epoch="$1" + local end_epoch="$2" + + db_query "SELECT COUNT(*), COALESCE(NULLIF(protocol, ''), 'unbekannt') + FROM ban_history + WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch + GROUP BY COALESCE(NULLIF(protocol, ''), 'unbekannt') + ORDER BY COUNT(*) DESC;" +} + +db_history_recent_bans() { + local start_epoch="$1" + local end_epoch="$2" + local limit="${3:-10}" + + db_query "SELECT timestamp_text, action, client_ip, domain, count, duration, protocol, reason + FROM ban_history + WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch + ORDER BY id DESC + LIMIT $limit;" +} + +# ─── Whitelist-Funktionen ─────────────────────────────────────────────────── + +db_whitelist_contains() { + local ip=$(_db_escape "$1") + local result + result=$(db_query "SELECT 1 FROM whitelist_cache WHERE ip_address='$ip' LIMIT 1;") + [[ -n "$result" ]] +} + +db_whitelist_sync() { + local source=$(_db_escape "${1:-external}") + local tmp_sql="" + tmp_sql="BEGIN TRANSACTION; DELETE FROM whitelist_cache;" + while IFS= read -r ip; do + [[ -z "$ip" ]] && continue + local safe_ip=$(_db_escape "$ip") + tmp_sql+=" INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES ('$safe_ip', '$source');" + done + tmp_sql+=" COMMIT;" + db_exec "$tmp_sql" +} + +db_whitelist_count() { + db_query "SELECT COUNT(*) FROM whitelist_cache;" +} + +db_whitelist_get_all() { + db_query "SELECT ip_address FROM whitelist_cache;" +} + +db_whitelist_clear() { + db_exec "DELETE FROM whitelist_cache;" +} + +# ─── Migration von Flat-Files ─────────────────────────────────────────────── + +db_migrate_from_files() { + # Bereits migriert? + if [[ -f "$_DB_MIGRATION_MARKER" ]]; then + return 0 + fi + + local migrated=0 + local backup_dir="${STATE_DIR}/.backup_pre_sqlite" + + # ─── .ban-Dateien migrieren ────────────────────────────────────────── + local ban_sql="BEGIN TRANSACTION;" + local ban_count=0 + + for state_file in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban; do + [[ -f "$state_file" ]] || continue + local basename_f + basename_f=$(basename "$state_file") + + local s_ip s_domain s_count s_ban_time s_ban_until_epoch s_ban_duration + local s_offense_level s_is_permanent s_reason s_protocol s_source + local s_geoip_country s_geoip_mode + + s_ip=$(grep '^CLIENT_IP=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + [[ -z "$s_ip" ]] && continue + + s_domain=$(grep '^DOMAIN=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_count=$(grep '^COUNT=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_ban_time=$(grep '^BAN_TIME=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_ban_duration=$(grep '^BAN_DURATION=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_offense_level=$(grep '^OFFENSE_LEVEL=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_is_permanent=$(grep '^IS_PERMANENT=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_reason=$(grep '^REASON=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_protocol=$(grep '^PROTOCOL=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_geoip_country=$(grep '^GEOIP_COUNTRY=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + s_geoip_mode=$(grep '^GEOIP_MODE=' "$state_file" 2>/dev/null | cut -d= -f2 || true) + + # Source bestimmen + if [[ "$basename_f" == ext_* ]]; then + s_source="external-blocklist" + elif [[ "$s_reason" == "geoip" ]]; then + s_source="geoip" + else + s_source="monitor" + fi + + # Boolean zu Integer + local perm_int=0 + [[ "$s_is_permanent" == "true" ]] && perm_int=1 + + s_ip=$(_db_escape "$s_ip") + s_domain=$(_db_escape "${s_domain:--}") + s_ban_time=$(_db_escape "${s_ban_time:-}") + s_reason=$(_db_escape "${s_reason:-rate-limit}") + s_protocol=$(_db_escape "${s_protocol:-DNS}") + s_geoip_country=$(_db_escape "${s_geoip_country:-}") + s_geoip_mode=$(_db_escape "${s_geoip_mode:-}") + + ban_sql+=" INSERT OR IGNORE INTO active_bans (client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode) VALUES ('$s_ip', '$s_domain', ${s_count:-0}, '$s_ban_time', ${s_ban_until_epoch:-0}, ${s_ban_duration:-0}, ${s_offense_level:-0}, $perm_int, '$s_reason', '$s_protocol', '$s_source', '$s_geoip_country', '$s_geoip_mode');" + ban_count=$((ban_count + 1)) + done + ban_sql+=" COMMIT;" + + if [[ $ban_count -gt 0 ]]; then + db_exec "$ban_sql" + migrated=$((migrated + ban_count)) + fi + + # ─── .offenses-Dateien migrieren ───────────────────────────────────── + local offense_sql="BEGIN TRANSACTION;" + local offense_count=0 + + for offense_file in "${STATE_DIR}"/*.offenses; do + [[ -f "$offense_file" ]] || continue + + local o_ip o_level o_last_epoch o_last o_first + o_ip=$(grep '^CLIENT_IP=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) + [[ -z "$o_ip" ]] && continue + + o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) + o_last_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) + o_last=$(grep '^LAST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) + o_first=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) + + o_ip=$(_db_escape "$o_ip") + o_last=$(_db_escape "${o_last:-}") + o_first=$(_db_escape "${o_first:-}") + + offense_sql+=" INSERT OR IGNORE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense) VALUES ('$o_ip', ${o_level:-0}, ${o_last_epoch:-0}, '$o_last', '$o_first');" + offense_count=$((offense_count + 1)) + done + offense_sql+=" COMMIT;" + + if [[ $offense_count -gt 0 ]]; then + db_exec "$offense_sql" + migrated=$((migrated + offense_count)) + fi + + # ─── Ban-History-Log migrieren ─────────────────────────────────────── + local history_count=0 + if [[ -f "$BAN_HISTORY_FILE" ]]; then + local history_sql + history_sql=$(awk ' + /^#/ || /^[[:space:]]*$/ { next } + { + n = split($0, f, "|") + if (n < 2) next + ts = f[1]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", ts) + if (length(ts) < 19) next + ep = mktime(substr(ts,1,4) " " substr(ts,6,2) " " substr(ts,9,2) " " \ + substr(ts,12,2) " " substr(ts,15,2) " " substr(ts,18,2)) + if (ep < 0) next + for (i = 1; i <= n; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", f[i]) + # Single quotes escapen + gsub(/'\''/, "'\'''\''", f[1]) + gsub(/'\''/, "'\'''\''", f[2]) + gsub(/'\''/, "'\'''\''", f[3]) + gsub(/'\''/, "'\'''\''", f[4]) + gsub(/'\''/, "'\'''\''", f[5]) + gsub(/'\''/, "'\'''\''", f[6]) + gsub(/'\''/, "'\'''\''", f[7]) + gsub(/'\''/, "'\'''\''", f[8]) + printf "INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (%d, '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'');\n", \ + ep, f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8] + count++ + } + END { print "-- migrated " count+0 " history entries" } + ' "$BAN_HISTORY_FILE") + + if [[ -n "$history_sql" ]]; then + echo "BEGIN TRANSACTION; $history_sql COMMIT;" | sqlite3 "$DB_FILE" 2>/dev/null + history_count=$(echo "$history_sql" | grep -c '^INSERT' || true) + migrated=$((migrated + history_count)) + fi + fi + + # ─── Whitelist-Cache migrieren ─────────────────────────────────────── + local wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt" + local wl_count=0 + if [[ -f "$wl_file" ]]; then + local wl_sql="BEGIN TRANSACTION;" + while IFS= read -r ip; do + [[ -z "$ip" ]] && continue + local safe_ip=$(_db_escape "$ip") + wl_sql+=" INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES ('$safe_ip', 'external');" + wl_count=$((wl_count + 1)) + done < "$wl_file" + wl_sql+=" COMMIT;" + + if [[ $wl_count -gt 0 ]]; then + db_exec "$wl_sql" + migrated=$((migrated + wl_count)) + fi + fi + + # ─── Alte Dateien in Backup verschieben ────────────────────────────── + if [[ $migrated -gt 0 ]]; then + mkdir -p "$backup_dir" + + for f in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban; do + [[ -f "$f" ]] || continue + mv "$f" "$backup_dir/" 2>/dev/null || true + done + + for f in "${STATE_DIR}"/*.offenses; do + [[ -f "$f" ]] || continue + mv "$f" "$backup_dir/" 2>/dev/null || true + done + + if [[ -f "$BAN_HISTORY_FILE" ]]; then + cp "$BAN_HISTORY_FILE" "${backup_dir}/adguard-shield-bans.log.bak" 2>/dev/null || true + fi + + if [[ -f "$wl_file" ]]; then + cp "$wl_file" "${backup_dir}/resolved_ips.txt.bak" 2>/dev/null || true + fi + fi + + # Migrations-Marker setzen + echo "migrated_at=$(date '+%Y-%m-%d %H:%M:%S')" > "$_DB_MIGRATION_MARKER" + echo "bans=$ban_count" >> "$_DB_MIGRATION_MARKER" + echo "offenses=$offense_count" >> "$_DB_MIGRATION_MARKER" + echo "history=$history_count" >> "$_DB_MIGRATION_MARKER" + echo "whitelist=$wl_count" >> "$_DB_MIGRATION_MARKER" + + echo "$migrated" +} diff --git a/docs/architektur.md b/docs/architektur.md index ee68854..b3adbae 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -93,48 +93,36 @@ ADGUARD_SHIELD Chain - Kann komplett geflusht werden ohne andere Regeln zu beeinflussen - Einfaches Debugging per `iptables -L ADGUARD_SHIELD` -## State-Management +## State-Management (SQLite) -Jede aktive Sperre wird als Datei gespeichert: +Alle Laufzeitdaten werden in einer zentralen SQLite-Datenbank gespeichert: ``` -/var/lib/adguard-shield/192.168.1.50.ban +/var/lib/adguard-shield/adguard-shield.db ``` -Inhalt: -``` -CLIENT_IP=192.168.1.50 -DOMAIN=microsoft.com -COUNT=45 -BAN_TIME=2026-03-03 14:30:00 -BAN_UNTIL_EPOCH=1741012200 -BAN_UNTIL=2026-03-03 15:30:00 -BAN_DURATION=3600 -OFFENSE_LEVEL=1 -IS_PERMANENT=false -REASON=rate-limit -``` +Die Datenbank enthält folgende Tabellen: -Zusätzlich wird für jede IP ein Offense-Tracker gespeichert: +| Tabelle | Beschreibung | +|---------|--------------| +| `active_bans` | Aktive Sperren (IP, Domain, Sperrdauer, Offense-Level, Grund, Quelle, GeoIP) | +| `offense_tracking` | Offense-Zähler für progressive Sperren (Level, letztes/erstes Vergehen) | +| `ban_history` | Vollständige Ban-History (alle Sperren und Entsperrungen) | +| `whitelist_cache` | Cache der aufgelösten externen Whitelist-IPs | +| `schema_version` | Datenbank-Schema-Version für zukünftige Migrationen | -``` -/var/lib/adguard-shield/192.168.1.50.offenses -``` +**Vorteile gegenüber Flat-Files:** +- Schnellere Abfragen, besonders bei vielen aktiven Sperren +- Atomare Transaktionen — kein Datenverlust bei Stromausfall +- WAL-Modus für parallelen Lese-/Schreibzugriff +- Indexierte Suche nach IP, Zeitstempel, Quelle und Aktion +- Kompakte Speicherung statt tausender Einzeldateien -Inhalt: -``` -CLIENT_IP=192.168.1.50 -OFFENSE_LEVEL=2 -LAST_OFFENSE_EPOCH=1741008600 -LAST_OFFENSE=2026-03-03 14:30:00 -FIRST_OFFENSE=2026-03-03 12:15:00 -``` +Die zentrale Datenbankbibliothek (`db.sh`) wird von allen Scripts per `source db.sh` eingebunden und stellt typisierte Funktionen für alle Tabellen bereit (z.B. `db_ban_insert`, `db_offense_get_level`, `db_history_add`). -Das ermöglicht: -- Persistenz über Script-Neustarts hinweg -- Statusabfragen jederzeit möglich -- Automatisches Aufräumen per Cron-Job -- Progressive Sperrzeiten über mehrere Ban-Zyklen hinweg +### Migration von Flat-Files + +Beim Update auf die SQLite-Version werden bestehende Flat-File-Daten (`.ban`, `.offenses`, Ban-History-Log, Whitelist-Cache) automatisch in die Datenbank migriert. Die alten Dateien werden als Backup nach `/var/lib/adguard-shield/.backup_pre_sqlite/` verschoben. Die Migration läuft einmalig beim Update und zeigt den Fortschritt im Terminal an. ## Dateistruktur nach Installation @@ -149,6 +137,7 @@ Das ermöglicht: ├── external-whitelist-worker.sh # Externer Whitelist-Worker (DNS-Auflösung) ├── geoip-worker.sh # GeoIP-Länderfilter-Worker ├── offense-cleanup-worker.sh # Aufräumen abgelaufener Offense-Zähler (nice 19, idle I/O) +├── db.sh # SQLite Datenbank-Bibliothek (wird von allen Scripts eingebunden) ├── unban-expired.sh # Cron-basiertes Entsperren └── geoip/ # Auto-Download MaxMind GeoLite2 DB (optional) @@ -158,15 +147,16 @@ Das ermöglicht: └── adguard-shield-watchdog.timer # systemd Timer (alle 5 Min.) /var/lib/adguard-shield/ -├── *.ban # State-Dateien aktiver Sperren -├── *.offenses # Offense-Zähler (Progressive Sperren) +├── adguard-shield.db # SQLite-Datenbank (Bans, Offenses, History, Whitelist-Cache) +├── .migration_v1_complete # Marker: Flat-File-Migration abgeschlossen +├── .backup_pre_sqlite/ # Backup der alten Flat-Files nach Migration ├── external-blocklist/ # Cache für externe Blocklisten -├── external-whitelist/ # Cache für externe Whitelisten + aufgelöste IPs +├── external-whitelist/ # Cache für externe Whitelisten └── geoip-cache/ # Cache für GeoIP-Lookups (24h) /var/log/ ├── adguard-shield.log # Laufzeit-Log -└── adguard-shield-bans.log # Ban-History (alle Sperren/Entsperrungen) +└── adguard-shield-bans.log # Ban-History (Legacy, wird nach Migration nicht mehr geschrieben) ``` ## Installer-Architektur @@ -176,7 +166,7 @@ Der Installer (`install.sh`) bietet ein interaktives Menü und folgende Funktion | Befehl | Beschreibung | |--------|--------------| | `install` | Vollständige Neuinstallation (Abhängigkeiten, Dateien, Konfiguration, Service, Watchdog) | -| `update` | Update mit automatischer Konfigurations-Migration, Watchdog-Aktivierung und Service-Neustart | +| `update` | Update mit automatischer Konfigurations-Migration, Datenbank-Migration, Watchdog-Aktivierung und Service-Neustart | | `uninstall` | Deinstallation mit optionalem Behalten der Konfiguration | | `status` | Installationsstatus, Version und Service-Status anzeigen | | `--help` | Hilfe und Befehlsübersicht | @@ -206,15 +196,20 @@ Der Installer (`install.sh`) bietet ein interaktives Menü und folgende Funktion ## Ban-History -Jede Sperre und Entsperrung wird dauerhaft in der Ban-History protokolliert (`/var/log/adguard-shield-bans.log`). Das ermöglicht eine lückenlose Nachvollziehbarkeit, auch nachdem State-Dateien bereits gelöscht wurden. +Jede Sperre und Entsperrung wird dauerhaft in der SQLite-Datenbank protokolliert (Tabelle `ban_history`). Das ermöglicht eine lückenlose Nachvollziehbarkeit mit indexierter Suche nach IP, Zeitstempel und Aktion. -**Format:** -``` -ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND -2026-03-03 14:30:12 | BAN | 192.168.1.50 | microsoft.com | 45 | 3600s | rate-limit -2026-03-03 15:30:12 | UNBAN | 192.168.1.50 | microsoft.com | - | - | expired -2026-03-03 16:10:33 | UNBAN | 10.0.0.25 | telemetry.example.com | - | - | manual -``` +**Gespeicherte Felder pro Eintrag:** +| Feld | Beschreibung | +|------|--------------| +| `timestamp_epoch` | Unix-Zeitstempel | +| `timestamp_text` | Lesbarer Zeitstempel | +| `action` | `BAN` oder `UNBAN` | +| `client_ip` | Betroffene IP-Adresse | +| `domain` | Angefragte Domain | +| `count` | Anzahl der Anfragen | +| `duration` | Sperrdauer | +| `protocol` | Verwendetes DNS-Protokoll | +| `reason` | Sperrgrund | **Mögliche Gründe (GRUND-Spalte):** | Grund | Bedeutung | diff --git a/docs/befehle.md b/docs/befehle.md index 385cc1b..7c35f49 100644 --- a/docs/befehle.md +++ b/docs/befehle.md @@ -42,9 +42,10 @@ Beim Update passiert automatisch: 2. Die bestehende Konfiguration wird als `adguard-shield.conf.old` gesichert 3. Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig hinzugefügt 4. Bestehende Einstellungen bleiben **immer** erhalten -5. Der systemd Service und Watchdog-Timer werden per `daemon-reload` neu geladen -6. Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) -7. Der Service wird automatisch neu gestartet (falls er lief) +5. Bestehende Flat-File-Daten werden einmalig (mit einem Update kommend von einer v0.9.0 oder älter) in die SQLite-Datenbank migriert (mit Fortschrittsanzeige und Backup) +6. Der systemd Service und Watchdog-Timer werden per `daemon-reload` neu geladen +7. Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) +8. Der Service wird automatisch neu gestartet (falls er lief) ### API-Verbindungstest nach Installation @@ -66,6 +67,7 @@ Folgende Pakete werden bei der Installation automatisch installiert (via `apt`): - `iptables` — Firewall-Regeln für IP-Sperren - `gawk` — Textverarbeitung - `systemd` — Service-Management +- `sqlite3` — Datenbank für State-Management, Ban-History und Offense-Tracking ## systemd Service diff --git a/docs/konfiguration.md b/docs/konfiguration.md index e28e4cc..5a3dae9 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -137,7 +137,7 @@ Wiederholungstäter werden wie bei fail2ban stufenweise länger gesperrt. Wird e | `LOG_FILE` | `/var/log/adguard-shield.log` | Pfad zur Log-Datei | | `LOG_LEVEL` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` | | `LOG_MAX_SIZE_MB` | `50` | Max. Log-Größe bevor rotiert wird | -| `BAN_HISTORY_FILE` | `/var/log/adguard-shield-bans.log` | Datei für die Ban-History (alle Sperren/Entsperrungen) | +| `BAN_HISTORY_FILE` | `/var/log/adguard-shield-bans.log` | Legacy: Pfad zur alten Ban-History-Datei (wird bei der SQLite-Migration als Quelle verwendet). Neue Einträge werden direkt in die SQLite-Datenbank geschrieben. | | `BAN_HISTORY_RETENTION_DAYS` | `0` | Aufbewahrungsdauer der Ban-History in Tagen. `0` = unbegrenzt (niemals löschen). Alte Einträge werden beim nächsten Report automatisch entfernt. | ### Benachrichtigungen @@ -175,7 +175,7 @@ Regelmäßige Statistik-Reports per E-Mail. Voraussetzung ist ein funktionierend | Parameter | Standard | Beschreibung | |-----------|----------|--------------| -| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für State-Dateien | +| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für die SQLite-Datenbank (`adguard-shield.db`) und Caches | | `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei | | `DRY_RUN` | `false` | Testmodus — nur loggen, nicht sperren | diff --git a/docs/report.md b/docs/report.md index 79e3cc0..502e225 100644 --- a/docs/report.md +++ b/docs/report.md @@ -259,7 +259,7 @@ Die Test-Mail enthält eine Übersicht der aktuellen Konfiguration und bestätig ### Report enthält keine Daten -Der Report basiert auf der Ban-History-Datei (`/var/log/adguard-shield-bans.log`). Wenn keine Sperren im Berichtszeitraum vorhanden sind, zeigt der Report „Keine Daten" an. +Der Report basiert auf der Ban-History in der SQLite-Datenbank (`/var/lib/adguard-shield/adguard-shield.db`). Wenn keine Sperren im Berichtszeitraum vorhanden sind, zeigt der Report „Keine Daten" an. ### Cron-Job wird nicht ausgeführt diff --git a/docs/tipps-und-troubleshooting.md b/docs/tipps-und-troubleshooting.md index 23f0663..9813669 100644 --- a/docs/tipps-und-troubleshooting.md +++ b/docs/tipps-und-troubleshooting.md @@ -149,7 +149,7 @@ Wenn eine IP die maximale Stufe der progressiven Sperren erreicht hat, wird sie ### Sperren überleben Reboot nicht -Das ist normal — iptables-Regeln sind flüchtig. Der **Service** erstellt die Chain beim Start automatisch neu. Aktive Sperren aus dem State-Verzeichnis werden aber nicht automatisch wiederhergestellt. +Das ist normal — iptables-Regeln sind flüchtig. Der **Service** erstellt die Chain beim Start automatisch neu. Aktive Sperren aus der SQLite-Datenbank werden aber nicht automatisch als iptables-Regeln wiederhergestellt. **Optionen:** - `iptables-persistent` installieren (`apt install iptables-persistent`) @@ -240,6 +240,7 @@ sudo bash install.sh update - Konfiguration wird als `adguard-shield.conf.old` gesichert - Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig ergänzt - Bestehende Einstellungen bleiben erhalten +- Bestehende Flat-File-Daten werden einmalig in die SQLite-Datenbank migriert (mit Fortschrittsanzeige) - Service wird per `daemon-reload` neu geladen und automatisch neu gestartet ## Deinstallation @@ -281,5 +282,6 @@ Folgende Pakete werden für den Betrieb benötigt und bei der Installation autom | `iptables` | Firewall-Regeln (IPv4 + IPv6) | | `gawk` | Textverarbeitung in Scripts | | `systemd` | Service-Management und Autostart | +| `sqlite3` | Datenbank für State-Management, Ban-History und Offense-Tracking | Diese werden bei `sudo bash install.sh install` automatisch geprüft und bei Bedarf über den Paketmanager (`apt`, `dnf`, `yum`, `pacman`) nachinstalliert. diff --git a/docs/update.md b/docs/update.md index 0636259..aa84a6a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -31,13 +31,14 @@ sudo bash install.sh update Das Update-Script macht automatisch folgendes: -1. **Abhängigkeiten prüfen** — Fehlende Pakete werden nachinstalliert +1. **Abhängigkeiten prüfen** — Fehlende Pakete (inkl. `sqlite3`) werden nachinstalliert 2. **Scripts aktualisieren** — Alle `.sh`-Dateien werden nach `/opt/adguard-shield/` kopiert 3. **Konfigurations-Migration** — Neue Parameter werden automatisch zur bestehenden Konfiguration hinzugefügt, bestehende Einstellungen bleiben **unverändert** 4. **Backup erstellen** — Die alte Konfiguration wird als `adguard-shield.conf.old` gesichert -5. **Service aktualisieren** — Die systemd Service-Datei und Watchdog-Dateien werden aktualisiert und `daemon-reload` ausgeführt -6. **Watchdog aktivieren** — Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) -7. **Service neustarten** — Der Service wird automatisch neu gestartet (falls er vorher lief) +5. **Datenbank-Migration (in der v1.0.0)** — Bestehende Flat-File-Daten (`.ban`, `.offenses`, Ban-History-Log) werden einmalig in die SQLite-Datenbank migriert. Die alten Dateien werden als Backup gesichert. Der Fortschritt und das Ergebnis werden im Terminal angezeigt. +6. **Service aktualisieren** — Die systemd Service-Datei und Watchdog-Dateien werden aktualisiert und `daemon-reload` ausgeführt +7. **Watchdog aktivieren** — Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) +8. **Service neustarten** — Der Service wird automatisch neu gestartet (falls er vorher lief) ### 3. Neue Parameter prüfen (optional) diff --git a/external-blocklist-worker.sh b/external-blocklist-worker.sh index 6613a3e..ebc3d53 100644 --- a/external-blocklist-worker.sh +++ b/external-blocklist-worker.sh @@ -23,6 +23,9 @@ fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" + # ─── Worker PID-File ────────────────────────────────────────────────────────── WORKER_PID_FILE="/var/run/adguard-blocklist-worker.pid" @@ -48,21 +51,11 @@ log_ban_history() { local action="$1" local client_ip="$2" local reason="${3:-external-blocklist}" - 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 | 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 | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "-" "-" "$duration" "-" "$reason" \ - >> "$BAN_HISTORY_FILE" + db_history_add "$action" "$client_ip" "-" "-" "$reason" "$duration" "-" } # ─── Verzeichnisse erstellen ────────────────────────────────────────────────── @@ -70,6 +63,7 @@ init_directories() { mkdir -p "$EXTERNAL_BLOCKLIST_CACHE_DIR" mkdir -p "$STATE_DIR" mkdir -p "$(dirname "$LOG_FILE")" + db_init } # ─── Whitelist Prüfung ─────────────────────────────────────────────────────── @@ -77,15 +71,13 @@ is_whitelisted() { local ip="$1" IFS=',' read -ra wl_entries <<< "$WHITELIST" for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) # trim + entry=$(echo "$entry" | xargs) if [[ "$ip" == "$entry" ]]; then return 0 fi done - # Externe Whitelist prüfen (aufgelöste IPs aus dem Whitelist-Worker) - local ext_wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt" - if [[ -f "$ext_wl_file" ]] && grep -qxF "$ip" "$ext_wl_file" 2>/dev/null; then + if db_whitelist_contains "$ip"; then return 0 fi @@ -118,11 +110,10 @@ setup_iptables_chain() { # ─── IP sperren ────────────────────────────────────────────────────────────── ban_ip() { local ip="$1" - local state_file="${STATE_DIR}/ext_${ip//[:\/]/_}.ban" # Bereits gesperrt? - if [[ -f "$state_file" ]]; then - # iptables-Regel prüfen und ggf. nachziehen (z.B. nach Neustart verloren gegangen) + if db_ban_exists "$ip"; then + # iptables-Regel pruefen und ggf. nachziehen if [[ "$ip" == *:* ]]; then if ! ip6tables -C "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null; then ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true @@ -132,14 +123,7 @@ ban_ip() { iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true fi fi - log "DEBUG" "IP $ip bereits über externe Blocklist gesperrt" - return 0 - fi - - # Nicht auch vom Hauptscript gesperrt? (State-Datei ohne ext_ Prefix) - local main_state_file="${STATE_DIR}/${ip//[:\/]/_}.ban" - if [[ -f "$main_state_file" ]]; then - log "DEBUG" "IP $ip bereits vom Rate-Limiter gesperrt - überspringe" + log "DEBUG" "IP $ip bereits gesperrt" return 0 fi @@ -151,36 +135,24 @@ ban_ip() { log "WARN" "SPERRE IP (externe Blocklist): $ip" - # iptables-Regel setzen if [[ "$ip" == *:* ]]; then ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true else iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true fi - # State speichern local ban_until_epoch="0" - local ban_until_display="permanent" + local is_permanent=1 if [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]]; then ban_until_epoch=$(date -d "+${EXTERNAL_BLOCKLIST_BAN_DURATION} seconds" '+%s' 2>/dev/null \ || date -v "+${EXTERNAL_BLOCKLIST_BAN_DURATION}S" '+%s') - ban_until_display=$(date -d "@$ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null \ - || date -r "$ban_until_epoch" '+%Y-%m-%d %H:%M:%S') + is_permanent=0 fi - cat > "$state_file" << EOF -CLIENT_IP=$ip -DOMAIN=- -COUNT=- -BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S') -BAN_UNTIL_EPOCH=$ban_until_epoch -BAN_UNTIL=$ban_until_display -SOURCE=external-blocklist -EOF + db_ban_insert "$ip" "-" "0" "$(date '+%Y-%m-%d %H:%M:%S')" "$ban_until_epoch" "${EXTERNAL_BLOCKLIST_BAN_DURATION:-0}" "0" "$is_permanent" "external-blocklist" "-" "external-blocklist" log_ban_history "BAN" "$ip" "external-blocklist" - # Benachrichtigung senden (nur wenn EXTERNAL_BLOCKLIST_NOTIFY=true) if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then send_notification "ban" "$ip" fi @@ -190,9 +162,8 @@ EOF unban_ip() { local ip="$1" local reason="${2:-external-blocklist-removed}" - local state_file="${STATE_DIR}/ext_${ip//[:\/]/_}.ban" - [[ -f "$state_file" ]] || return 0 + db_ban_exists "$ip" || return 0 log "INFO" "ENTSPERRE IP (externe Blocklist entfernt): $ip" @@ -202,7 +173,7 @@ unban_ip() { iptables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true fi - rm -f "$state_file" + db_ban_delete "$ip" log_ban_history "UNBAN" "$ip" "$reason" if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then @@ -542,31 +513,21 @@ parse_blocklist_ips() { # ─── Aktuelle externe Sperren ermitteln ────────────────────────────────────── get_currently_banned_external_ips() { - for state_file in "${STATE_DIR}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 - done + db_ban_get_by_source "external-blocklist" } # ─── Abgelaufene externe Sperren prüfen ───────────────────────────────────── check_expired_external_bans() { [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] || return - local now - now=$(date '+%s') + local expired_ips + expired_ips=$(db_ban_get_expired_by_source "external-blocklist") + [[ -z "$expired_ips" ]] && return - for state_file in "${STATE_DIR}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - - local ban_until_epoch - ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2) - local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) - - if [[ -n "$ban_until_epoch" && "$ban_until_epoch" -gt 0 && "$now" -ge "$ban_until_epoch" ]]; then - unban_ip "$client_ip" "external-blocklist-expired" - fi - done + while IFS= read -r client_ip; do + [[ -z "$client_ip" ]] && continue + unban_ip "$client_ip" "external-blocklist-expired" + done <<< "$expired_ips" } # ─── Blocklisten synchronisieren ───────────────────────────────────────────── @@ -615,9 +576,8 @@ sync_blocklists() { continue fi - local _state_file_before="${STATE_DIR}/ext_${ip//[:/]/_}.ban" local _was_new=false - [[ ! -f "$_state_file_before" ]] && _was_new=true + db_ban_exists "$ip" || _was_new=true ban_ip "$ip" [[ "$_was_new" == "true" ]] && new_bans=$((new_bans + 1)) @@ -729,12 +689,9 @@ show_status() { echo "" # Aktive externe Sperren - local ext_ban_count=0 - for state_file in "${STATE_DIR}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - ext_ban_count=$((ext_ban_count + 1)) - done - echo " Aktive Sperren (externe Blocklist): $ext_ban_count" + local ext_ban_count + ext_ban_count=$(db_ban_count_by_source "external-blocklist") + echo " Aktive Sperren (externe Blocklist): ${ext_ban_count:-0}" echo "" echo " Prüfintervall: ${EXTERNAL_BLOCKLIST_INTERVAL}s" @@ -817,11 +774,14 @@ case "${1:-start}" in flush) init_directories echo "Entferne alle externen Blocklist-Sperren..." - for state_file in "${STATE_DIR}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - _ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) - unban_ip "$_ip" "manual-flush" - done + local flush_ips + flush_ips=$(db_ban_get_by_source "external-blocklist") + if [[ -n "$flush_ips" ]]; then + while IFS= read -r _ip; do + [[ -z "$_ip" ]] && continue + unban_ip "$_ip" "manual-flush" + done <<< "$flush_ips" + fi echo "Alle externen Blocklist-Sperren aufgehoben" ;; *) diff --git a/external-whitelist-worker.sh b/external-whitelist-worker.sh index 83734bf..101f832 100644 --- a/external-whitelist-worker.sh +++ b/external-whitelist-worker.sh @@ -25,9 +25,11 @@ fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" + # ─── Standardwerte ──────────────────────────────────────────────────────────── EXTERNAL_WHITELIST_CACHE_DIR="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}" -EXTERNAL_WHITELIST_RESOLVED_FILE="${EXTERNAL_WHITELIST_CACHE_DIR}/resolved_ips.txt" # ─── Worker PID-File ────────────────────────────────────────────────────────── WORKER_PID_FILE="/var/run/adguard-whitelist-worker.pid" @@ -53,6 +55,7 @@ log() { init_directories() { mkdir -p "$EXTERNAL_WHITELIST_CACHE_DIR" mkdir -p "$(dirname "$LOG_FILE")" + db_init } # ─── Eintrag-Validierung ───────────────────────────────────────────────────── @@ -271,7 +274,7 @@ sync_whitelists() { index=$((index + 1)) done - # Alle Einträge aus Cache-Dateien parsen und IPs auflösen + # Alle Eintraege aus Cache-Dateien parsen und IPs aufloesen local all_ips_file="${EXTERNAL_WHITELIST_CACHE_DIR}/.all_ips.tmp" > "$all_ips_file" @@ -280,56 +283,42 @@ sync_whitelists() { parse_whitelist_entries "$cache_file" >> "$all_ips_file" done - # Duplikate entfernen und in die resolved-Datei schreiben + # Duplikate entfernen und in SQLite-Whitelist schreiben (atomar) + local unique_file="${EXTERNAL_WHITELIST_CACHE_DIR}/.all_ips_unique.tmp" + sort -u "$all_ips_file" > "$unique_file" local unique_count - sort -u "$all_ips_file" > "${EXTERNAL_WHITELIST_RESOLVED_FILE}.tmp" - mv "${EXTERNAL_WHITELIST_RESOLVED_FILE}.tmp" "$EXTERNAL_WHITELIST_RESOLVED_FILE" - unique_count=$(wc -l < "$EXTERNAL_WHITELIST_RESOLVED_FILE" | xargs) + unique_count=$(wc -l < "$unique_file" | xargs) - rm -f "$all_ips_file" + db_whitelist_sync "external" < "$unique_file" + + rm -f "$all_ips_file" "$unique_file" log "DEBUG" "Externe Whitelist: $unique_count eindeutige IPs aufgelöst" - # Prüfe ob gesperrte IPs jetzt auf der Whitelist stehen und entsperrt werden müssen + # Pruefen ob gesperrte IPs jetzt auf der Whitelist stehen check_banned_whitelist_ips } # ─── Gesperrte IPs prüfen die jetzt gewhitelistet sind ────────────────────── -# Wenn eine IP nach einer Whitelist-Aktualisierung nun auf der externen -# Whitelist steht, wird sie automatisch entsperrt. check_banned_whitelist_ips() { - local state_dir="${STATE_DIR:-/var/lib/adguard-shield}" - [[ -d "$state_dir" ]] || return - [[ -f "$EXTERNAL_WHITELIST_RESOLVED_FILE" ]] || return + # Alle gesperrten IPs pruefen, ob sie jetzt auf der Whitelist stehen + local banned_ips + banned_ips=$(db_query "SELECT a.client_ip FROM active_bans a INNER JOIN whitelist_cache w ON a.client_ip = w.ip_address;") + [[ -z "$banned_ips" ]] && return - for state_file in "${state_dir}"/*.ban "${state_dir}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) + while IFS= read -r client_ip; do [[ -z "$client_ip" ]] && continue + log "INFO" "Gesperrte IP $client_ip ist jetzt auf externer Whitelist – entsperre automatisch" - if grep -qxF "$client_ip" "$EXTERNAL_WHITELIST_RESOLVED_FILE" 2>/dev/null; then - log "INFO" "Gesperrte IP $client_ip ist jetzt auf externer Whitelist – entsperre automatisch" - - # iptables-Regel entfernen - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - rm -f "$state_file" - - # Ban-History Eintrag - if [[ -f "${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ - "$timestamp" "UNBAN" "$client_ip" "-" "-" "-" "-" "external-whitelist" \ - >> "${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" - fi + if [[ "$client_ip" == *:* ]]; then + ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true + else + iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - done + + db_ban_delete "$client_ip" + db_history_add "UNBAN" "$client_ip" "-" "-" "external-whitelist" "-" "-" + done <<< "$banned_ips" } # ─── PID-Management ────────────────────────────────────────────────────────── @@ -409,27 +398,29 @@ show_status() { echo "" - # Aufgelöste IPs - if [[ -f "$EXTERNAL_WHITELIST_RESOLVED_FILE" ]]; then - local resolved_count - resolved_count=$(wc -l < "$EXTERNAL_WHITELIST_RESOLVED_FILE" | xargs) - local last_resolved - last_resolved=$(date -r "$EXTERNAL_WHITELIST_RESOLVED_FILE" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt") - echo " Aufgelöste IPs: $resolved_count" - echo " Letzte Auflösung: $last_resolved" + # Aufgelöste IPs aus Datenbank + local resolved_count + resolved_count=$(db_whitelist_count) - if [[ "$resolved_count" -gt 0 && "$resolved_count" -le 20 ]]; then + if [[ "${resolved_count:-0}" -gt 0 ]]; then + echo " Aufgelöste IPs: $resolved_count" + + if [[ "$resolved_count" -le 20 ]]; then echo "" echo " Aktuelle IPs:" + local all_wl_ips + all_wl_ips=$(db_whitelist_get_all) while IFS= read -r ip; do echo " ✅ $ip" - done < "$EXTERNAL_WHITELIST_RESOLVED_FILE" - elif [[ "$resolved_count" -gt 20 ]]; then + done <<< "$all_wl_ips" + else echo "" echo " Erste 20 IPs:" - head -20 "$EXTERNAL_WHITELIST_RESOLVED_FILE" | while IFS= read -r ip; do + local first_wl_ips + first_wl_ips=$(db_query "SELECT ip_address FROM whitelist_cache LIMIT 20;") + while IFS= read -r ip; do echo " ✅ $ip" - done + done <<< "$first_wl_ips" echo " ... ($((resolved_count - 20)) weitere)" fi else @@ -508,7 +499,7 @@ case "${1:-start}" in flush) init_directories echo "Entferne aufgelöste externe Whitelist-IPs..." - rm -f "$EXTERNAL_WHITELIST_RESOLVED_FILE" + db_whitelist_clear echo "Externe Whitelist-IPs entfernt" ;; *) diff --git a/geoip-worker.sh b/geoip-worker.sh index e2f63ff..62c92bb 100644 --- a/geoip-worker.sh +++ b/geoip-worker.sh @@ -22,6 +22,9 @@ fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" + # ─── Worker PID-File ────────────────────────────────────────────────────────── WORKER_PID_FILE="/var/run/adguard-geoip-worker.pid" @@ -56,20 +59,8 @@ log_ban_history() { local client_ip="$2" local country="${3:-}" local reason="${4:-geoip}" - 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 | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" - echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" - fi - - local duration="permanent" - - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ - "$timestamp" "$action" "$client_ip" "Land: ${country:-?}" "-" "$duration" "-" "$reason" \ - >> "$BAN_HISTORY_FILE" + db_history_add "$action" "$client_ip" "Land: ${country:-?}" "-" "$reason" "permanent" "-" } # ─── Verzeichnisse erstellen ────────────────────────────────────────────────── @@ -78,6 +69,7 @@ init_directories() { mkdir -p "$GEOIP_DB_DIR" mkdir -p "$STATE_DIR" mkdir -p "$(dirname "$LOG_FILE")" + db_init } # ─── Private IP-Adressen erkennen ──────────────────────────────────────────── @@ -107,15 +99,13 @@ is_whitelisted() { local ip="$1" IFS=',' read -ra wl_entries <<< "$WHITELIST" for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) # trim + entry=$(echo "$entry" | xargs) if [[ "$ip" == "$entry" ]]; then return 0 fi done - # Externe Whitelist prüfen - local ext_wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt" - if [[ -f "$ext_wl_file" ]] && grep -qxF "$ip" "$ext_wl_file" 2>/dev/null; then + if db_whitelist_contains "$ip"; then return 0 fi @@ -324,8 +314,7 @@ ban_ip_geoip() { local mode="${GEOIP_MODE:-blocklist}" # Prüfen ob bereits gesperrt - local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" - if [[ -f "$state_file" ]]; then + if db_ban_exists "$client_ip"; then log "DEBUG" "GeoIP: $client_ip ist bereits gesperrt" return 0 fi @@ -350,22 +339,8 @@ ban_ip_geoip() { iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - # State-Datei erstellen - cat > "$state_file" << EOF -CLIENT_IP=$client_ip -DOMAIN=GeoIP:${country_code} -COUNT=- -BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S') -BAN_UNTIL_EPOCH=0 -BAN_UNTIL=PERMANENT -BAN_DURATION=0 -OFFENSE_LEVEL=0 -IS_PERMANENT=true -REASON=geoip -PROTOCOL=- -GEOIP_COUNTRY=$country_code -GEOIP_MODE=$mode -EOF + # State in Datenbank speichern + db_ban_insert "$client_ip" "GeoIP:${country_code}" "0" "$(date '+%Y-%m-%d %H:%M:%S')" "0" "0" "0" "1" "geoip" "-" "geoip" "$country_code" "$mode" # Ban-History log_ban_history "BAN" "$client_ip" "$country_code" "$reason_text" @@ -516,47 +491,37 @@ get_active_clients() { # - GeoIP deaktiviert wurde auto_unban_geoip() { local unban_count=0 + local geoip_bans + geoip_bans=$(db_ban_get_by_reason "geoip") + [[ -z "$geoip_bans" ]] && return - for f in "${STATE_DIR}"/*.ban; do - [[ -f "$f" ]] || continue - - local reason - reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true) - [[ "$reason" != "geoip" ]] && continue - - local client_ip country_code old_mode - client_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true) - country_code=$(grep '^GEOIP_COUNTRY=' "$f" | cut -d= -f2 || true) - old_mode=$(grep '^GEOIP_MODE=' "$f" | cut -d= -f2 || true) + while IFS='|' read -r client_ip domain count ban_time ban_until_epoch ban_duration offense_level is_permanent reason protocol source geoip_country geoip_mode; do + [[ -z "$client_ip" ]] && continue local should_unban=false - # GeoIP deaktiviert → alle GeoIP-Sperren aufheben if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then should_unban=true - # Modus gewechselt → alle GeoIP-Sperren aufheben und neu prüfen - elif [[ -n "$old_mode" && "$old_mode" != "${GEOIP_MODE:-blocklist}" ]]; then + elif [[ -n "$geoip_mode" && "$geoip_mode" != "${GEOIP_MODE:-blocklist}" ]]; then should_unban=true - # Prüfen ob das Land nach aktueller Konfiguration noch gesperrt sein soll - elif [[ -n "$country_code" ]] && ! should_block_by_geoip "$country_code"; then + elif [[ -n "$geoip_country" ]] && ! should_block_by_geoip "$geoip_country"; then should_unban=true fi if [[ "$should_unban" == "true" ]]; then - log "INFO" "GeoIP Auto-Unban: $client_ip (Land: ${country_code:-?}, war: ${old_mode:-?})" + log "INFO" "GeoIP Auto-Unban: $client_ip (Land: ${geoip_country:-?}, war: ${geoip_mode:-?})" - # iptables Regel entfernen if [[ "$client_ip" == *:* ]]; then ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true else iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - rm -f "$f" - log_ban_history "UNBAN" "$client_ip" "$country_code" "geoip-auto-unban" + db_ban_delete "$client_ip" + log_ban_history "UNBAN" "$client_ip" "$geoip_country" "geoip-auto-unban" unban_count=$((unban_count + 1)) fi - done + done <<< "$geoip_bans" if [[ $unban_count -gt 0 ]]; then log "INFO" "GeoIP Auto-Unban: $unban_count Sperren aufgehoben (Länderliste/Modus geändert)" @@ -618,8 +583,7 @@ sync_geoip() { fi # Bereits gesperrt? - local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" - if [[ -f "$state_file" ]]; then + if db_ban_exists "$client_ip"; then skipped=$((skipped + 1)) continue fi @@ -741,21 +705,20 @@ show_status() { echo "" # GeoIP-Sperren anzeigen + local geoip_bans_data + geoip_bans_data=$(db_ban_get_by_reason "geoip") local geoip_bans=0 - if [[ -d "$STATE_DIR" ]]; then - for f in "${STATE_DIR}"/*.ban; do - [[ -f "$f" ]] || continue - local reason - reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true) - if [[ "$reason" == "geoip" ]]; then - geoip_bans=$((geoip_bans + 1)) - local s_ip s_country s_until - s_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true) - s_country=$(grep '^GEOIP_COUNTRY=' "$f" | cut -d= -f2 || true) - s_until=$(grep '^BAN_UNTIL=' "$f" | cut -d= -f2 || true) - echo " 🌍 $s_ip → Land: ${s_country:-?} (bis: ${s_until:-?})" + + if [[ -n "$geoip_bans_data" ]]; then + while IFS='|' read -r s_ip s_domain _ _ s_ban_until_epoch _ _ s_perm_int _ _ _ s_country _; do + [[ -z "$s_ip" ]] && continue + geoip_bans=$((geoip_bans + 1)) + local s_until_display="PERMANENT" + if [[ "$s_ban_until_epoch" != "0" && "$s_perm_int" != "1" ]]; then + s_until_display=$(date -d "@$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?") fi - done + echo " 🌍 $s_ip → Land: ${s_country:-?} (bis: $s_until_display)" + done <<< "$geoip_bans_data" fi if [[ $geoip_bans -eq 0 ]]; then @@ -831,27 +794,24 @@ flush_cache() { # ─── GeoIP-Sperren aufheben ───────────────────────────────────────────────── flush_geoip_bans() { local count=0 - if [[ -d "$STATE_DIR" ]]; then - for f in "${STATE_DIR}"/*.ban; do - [[ -f "$f" ]] || continue - local reason - reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true) - if [[ "$reason" == "geoip" ]]; then - local client_ip - client_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true) + local geoip_ips + geoip_ips=$(db_query "SELECT client_ip FROM active_bans WHERE reason='geoip';") - # iptables Regel entfernen - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi + if [[ -n "$geoip_ips" ]]; then + while IFS= read -r client_ip; do + [[ -z "$client_ip" ]] && continue - rm -f "$f" - log_ban_history "UNBAN" "$client_ip" "" "geoip-flush" - count=$((count + 1)) + # iptables Regel entfernen + if [[ "$client_ip" == *:* ]]; then + ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true + else + iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true fi - done + + db_ban_delete "$client_ip" + log_ban_history "UNBAN" "$client_ip" "" "geoip-flush" + count=$((count + 1)) + done <<< "$geoip_ips" fi echo "✅ $count GeoIP-Sperren aufgehoben" diff --git a/install.sh b/install.sh index 89049fb..b7c63ba 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ # Lizenz: MIT ############################################################################### -VERSION="v0.9.0" +VERSION="v1.0.0" set -euo pipefail @@ -65,6 +65,7 @@ print_help() { echo -e " Aktualisiert alle Scripts, führt eine automatische" echo -e " Konfigurations-Migration durch (neue Parameter werden" echo -e " hinzugefügt, bestehende Einstellungen bleiben erhalten)," + echo -e " migriert bestehende Daten nach SQLite (einmalig)" echo -e " und startet den Service automatisch neu." echo "" echo -e " ${GREEN}uninstall${NC} Vollständige Deinstallation" @@ -142,7 +143,7 @@ print_help() { echo " - Linux Server (Debian/Ubuntu empfohlen)" echo " - Root-Zugriff (sudo)" echo " - AdGuard Home installiert und erreichbar" - echo " - Pakete: curl, jq, iptables, gawk (werden bei Installation automatisch installiert)" + echo " - Pakete: curl, jq, iptables, gawk, sqlite3 (werden bei Installation automatisch installiert)" echo " - GeoIP (optional): geoip-bin + geoip-database oder MaxMind GeoLite2 DB" echo "" echo -e "${BOLD}Dokumentation:${NC}" @@ -197,9 +198,10 @@ check_dependencies() { [ip6tables]="iptables" [gawk]="gawk" [systemctl]="systemd" + [sqlite3]="sqlite3" ) - for cmd in curl jq iptables ip6tables gawk systemctl; do + for cmd in curl jq iptables ip6tables gawk systemctl sqlite3; do if command -v "$cmd" &>/dev/null; then echo -e " ✅ $cmd" else @@ -266,6 +268,7 @@ install_files() { cp "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/" cp "$SCRIPT_DIR/geoip-worker.sh" "$INSTALL_DIR/" cp "$SCRIPT_DIR/offense-cleanup-worker.sh" "$INSTALL_DIR/" + cp "$SCRIPT_DIR/db.sh" "$INSTALL_DIR/" # Templates kopieren mkdir -p "$INSTALL_DIR/templates" @@ -283,6 +286,7 @@ install_files() { chmod +x "$INSTALL_DIR/uninstall.sh" chmod +x "$INSTALL_DIR/geoip-worker.sh" chmod +x "$INSTALL_DIR/offense-cleanup-worker.sh" + chmod +x "$INSTALL_DIR/db.sh" echo -e " ✅ Dateien installiert" echo "" @@ -671,6 +675,92 @@ do_install() { print_summary } +# ─── SQLite-Datenbank-Migration ────────────────────────────────────────────── +# Migriert bestehende Flat-File-Daten (*.ban, *.offenses, History-Log) nach SQLite. +# Läuft synchron im Vordergrund mit sichtbarer Fortschrittsanzeige. +migrate_database() { + echo -e "${YELLOW}Prüfe Datenbank-Migration...${NC}" + + # Konfiguration laden für STATE_DIR und BAN_HISTORY_FILE + local conf="$INSTALL_DIR/adguard-shield.conf" + if [[ ! -f "$conf" ]]; then + echo -e " ${RED}Konfiguration nicht gefunden — Migration übersprungen${NC}" + echo "" + return 0 + fi + + # Nur die benötigten Variablen aus der Konfig laden + STATE_DIR=$(grep '^STATE_DIR=' "$conf" | cut -d= -f2 | tr -d '"') + STATE_DIR="${STATE_DIR:-/var/lib/adguard-shield}" + BAN_HISTORY_FILE=$(grep '^BAN_HISTORY_FILE=' "$conf" | cut -d= -f2 | tr -d '"') + BAN_HISTORY_FILE="${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" + export STATE_DIR BAN_HISTORY_FILE + + # db.sh aus dem Installationsverzeichnis laden + source "$INSTALL_DIR/db.sh" + + # Datenbank initialisieren (Schema anlegen falls nötig) + db_init + + # Prüfen ob Migration bereits durchgeführt wurde + if [[ -f "$_DB_MIGRATION_MARKER" ]]; then + echo -e " ✅ Datenbank ist aktuell — Migration bereits abgeschlossen" + echo "" + return 0 + fi + + # Prüfen ob überhaupt Flat-Files vorhanden sind + local has_files=false + for f in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban "${STATE_DIR}"/*.offenses; do + if [[ -f "$f" ]]; then + has_files=true + break + fi + done + if [[ "$has_files" == "false" && ! -f "$BAN_HISTORY_FILE" ]]; then + # Keine alten Daten vorhanden — Marker setzen und fertig + echo "migrated_at=$(date '+%Y-%m-%d %H:%M:%S')" > "$_DB_MIGRATION_MARKER" + echo "bans=0" >> "$_DB_MIGRATION_MARKER" + echo "offenses=0" >> "$_DB_MIGRATION_MARKER" + echo "history=0" >> "$_DB_MIGRATION_MARKER" + echo "whitelist=0" >> "$_DB_MIGRATION_MARKER" + echo -e " ✅ Keine bestehenden Daten gefunden — Datenbank bereit" + echo "" + return 0 + fi + + echo -e " ${CYAN}Migriere bestehende Daten nach SQLite...${NC}" + echo "" + + local migrated + migrated=$(db_migrate_from_files) + + if [[ "${migrated:-0}" -gt 0 ]]; then + # Details aus dem Marker lesen + local m_bans m_offenses m_history m_whitelist + m_bans=$(grep '^bans=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) + m_offenses=$(grep '^offenses=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) + m_history=$(grep '^history=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) + m_whitelist=$(grep '^whitelist=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) + + echo -e " ${GREEN}═══════════════════════════════════════════════════════════${NC}" + echo -e " ${GREEN} SQLite-Migration erfolgreich abgeschlossen!${NC}" + echo -e " ${GREEN}═══════════════════════════════════════════════════════════${NC}" + echo "" + echo -e " Migrierte Einträge gesamt: ${BOLD}${migrated}${NC}" + [[ "${m_bans:-0}" -gt 0 ]] && echo -e " • Aktive Bans: ${m_bans}" + [[ "${m_offenses:-0}" -gt 0 ]] && echo -e " • Offense-Tracking: ${m_offenses}" + [[ "${m_history:-0}" -gt 0 ]] && echo -e " • Ban-History: ${m_history}" + [[ "${m_whitelist:-0}" -gt 0 ]] && echo -e " • Whitelist-Cache: ${m_whitelist}" + echo "" + echo -e " 📦 Backup der alten Dateien: ${STATE_DIR}/.backup_pre_sqlite/" + echo -e " 📂 Neue Datenbank: ${STATE_DIR}/adguard-shield.db" + else + echo -e " ✅ Migration abgeschlossen — keine Daten zum Migrieren" + fi + echo "" +} + # ─── Update ────────────────────────────────────────────────────────────────── do_update() { check_root @@ -691,6 +781,9 @@ do_update() { # Konfigurations-Migration durchführen migrate_config + # SQLite-Datenbank-Migration durchführen + migrate_database + # Service-Datei aktualisieren echo -e "${YELLOW}Aktualisiere systemd Service...${NC}" cp "$SCRIPT_DIR/adguard-shield.service" "$SERVICE_FILE" @@ -816,6 +909,7 @@ do_uninstall() { rm -f "$INSTALL_DIR/geoip-worker.sh" rm -f "$INSTALL_DIR/report-generator.sh" rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh" + rm -f "$INSTALL_DIR/db.sh" rm -f "$INSTALL_DIR/uninstall.sh" rm -rf "$INSTALL_DIR/templates" rm -rf "$INSTALL_DIR/geoip" diff --git a/offense-cleanup-worker.sh b/offense-cleanup-worker.sh index ff40f9e..d1904cd 100644 --- a/offense-cleanup-worker.sh +++ b/offense-cleanup-worker.sh @@ -24,6 +24,8 @@ if [[ ! -f "$CONFIG_FILE" ]]; then fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" # ─── Niedrigste Priorität setzen (CPU + I/O) ───────────────────────────────── # Stellt sicher, dass der Worker auch bei manuellem Start nie andere Dienste @@ -77,6 +79,7 @@ format_duration() { init_directories() { mkdir -p "${STATE_DIR}" mkdir -p "$(dirname "$LOG_FILE")" + db_init } # ─── Abgelaufene Offense-Zähler aufräumen ──────────────────────────────────── @@ -84,39 +87,23 @@ cleanup_expired_offenses() { local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" local now now=$(date '+%s') - local cleaned=0 + local cutoff=$((now - reset_after)) - local batch_count=0 - for offense_file in "${STATE_DIR}"/*.offenses; do - [[ -f "$offense_file" ]] || continue + local expired_rows + expired_rows=$(db_query "SELECT client_ip, offense_level, last_offense_epoch FROM offense_tracking WHERE last_offense_epoch <= $cutoff;") - local last_offense_epoch client_ip offense_level - last_offense_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true) - client_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true) - offense_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true) - - # Kein Zeitstempel vorhanden → überspringen - if [[ -z "$last_offense_epoch" ]]; then - log "DEBUG" "Offense-Datei ohne Zeitstempel übersprungen: $offense_file" - continue - fi - - local elapsed=$((now - last_offense_epoch)) - - if [[ $elapsed -gt $reset_after ]]; then + if [[ -n "$expired_rows" ]]; then + while IFS='|' read -r client_ip offense_level last_epoch; do + [[ -z "$client_ip" ]] && continue + local elapsed=$((now - last_epoch)) log "INFO" "Offense-Zähler abgelaufen: $client_ip (Stufe $offense_level, letztes Vergehen vor $(format_duration $elapsed)) → entfernt" - rm -f "$offense_file" - cleaned=$((cleaned + 1)) - fi + done <<< "$expired_rows" + fi - # Alle 10 Dateien kurz pausieren, um I/O-Bursts zu vermeiden - batch_count=$((batch_count + 1)) - if (( batch_count % 10 == 0 )); then - sleep 0.1 - fi - done + local cleaned + cleaned=$(db_offense_delete_expired "$reset_after") - if [[ $cleaned -gt 0 ]]; then + if [[ "$cleaned" -gt 0 ]]; then log "INFO" "Offense-Cleanup: $cleaned abgelaufene Zähler entfernt" else log "DEBUG" "Offense-Cleanup: keine abgelaufenen Zähler gefunden" @@ -179,22 +166,11 @@ show_status() { echo " Reset-Zeitraum: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}")" echo " Prüfintervall: $(format_duration "$OFFENSE_CLEANUP_INTERVAL")" - # Aktuelle Offense-Dateien zählen - local total=0 - local expired=0 - local now - now=$(date '+%s') local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" - - for offense_file in "${STATE_DIR}"/*.offenses; do - [[ -f "$offense_file" ]] || continue - total=$((total + 1)) - local last_epoch - last_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true) - if [[ -n "$last_epoch" && $((now - last_epoch)) -gt $reset_after ]]; then - expired=$((expired + 1)) - fi - done + local total + total=$(db_offense_count) + local expired + expired=$(db_offense_count_expired "$reset_after") echo "" echo " Offense-Zähler gesamt: $total" diff --git a/report-generator.sh b/report-generator.sh index 5fdb8e2..9f94979 100644 --- a/report-generator.sh +++ b/report-generator.sh @@ -32,6 +32,8 @@ if [[ ! -f "$CONFIG_FILE" ]]; then fi # shellcheck source=adguard-shield.conf source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" # ─── Standardwerte ──────────────────────────────────────────────────────────── REPORT_ENABLED="${REPORT_ENABLED:-false}" @@ -179,143 +181,48 @@ get_period_end_epoch() { echo $((today_midnight - 1)) } -# ─── History-Cache (einmaliges Einlesen der Ban-History) ───────────────────── -# Die Datei wird genau einmal mit awk geparst; alle Funktionen lesen danach -# nur noch aus diesem In-Memory-Cache – keine date-Subprozesse pro Zeile mehr. -# -# Cache-Format pro Zeile (Pipe-separiert, alle Felder getrimmt): -# EPOCH|TIMESTAMP|ACTION|IP|DOMAIN|COUNT|DURATION|PROTOCOL|REASON -HISTORY_CACHE="" -HISTORY_CACHE_LOADED=false - -_load_history_cache() { - [[ "$HISTORY_CACHE_LOADED" == "true" ]] && return - HISTORY_CACHE_LOADED=true - [[ ! -f "$BAN_HISTORY_FILE" ]] && return - HISTORY_CACHE=$(awk ' - /^#/ || /^[[:space:]]*$/ { next } - { - n = split($0, f, "|") - if (n < 2) next - ts = f[1]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", ts) - if (length(ts) < 19) next - ep = mktime(substr(ts,1,4) " " substr(ts,6,2) " " substr(ts,9,2) " " \ - substr(ts,12,2) " " substr(ts,15,2) " " substr(ts,18,2)) - if (ep < 0) next - for (i = 1; i <= n; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", f[i]) - print ep "|" f[1] "|" f[2] "|" f[3] "|" f[4] "|" f[5] "|" f[6] "|" f[7] "|" f[8] - } - ' "$BAN_HISTORY_FILE") -} - -# ─── Ban-History filtern nach Zeitraum ──────────────────────────────────────── -# Gibt nur Zeilen zurück, deren Zeitstempel im Berichtszeitraum liegen. -# Liest intern aus dem Cache – keine erneuten date-Subprozesse. -filter_history_by_period() { - local start_epoch="$1" - local end_epoch="$2" - - [[ ! -f "$BAN_HISTORY_FILE" ]] && return - _load_history_cache - [[ -z "$HISTORY_CACHE" ]] && return - - # Aus dem Cache filtern und im Original-Format ausgeben (Abwärtskompatibilität) - echo "$HISTORY_CACHE" | awk -F'|' -v s="$start_epoch" -v e="$end_epoch" ' - $1 >= s && $1 <= e { - printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n", - $2, $3, $4, $5, $6, $7, $8, $9 - } - ' -} - # ─── Ban-History bereinigen ──────────────────────────────────────────────────── -# Entfernt Einträge älter als BAN_HISTORY_RETENTION_DAYS (0 = deaktiviert). -# Nutzt einen einzelnen awk-Durchlauf mit mktime() – kein date-Subprocess pro Zeile. cleanup_ban_history() { - [[ ! -f "$BAN_HISTORY_FILE" ]] && return [[ "$BAN_HISTORY_RETENTION_DAYS" == "0" || -z "$BAN_HISTORY_RETENTION_DAYS" ]] && return - local cutoff_epoch - cutoff_epoch=$(date -d "-${BAN_HISTORY_RETENTION_DAYS} days" '+%s' 2>/dev/null) - [[ -z "$cutoff_epoch" ]] && return - - local tmp_file="${BAN_HISTORY_FILE}.tmp" - local lines_before lines_after - lines_before=$(wc -l < "$BAN_HISTORY_FILE") - - awk -v cutoff="$cutoff_epoch" ' - /^#/ || /^[[:space:]]*$/ { print; next } - { - n = split($0, f, "|") - if (n < 2) { print; next } - ts = f[1]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", ts) - if (length(ts) < 19) { print; next } - ep = mktime(substr(ts,1,4) " " substr(ts,6,2) " " substr(ts,9,2) " " \ - substr(ts,12,2) " " substr(ts,15,2) " " substr(ts,18,2)) - if (ep >= cutoff) print - } - ' "$BAN_HISTORY_FILE" > "$tmp_file" - - lines_after=$(wc -l < "$tmp_file") - local removed=$(( lines_before - lines_after )) - - if [[ $removed -gt 0 ]]; then - mv "$tmp_file" "$BAN_HISTORY_FILE" - # Cache invalidieren, damit Folgeaufrufe die bereinigte Datei neu lesen - HISTORY_CACHE="" - HISTORY_CACHE_LOADED=false + local removed + removed=$(db_history_cleanup "$BAN_HISTORY_RETENTION_DAYS") + if [[ "${removed:-0}" -gt 0 ]]; then log "INFO" "Ban-History bereinigt: $removed Einträge älter als ${BAN_HISTORY_RETENTION_DAYS} Tage entfernt" - else - rm -f "$tmp_file" fi } # ─── Statistiken für beliebigen Zeitraum berechnen ────────────────────────── -# Gibt "bans|unbans|unique_ips|permanent" für einen Epochen-Bereich zurück. -# Liest direkt aus dem Cache in einem einzigen awk-Durchlauf. get_stats_for_epoch_range() { local start_epoch="$1" local end_epoch="$2" - _load_history_cache - if [[ -z "$HISTORY_CACHE" ]]; then + local result + result=$(db_history_stats_for_range "$start_epoch" "$end_epoch") + if [[ -z "$result" ]]; then echo "0|0|0|0" return fi - - echo "$HISTORY_CACHE" | awk -F'|' -v s="$start_epoch" -v e="$end_epoch" ' - $1 >= s && $1 <= e { - if ($3 == "BAN") { - bans++ - ip_seen[$4] = 1 - if (tolower($7) ~ /permanent/) perm++ - } else if ($3 == "UNBAN") { - unbans++ - } - } - END { - for (ip in ip_seen) unique++ - print (bans+0) "|" (unbans+0) "|" (unique+0) "|" (perm+0) - } - ' + echo "$result" } # ─── Statistiken berechnen ──────────────────────────────────────────────────── -# Liest die Ban-History genau einmal aus dem Cache und berechnet alle -# Kennzahlen in einem einzigen awk-Durchlauf – keine Subprozesse pro Zeile. calculate_stats() { # Ban-History bereinigen (falls Retention konfiguriert) cleanup_ban_history + # Datenbank initialisieren + db_init + local start_epoch start_epoch=$(get_period_start_epoch) local end_epoch end_epoch=$(get_period_end_epoch) - _load_history_cache + local total_history + total_history=$(db_history_count) - # Wenn keine History-Datei vorhanden, Standardwerte setzen - if [[ -z "$HISTORY_CACHE" ]]; then + if [[ "${total_history:-0}" -eq 0 ]]; then TOTAL_BANS=0 TOTAL_UNBANS=0 UNIQUE_IPS=0 @@ -334,8 +241,12 @@ calculate_stats() { return fi - # Einen einzigen awk-Pass über den Cache: alle Statistiken auf einmal - # Busiest-Day-Bereich berechnen (konfigurierbar, Standard: 30 Tage) + # Haupt-Statistiken per SQL + local stats_row + stats_row=$(db_history_report_stats "$start_epoch" "$end_epoch") + IFS='|' read -r TOTAL_BANS TOTAL_UNBANS UNIQUE_IPS PERMANENT_BANS RATELIMIT_BANS SUBDOMAIN_FLOOD_BANS EXTERNAL_BLOCKLIST_BANS <<< "$stats_row" + + # Busiest-Day-Bereich berechnen local busiest_start_epoch if [[ "$REPORT_BUSIEST_DAY_RANGE" == "0" || -z "$REPORT_BUSIEST_DAY_RANGE" ]]; then busiest_start_epoch="$start_epoch" @@ -345,75 +256,11 @@ calculate_stats() { busiest_start_epoch=$((today_midnight - REPORT_BUSIEST_DAY_RANGE * 86400)) fi - local awk_result - awk_result=$(echo "$HISTORY_CACHE" | awk -F'|' -v s="$start_epoch" -v e="$end_epoch" -v bs="$busiest_start_epoch" ' - $1 >= s && $1 <= e { - action = $3 - if (action == "BAN") { - bans++ - ip_count[$4]++ - ip_seen[$4] = 1 - dom = $5 - if (dom != "" && dom != "-") dom_count[dom]++ - proto = $8 - if (proto == "" || proto == "-") proto = "unbekannt" - proto_count[proto]++ - if (tolower($7) ~ /permanent/) perm++ - rsn = tolower($9) - if (rsn ~ /rate.limit/) rl++ - if (rsn ~ /subdomain.flood/) sf++ - if (rsn ~ /external.blocklist/) eb++ - # Zirkulärer Puffer für die letzten 10 Sperren - recent[bans % 10] = $2 "|" $3 "|" $4 "|" $5 "|" $6 "|" $7 "|" $8 "|" $9 - } else if (action == "UNBAN") { - unbans++ - } - } - # Aktivster Tag: separater Zeitraum (konfigurierbar, z.B. letzte 30 Tage) - $1 >= bs && $1 <= e && $3 == "BAN" { - bday = substr($2, 1, 10) - bday_count[bday]++ - } - END { - for (ip in ip_seen) unique++ - busiest = ""; max_d = 0 - for (d in bday_count) { - if (bday_count[d] > max_d) { max_d = bday_count[d]; busiest = d; busiest_cnt = bday_count[d] } - } - print "BANS=" (bans+0) - print "UNBANS=" (unbans+0) - print "UNIQUE=" (unique+0) - print "PERM=" (perm+0) - print "RL=" (rl+0) - print "SF=" (sf+0) - print "EB=" (eb+0) - print "BUSIEST=" busiest - print "BUSIEST_CNT=" (busiest_cnt+0) - for (ip in ip_count) print "IP\t" ip_count[ip] "\t" ip - for (d in dom_count) print "DOMAIN\t" dom_count[d] "\t" d - for (p in proto_count) print "PROTO\t" proto_count[p] "\t" p - n = (bans < 10) ? bans : 10 - for (i = 0; i < n; i++) { - idx = (bans - i) % 10 - print "RECENT\t" recent[idx] - } - } - ') - - # Einfache Kennzahlen aus dem awk-Ergebnis extrahieren - TOTAL_BANS=$( echo "$awk_result" | awk -F= '$1=="BANS" {print $2; exit}') - TOTAL_UNBANS=$( echo "$awk_result" | awk -F= '$1=="UNBANS" {print $2; exit}') - UNIQUE_IPS=$( echo "$awk_result" | awk -F= '$1=="UNIQUE" {print $2; exit}') - PERMANENT_BANS=$(echo "$awk_result" | awk -F= '$1=="PERM" {print $2; exit}') - RATELIMIT_BANS=$( echo "$awk_result" | awk -F= '$1=="RL" {print $2; exit}') - SUBDOMAIN_FLOOD_BANS=$( echo "$awk_result" | awk -F= '$1=="SF" {print $2; exit}') - EXTERNAL_BLOCKLIST_BANS=$(echo "$awk_result" | awk -F= '$1=="EB" {print $2; exit}') - - local busiest_raw - busiest_raw=$(echo "$awk_result" | awk -F= '$1=="BUSIEST" {print $2; exit}') - local busiest_cnt - busiest_cnt=$(echo "$awk_result" | awk -F= '$1=="BUSIEST_CNT" {print $2; exit}') - if [[ -n "$busiest_raw" ]]; then + local busiest_row + busiest_row=$(db_history_busiest_day "$busiest_start_epoch" "$end_epoch") + if [[ -n "$busiest_row" ]]; then + local busiest_raw busiest_cnt + IFS='|' read -r busiest_raw busiest_cnt <<< "$busiest_row" local busiest_formatted busiest_formatted=$(date -d "$busiest_raw" '+%d.%m.%Y' 2>/dev/null || echo "$busiest_raw") BUSIEST_DAY="${busiest_formatted} (${busiest_cnt})" @@ -421,28 +268,22 @@ calculate_stats() { BUSIEST_DAY="–" fi - # Dynamisches Label für den aktivsten Tag if [[ "$REPORT_BUSIEST_DAY_RANGE" == "0" || -z "$REPORT_BUSIEST_DAY_RANGE" ]]; then BUSIEST_DAY_LABEL="Aktivster Tag" else BUSIEST_DAY_LABEL="Aktivster Tag (${REPORT_BUSIEST_DAY_RANGE} Tage)" fi - # Top-Listen: Tab-getrennte Felder sortieren und in das erwartete Format bringen - TOP10_IPS=$( echo "$awk_result" | awk -F'\t' '$1=="IP" {print $2 " " $3}' | sort -rn | head -10) - TOP10_DOMAINS=$(echo "$awk_result" | awk -F'\t' '$1=="DOMAIN" {print $2 " " $3}' | sort -rn | head -10) - PROTOCOL_STATS=$(echo "$awk_result" | awk -F'\t' '$1=="PROTO" {print $2 " " $3}' | sort -rn) - RECENT_BANS=$( echo "$awk_result" | awk -F'\t' '$1=="RECENT" {print $2}') + # Top-Listen per SQL (Ausgabe: "count|value" → umformatieren zu "count value") + TOP10_IPS=$(db_history_top_ips "$start_epoch" "$end_epoch" 10 | sed 's/|/ /') + TOP10_DOMAINS=$(db_history_top_domains "$start_epoch" "$end_epoch" 10 | sed 's/|/ /') + PROTOCOL_STATS=$(db_history_protocol_stats "$start_epoch" "$end_epoch" | sed 's/|/ /') + RECENT_BANS=$(db_history_recent_bans "$start_epoch" "$end_epoch" 10) - # Aktuell aktive Sperren (aus State-Dateien) - ACTIVE_BANS=0 - if [[ -d "$STATE_DIR" ]]; then - for f in "${STATE_DIR}"/*.ban; do - [[ -f "$f" ]] && ACTIVE_BANS=$((ACTIVE_BANS + 1)) - done - fi + # Aktuell aktive Sperren aus der Datenbank + ACTIVE_BANS=$(db_ban_count) - # AbuseIPDB Reports – zeitraum-gefiltert aus der Logdatei via awk+mktime + # AbuseIPDB Reports – zeitraum-gefiltert aus der Logdatei ABUSEIPDB_REPORTS=0 if [[ -f "$LOG_FILE" ]]; then ABUSEIPDB_REPORTS=$(grep "AbuseIPDB:.*erfolgreich gemeldet" "$LOG_FILE" 2>/dev/null | \ @@ -1081,12 +922,12 @@ send_test_email() { errors=$((errors + 1)) fi - # 5. Ban-History prüfen - echo -n " 5) Ban-History ... " - if [[ -f "$BAN_HISTORY_FILE" ]]; then - local lines - lines=$(grep -vc '^#' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0") - echo "✅ vorhanden ($lines Einträge)" + # 5. Datenbank prüfen + echo -n " 5) Datenbank ... " + if [[ -f "$DB_FILE" ]]; then + local entries + entries=$(db_history_count 2>/dev/null || echo "0") + echo "✅ vorhanden ($entries History-Einträge)" else echo "⚠️ nicht vorhanden (Report wird leer sein – das ist OK für einen Test)" fi diff --git a/unban-expired.sh b/unban-expired.sh index a45184a..0b6bda9 100644 --- a/unban-expired.sh +++ b/unban-expired.sh @@ -17,52 +17,29 @@ if [[ ! -f "$CONFIG_FILE" ]]; then exit 1 fi source "$CONFIG_FILE" +# shellcheck source=db.sh +source "${SCRIPT_DIR}/db.sh" -BAN_HISTORY_FILE="${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')] [UNBAN-TIMER]" -NOW=$(date '+%s') -# History-Eintrag schreiben -log_ban_history() { - local action="$1" - local client_ip="$2" - 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 | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE" - echo "#────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE" - fi - - [[ -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" -} +# Datenbank initialisieren +mkdir -p "${STATE_DIR}" +db_init unban_count=0 -for state_file in "${STATE_DIR}"/*.ban; do - [[ -f "$state_file" ]] || continue +# Abgelaufene Sperren aus der Datenbank abfragen +expired_ips=$(db_ban_get_expired) - ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2) - 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) +if [[ -n "$expired_ips" ]]; then + while IFS= read -r client_ip; do + [[ -z "$client_ip" ]] && continue - # Permanente Sperren nicht automatisch aufheben - if [[ "$is_permanent" == "true" || "$ban_until_epoch" == "0" ]]; then - continue - fi + # Domain und Protokoll für History-Eintrag holen + local_ban_data=$(db_ban_get "$client_ip") + domain=$(echo "$local_ban_data" | cut -d'|' -f2) + protocol=$(echo "$local_ban_data" | cut -d'|' -f10) - if [[ -n "$ban_until_epoch" && "$NOW" -ge "$ban_until_epoch" ]]; then echo "$LOG_PREFIX Entsperre abgelaufene Sperre: $client_ip" >> "$LOG_FILE" # iptables Regel entfernen @@ -73,12 +50,12 @@ for state_file in "${STATE_DIR}"/*.ban; do fi # Ban-History Eintrag - log_ban_history "UNBAN" "$client_ip" "$domain" "-" "expired-cron" "${protocol:-}" + db_history_add "UNBAN" "$client_ip" "${domain:--}" "-" "expired-cron" "-" "${protocol:-}" - rm -f "$state_file" + db_ban_delete "$client_ip" unban_count=$((unban_count + 1)) - fi -done + done <<< "$expired_ips" +fi if [[ $unban_count -gt 0 ]]; then echo "$LOG_PREFIX $unban_count Sperren aufgehoben" >> "$LOG_FILE" diff --git a/uninstall.sh b/uninstall.sh index 751891e..ba964f7 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -127,6 +127,7 @@ do_uninstall() { rm -f "$INSTALL_DIR/report-generator.sh" rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh" rm -f "$INSTALL_DIR/geoip-worker.sh" + rm -f "$INSTALL_DIR/db.sh" rm -f "$INSTALL_DIR/uninstall.sh" rm -rf "$INSTALL_DIR/templates" rm -rf "$INSTALL_DIR/geoip" From 4f17f7ff81b11623c00f72a3dcd7c971e2c0603b Mon Sep 17 00:00:00 2001 From: Patrick Asmus Date: Fri, 1 May 2026 00:08:57 +0200 Subject: [PATCH 2/2] feat!: Migration auf Go-Binary BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden. --- .gitea/workflows/pr-test.yml | 46 + .gitea/workflows/release.yml | 110 ++ .gitea/workflows/security-scan.yml | 36 + .gitignore | 5 + README.md | 81 +- adguard-shield-watchdog.service | 7 - adguard-shield-watchdog.sh | 166 --- adguard-shield-watchdog.timer | 11 - adguard-shield.conf | 8 +- adguard-shield.service | 7 +- adguard-shield.sh | 1571 ---------------------------- cmd/adguard-shieldd/main.go | 661 ++++++++++++ db.sh | 641 ------------ docs/README.md | 64 +- docs/architektur.md | 555 ++++++---- docs/befehle.md | 977 ++++++++++------- docs/benachrichtigungen.md | 371 ++++--- docs/docker.md | 52 + docs/konfiguration.md | 956 ++++++++++------- docs/report.md | 405 ++++--- docs/tipps-und-troubleshooting.md | 659 +++++++----- docs/update.md | 254 ++++- external-blocklist-worker.sh | 805 -------------- external-whitelist-worker.sh | 523 --------- geoip-worker.sh | 892 ---------------- go.mod | 24 + go.sum | 57 + install.sh | 966 ----------------- internal/appinfo/appinfo.go | 5 + internal/config/config.go | 295 ++++++ internal/config/config_test.go | 44 + internal/daemon/daemon.go | 1221 +++++++++++++++++++++ internal/daemon/daemon_test.go | 365 +++++++ internal/daemon/live.go | 384 +++++++ internal/db/db.go | 408 ++++++++ internal/db/db_test.go | 31 + internal/firewall/firewall.go | 203 ++++ internal/firewall/firewall_test.go | 142 +++ internal/geoip/geoip.go | 245 +++++ internal/geoip/geoip_test.go | 30 + internal/installer/installer.go | 642 ++++++++++++ internal/report/report.go | 242 +++++ internal/syslog/syslog.go | 82 ++ iptables-helper.sh | 234 ----- offense-cleanup-worker.sh | 244 ----- report-generator.sh | 1130 -------------------- templates/report.html | 370 ------- templates/report.txt | 68 -- unban-expired.sh | 62 -- uninstall.sh | 151 --- 50 files changed, 8012 insertions(+), 9496 deletions(-) create mode 100644 .gitea/workflows/pr-test.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/security-scan.yml delete mode 100644 adguard-shield-watchdog.service delete mode 100644 adguard-shield-watchdog.sh delete mode 100644 adguard-shield-watchdog.timer delete mode 100644 adguard-shield.sh create mode 100644 cmd/adguard-shieldd/main.go delete mode 100644 db.sh create mode 100644 docs/docker.md delete mode 100644 external-blocklist-worker.sh delete mode 100644 external-whitelist-worker.sh delete mode 100644 geoip-worker.sh create mode 100644 go.mod create mode 100644 go.sum delete mode 100644 install.sh create mode 100644 internal/appinfo/appinfo.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/daemon/daemon.go create mode 100644 internal/daemon/daemon_test.go create mode 100644 internal/daemon/live.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/firewall/firewall.go create mode 100644 internal/firewall/firewall_test.go create mode 100644 internal/geoip/geoip.go create mode 100644 internal/geoip/geoip_test.go create mode 100644 internal/installer/installer.go create mode 100644 internal/report/report.go create mode 100644 internal/syslog/syslog.go delete mode 100644 iptables-helper.sh delete mode 100644 offense-cleanup-worker.sh delete mode 100644 report-generator.sh delete mode 100644 templates/report.html delete mode 100644 templates/report.txt delete mode 100644 unban-expired.sh delete mode 100644 uninstall.sh diff --git a/.gitea/workflows/pr-test.yml b/.gitea/workflows/pr-test.yml new file mode 100644 index 0000000..db88ea3 --- /dev/null +++ b/.gitea/workflows/pr-test.yml @@ -0,0 +1,46 @@ +# AdGuard Shield CI - Pull Request Tests +# Runs on every PR to master: format check, vet, build and tests. +name: PR Tests + +on: + pull_request: + branches: [master] + workflow_dispatch: + +permissions: read-all + +jobs: + test: + name: Format, Vet, Build & Test + runs-on: ubuntu-latest + container: + image: golang:1.26.2-alpine + + steps: + - name: Install build dependencies + run: apk add --no-cache git nodejs + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Go module cache + uses: actions/cache@v4 + with: + path: /go/pkg/mod + key: go-mod-${{ hashFiles('go.sum') }} + + - name: Download dependencies + run: go mod download + + - name: Check formatting + run: | + test -z "$(gofmt -l .)" + + - name: Go vet + run: go vet ./... + + - name: Build Linux binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o /tmp/adguard-shield ./cmd/adguard-shieldd + + - name: Run tests + run: go test ./... -v -count=1 -timeout 120s diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..d61c49b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,110 @@ +# AdGuard Shield CI - Release Binary +# Triggers when a release is published and uploads a Linux amd64 binary asset. +name: Release Binary + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: write + +env: + BINARY_NAME: adguard-shield + PACKAGE_NAME: adguard-shield-linux-amd64 + +jobs: + linux-binary: + name: Build & Upload Linux Binary + runs-on: ubuntu-latest + container: + image: golang:1.26.2-alpine + + steps: + - name: Install build dependencies + run: apk add --no-cache git curl jq tar nodejs + + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve release tag + id: version + run: | + TAG="${{ github.event.release.tag_name }}" + if [ -z "$TAG" ]; then + TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo '')" + fi + if [ -z "$TAG" ]; then + echo "::error::No release tag found. Create a release or tag first." + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Go module cache + uses: actions/cache@v4 + with: + path: /go/pkg/mod + key: go-mod-${{ hashFiles('go.sum') }} + + - name: Download dependencies + run: go mod download + + - name: Verify before release build + run: | + go vet ./... + go test ./... -count=1 -timeout 120s + + - name: Build Linux amd64 binary + run: | + mkdir -p dist + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -trimpath \ + -ldflags="-s -w -X adguard-shield/internal/appinfo.Version=${{ steps.version.outputs.tag }}" \ + -o "dist/${BINARY_NAME}" \ + ./cmd/adguard-shieldd + chmod +x "dist/${BINARY_NAME}" + tar -C dist -czf "dist/${PACKAGE_NAME}.tar.gz" "${BINARY_NAME}" + sha256sum "dist/${PACKAGE_NAME}.tar.gz" > "dist/${PACKAGE_NAME}.tar.gz.sha256" + + - name: Upload artifacts to Gitea release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_REPOSITORY: ${{ github.repository }} + GITEA_SERVER_URL: ${{ github.server_url }} + TAG: ${{ steps.version.outputs.tag }} + run: | + API="${GITEA_SERVER_URL%/}/api/v1" + REPO="${GITEA_REPOSITORY}" + + RELEASE_JSON="$(curl -fsSL \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/repos/${REPO}/releases/tags/${TAG}")" + RELEASE_ID="$(echo "${RELEASE_JSON}" | jq -r '.id')" + if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then + echo "::error::Could not resolve Gitea release id for tag ${TAG}." + exit 1 + fi + + for file in "dist/${PACKAGE_NAME}.tar.gz" "dist/${PACKAGE_NAME}.tar.gz.sha256"; do + name="$(basename "${file}")" + existing_id="$(curl -fsSL \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets" \ + | jq -r --arg name "${name}" '.[] | select(.name == $name) | .id' \ + | head -n 1)" + + if [ -n "${existing_id}" ]; then + curl -fsSL -X DELETE \ + -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets/${existing_id}" + fi + + curl -fsSL -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -F "attachment=@${file}" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${name}" \ + >/dev/null + done diff --git a/.gitea/workflows/security-scan.yml b/.gitea/workflows/security-scan.yml new file mode 100644 index 0000000..31be852 --- /dev/null +++ b/.gitea/workflows/security-scan.yml @@ -0,0 +1,36 @@ +# AdGuard Shield CI - Security Scan +# Checks Go dependencies and reachable code for known vulnerabilities. +name: Security Scan + +on: + pull_request: + branches: [master] + workflow_dispatch: + +permissions: read-all + +jobs: + govulncheck: + name: Go Vulnerability Check + runs-on: ubuntu-latest + container: + image: golang:1.26.2-alpine + + steps: + - name: Install dependencies + run: apk add --no-cache git nodejs + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Go module cache + uses: actions/cache@v4 + with: + path: /go/pkg/mod + key: go-mod-${{ hashFiles('go.sum') }} + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... diff --git a/.gitignore b/.gitignore index dec1284..e0b2a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ .ki-workspace +/adguard-shieldd +/adguard-shieldd.exe +/adguard-shield +/adguard-shield.exe +*.test diff --git a/README.md b/README.md index bcf2a6d..c609314 100644 --- a/README.md +++ b/README.md @@ -36,22 +36,26 @@ Das schützt klassische DNS-Anfragen genauso wie DoH, DoT und DoQ, ohne deine be - Progressive Sperren für Wiederholungstäter, ähnlich wie bei fail2ban - Unterstützung für DNS, DoH, DoT, DoQ und DNSCrypt - IPv4 und IPv6 -- Eigene Firewall-Chain für sauberes Debugging und einfache Entfernung +- Go-Daemon mit einem zentralen Querylog-Poller statt mehrerer Shell-Worker +- Eigene Firewall-Chain mit `ipset`-Sets für schnelle Sperren bei vielen IPs +- Firewall-Modi für klassische Installation, Docker Host Network und Docker mit veröffentlichten Ports - Externe Blocklisten und dynamische externe Whitelists - GeoIP-Länderfilter mit Blocklist- oder Allowlist-Modus - AbuseIPDB-Reporting für permanent gesperrte IPs - Benachrichtigungen über Ntfy, Discord, Slack, Gotify oder Generic Webhook -- E-Mail-Reports als HTML oder Text -- Watchdog mit automatischem Health Check und Recovery +- E-Mail-Reports als HTML oder Text direkt aus dem Go-Binary +- systemd-Service mit Restart-Policy, ohne Shell-Worker ## ✅ Voraussetzungen - Linux-Server mit AdGuard Home - Root-Zugriff per `sudo` - Erreichbare AdGuard Home Web-API, standardmäßig `http://127.0.0.1:3000` -- `curl`, `jq`, `iptables`, `gawk`, `sqlite3` und `systemd` +- `iptables`, `ip6tables`, `ipset` und `systemd` -Die benötigten Pakete werden vom Installer automatisch installiert. +Die benötigten Pakete werden vom Go-Installer auf Ubuntu/Debian automatisch installiert. + +Wichtig: Go wird auf dem Server nicht benötigt, wenn du ein fertiges Linux-Binary installierst. Zum Erzeugen dieses Binarys brauchst du Go aber auf dem Rechner, auf dem du baust, oder alternativ Docker/CI/Release-Artefakte. ## ⚡ Schnellstart @@ -59,18 +63,36 @@ Die benötigten Pakete werden vom Installer automatisch installiert. git clone https://git.techniverse.net/scriptos/adguard-shield.git /tmp/adguard-shield cd /tmp/adguard-shield -# Interaktives Installationsmenü -sudo bash install.sh +# Variante A: fertiges Release-Binary laden +curl -fL -o adguard-shield-linux-amd64.tar.gz \ + https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.0.0/adguard-shield-linux-amd64.tar.gz +tar -xzf adguard-shield-linux-amd64.tar.gz + +# Variante B: Linux-Binary lokal bauen, wenn Go installiert ist +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-shieldd + +# Variante C: ohne lokale Go-Installation per Docker bauen +docker run --rm -v "$PWD":/src -w /src -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=0 golang:1.22 \ + go build -o adguard-shield ./cmd/adguard-shieldd + +# Fertiges Binary auf dem Server installieren +chmod +x ./adguard-shield +sudo ./adguard-shield install +# Der Installer fragt am Ende, ob AdGuard Shield direkt gestartet werden soll. + +# Bestehende Shell-Installation? +# Der Go-Installer bricht ab und meldet die gefundenen Script-Artefakte. +# Die alte Version zuerst deinstallieren und die adguard-shield.conf behalten. # Vor dem produktiven Start testen: loggt nur, sperrt nichts -sudo /opt/adguard-shield/adguard-shield.sh dry-run +sudo /opt/adguard-shield/adguard-shield dry-run -# Service starten und prüfen +# Service starten, falls du die Nachfrage verneint hast, und prüfen sudo systemctl start adguard-shield sudo systemctl status adguard-shield ``` -> Beim Installieren wird der systemd-Service für den Autostart registriert. Der Watchdog-Timer wird ebenfalls eingerichtet und prüft den Service regelmäßig. +> Beim Installieren wird der systemd-Service für den Autostart registriert und am Ende nach dem direkten Start gefragt. Die Go-Version nutzt `Restart=on-failure`; einen separaten Watchdog-Timer wie in der alten Shell-Version gibt es nicht mehr. [![asciicast](https://asciinema.techniverse.net/a/77.svg)](https://asciinema.techniverse.net/a/77) @@ -79,11 +101,10 @@ sudo systemctl status adguard-shield ### Installation & Updates ```bash -sudo bash install.sh # Interaktives Menü -sudo bash install.sh install # Direkt installieren -sudo bash install.sh update # Update inkl. Konfig- & Datenbank-Migration -sudo bash install.sh status # Installationsstatus prüfen -sudo bash /opt/adguard-shield/uninstall.sh +sudo ./adguard-shield install # Go-Binary installieren +sudo ./adguard-shield update # Binary, Service und Config-Migration aktualisieren +sudo ./adguard-shield install-status # Installationsstatus prüfen +sudo /opt/adguard-shield/adguard-shield uninstall --keep-config ``` ### Betrieb & Diagnose @@ -93,23 +114,27 @@ sudo systemctl status adguard-shield sudo systemctl restart adguard-shield sudo journalctl -u adguard-shield -f -sudo /opt/adguard-shield/adguard-shield.sh status -sudo /opt/adguard-shield/adguard-shield.sh history -sudo /opt/adguard-shield/adguard-shield.sh test -sudo /opt/adguard-shield/adguard-shield.sh unban 192.0.2.10 -sudo /opt/adguard-shield/adguard-shield.sh flush +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield live +sudo /opt/adguard-shield/adguard-shield history +sudo /opt/adguard-shield/adguard-shield logs --level warn +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield unban 192.0.2.10 +sudo /opt/adguard-shield/adguard-shield flush ``` +`live` zeigt eine Terminal-Ansicht mit aktuellen Queries, Top-Client/Domain-Zählungen, Subdomain-Flood-Kandidaten, aktiven Sperren und Systemereignissen. Query-Inhalte werden dabei nicht dauerhaft ins Systemlog geschrieben; `logs` und `logs-follow` sind für Daemon-, Worker- und Fehlerereignisse gedacht. + ### Optionale Module ```bash -sudo /opt/adguard-shield/adguard-shield.sh blocklist-status -sudo /opt/adguard-shield/adguard-shield.sh whitelist-status -sudo /opt/adguard-shield/adguard-shield.sh geoip-status +sudo /opt/adguard-shield/adguard-shield blocklist-status +sudo /opt/adguard-shield/adguard-shield whitelist-status +sudo /opt/adguard-shield/adguard-shield geoip-status -sudo /opt/adguard-shield/report-generator.sh status -sudo /opt/adguard-shield/report-generator.sh send -sudo /opt/adguard-shield/report-generator.sh install +sudo /opt/adguard-shield/adguard-shield report-status +sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html +sudo /opt/adguard-shield/adguard-shield report-send ``` Die vollständige Befehlsreferenz steht in [docs/befehle.md](docs/befehle.md). @@ -127,6 +152,7 @@ Wichtige Startpunkte: - `ADGUARD_URL`, `ADGUARD_USER`, `ADGUARD_PASS` für die AdGuard-Home-API - `RATE_LIMIT_MAX_REQUESTS`, `RATE_LIMIT_WINDOW` und `CHECK_INTERVAL` für die Erkennung - `BAN_DURATION` und `PROGRESSIVE_BAN_*` für temporäre und progressive Sperren +- `FIREWALL_MODE` für klassische Installationen, Docker Host Network oder Docker Bridge - `WHITELIST` für vertrauenswürdige Clients wie Router, Management-IPs oder lokale Resolver - `DNS_FLOOD_WATCHLIST_*` für sofortigen Permanent-Ban bei bekannten Flood-Domains - `NOTIFY_*`, `REPORT_*`, `GEOIP_*`, `EXTERNAL_BLOCKLIST_*` und `EXTERNAL_WHITELIST_*` für optionale Funktionen @@ -142,6 +168,7 @@ Mehr Details findest du in [docs/konfiguration.md](docs/konfiguration.md). | Architektur & Funktionsweise | [docs/architektur.md](docs/architektur.md) | | Befehle & Nutzung | [docs/befehle.md](docs/befehle.md) | | Konfiguration | [docs/konfiguration.md](docs/konfiguration.md) | +| Docker-Installationen | [docs/docker.md](docs/docker.md) | | Benachrichtigungen | [docs/benachrichtigungen.md](docs/benachrichtigungen.md) | | E-Mail Report | [docs/report.md](docs/report.md) | | Updates | [docs/update.md](docs/update.md) | @@ -165,4 +192,4 @@ Mehr Details findest du in [docs/konfiguration.md](docs/konfiguration.md). Patrick Asmus · Techniverse Network · Lizenz -

\ No newline at end of file +

diff --git a/adguard-shield-watchdog.service b/adguard-shield-watchdog.service deleted file mode 100644 index 0267685..0000000 --- a/adguard-shield-watchdog.service +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -Description=AdGuard Shield - Watchdog Health Check -Documentation=https://git.techniverse.net/scriptos/adguard-shield - -[Service] -Type=oneshot -ExecStart=/opt/adguard-shield/adguard-shield-watchdog.sh diff --git a/adguard-shield-watchdog.sh b/adguard-shield-watchdog.sh deleted file mode 100644 index 9c3f60e..0000000 --- a/adguard-shield-watchdog.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Watchdog -# Prüft ob der Hauptservice läuft und startet ihn bei Bedarf neu. -# Wird über adguard-shield-watchdog.timer alle 5 Minuten ausgeführt. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -INSTALL_DIR="/opt/adguard-shield" -CONFIG_FILE="${INSTALL_DIR}/adguard-shield.conf" -SERVICE_NAME="adguard-shield.service" -LOG_FILE="/var/log/adguard-shield.log" -WATCHDOG_STATE_FILE="/var/lib/adguard-shield/watchdog.state" - -# ─── Logging ────────────────────────────────────────────────────────────────── -log() { - local level="$1" - shift - local message="$*" - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [WATCHDOG] [$level] $message" - - echo "$log_entry" | tee -a "$LOG_FILE" -} - -# ─── Benachrichtigung senden ────────────────────────────────────────────────── -send_watchdog_notification() { - local action="$1" # "recovery" oder "failure" - local detail="$2" - - # Konfiguration laden für Benachrichtigungs-Einstellungen - if [[ ! -f "$CONFIG_FILE" ]]; then - return - fi - source "$CONFIG_FILE" - - if [[ "${NOTIFY_ENABLED:-false}" != "true" ]]; then - return - fi - - local my_hostname - my_hostname=$(hostname) - local title message - - if [[ "$action" == "recovery" ]]; then - title="🔄 AdGuard Shield Watchdog" - message="🔄 AdGuard Shield Watchdog auf ${my_hostname} ---- -Der Service war ausgefallen und wurde automatisch neu gestartet. -${detail}" - elif [[ "$action" == "failure" ]]; then - title="🚨 AdGuard Shield Watchdog" - message="🚨 AdGuard Shield Watchdog auf ${my_hostname} ---- -Der Service konnte NICHT automatisch neu gestartet werden! -Manuelles Eingreifen erforderlich. -${detail}" - fi - - case "${NOTIFY_TYPE:-}" in - discord) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{content: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null || true - ;; - slack) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{text: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null || true - ;; - gotify) - curl -s -X POST "$NOTIFY_WEBHOOK_URL" \ - -F "title=${title}" \ - -F "message=${message}" \ - -F "priority=5" &>/dev/null || true - ;; - ntfy) - if [[ -n "${NTFY_TOPIC:-}" ]]; then - local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}" - local auth_args=() - if [[ -n "${NTFY_TOKEN:-}" ]]; then - auth_args=(-H "Authorization: Bearer ${NTFY_TOKEN}") - fi - curl -s \ - -H "Title: ${title}" \ - -H "Priority: ${NTFY_PRIORITY:-5}" \ - -H "Tags: warning,watchdog" \ - "${auth_args[@]}" \ - -d "$message" \ - "${ntfy_url}/${NTFY_TOPIC}" &>/dev/null || true - fi - ;; - generic) - local json_payload - json_payload=$(jq -nc --arg msg "$message" --arg act "watchdog_${action}" \ - '{message: $msg, action: $act}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null || true - ;; - esac -} - -# ─── Hauptlogik ────────────────────────────────────────────────────────────── -main() { - # Verzeichnis für State-Datei sicherstellen - mkdir -p "$(dirname "$WATCHDOG_STATE_FILE")" - - # Prüfen ob der Service aktiv ist - if systemctl is-active --quiet "$SERVICE_NAME"; then - # Service läuft – falls vorher ausgefallen war, Status zurücksetzen - if [[ -f "$WATCHDOG_STATE_FILE" ]]; then - rm -f "$WATCHDOG_STATE_FILE" - fi - exit 0 - fi - - # Service läuft NICHT – Recovery versuchen - log "WARN" "Service $SERVICE_NAME ist nicht aktiv – starte Recovery..." - - # Zähler für fehlgeschlagene Recovery-Versuche - local fail_count=0 - if [[ -f "$WATCHDOG_STATE_FILE" ]]; then - fail_count=$(cat "$WATCHDOG_STATE_FILE" 2>/dev/null || echo "0") - fi - - # systemd reset-failed damit StartLimit zurückgesetzt wird - systemctl reset-failed "$SERVICE_NAME" 2>/dev/null || true - - # Service starten - if systemctl start "$SERVICE_NAME" 2>/dev/null; then - # Kurz warten und prüfen ob er auch wirklich läuft - sleep 3 - if systemctl is-active --quiet "$SERVICE_NAME"; then - log "INFO" "Service $SERVICE_NAME erfolgreich neu gestartet (Watchdog Recovery)" - send_watchdog_notification "recovery" "Versuch: $((fail_count + 1))" - rm -f "$WATCHDOG_STATE_FILE" - exit 0 - fi - fi - - # Start fehlgeschlagen - fail_count=$((fail_count + 1)) - echo "$fail_count" > "$WATCHDOG_STATE_FILE" - log "ERROR" "Service $SERVICE_NAME konnte nicht gestartet werden (Fehlversuch: $fail_count)" - - # Bei jedem 3. Fehlversuch eine Benachrichtigung senden (Spam vermeiden) - if [[ $((fail_count % 3)) -eq 1 ]]; then - send_watchdog_notification "failure" "Fehlversuche: $fail_count -Letzter Fehler: $(systemctl status "$SERVICE_NAME" 2>&1 | tail -5)" - fi - - exit 1 -} - -main diff --git a/adguard-shield-watchdog.timer b/adguard-shield-watchdog.timer deleted file mode 100644 index a1d60d1..0000000 --- a/adguard-shield-watchdog.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=AdGuard Shield - Watchdog Timer -Documentation=https://git.techniverse.net/scriptos/adguard-shield - -[Timer] -OnBootSec=2min -OnUnitActiveSec=5min -AccuracySec=30s - -[Install] -WantedBy=timers.target diff --git a/adguard-shield.conf b/adguard-shield.conf index 5266996..ed4a7a4 100644 --- a/adguard-shield.conf +++ b/adguard-shield.conf @@ -26,6 +26,8 @@ DNS_FLOOD_WATCHLIST="" # Kommagetrennt, z.B. "example.com,evil.org" BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden IPTABLES_CHAIN="ADGUARD_SHIELD" BLOCKED_PORTS="53 443 853" # DNS(53), DoH(443), DoT/DoQ(853) +FIREWALL_BACKEND="ipset" # Daemon: ipset + iptables (schneller als Einzelregeln) +FIREWALL_MODE="host" # host/docker-host, docker-bridge oder hybrid # --- Whitelist --- # IPs die niemals gesperrt werden (kommagetrennt) @@ -34,9 +36,6 @@ WHITELIST="127.0.0.1,::1" # --- Logging --- LOG_FILE="/var/log/adguard-shield.log" LOG_LEVEL="INFO" # DEBUG, INFO, WARN, ERROR -LOG_MAX_SIZE_MB=50 # Max. Größe in MB (danach Rotation) -BAN_HISTORY_FILE="/var/log/adguard-shield-bans.log" -BAN_HISTORY_RETENTION_DAYS=0 # 0 = unbegrenzt # --- Benachrichtigungen --- NOTIFY_ENABLED=false @@ -93,11 +92,12 @@ ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack (siehe abuseipdb.com/categ GEOIP_ENABLED=false GEOIP_MODE="blocklist" # blocklist oder allowlist GEOIP_COUNTRIES="" # ISO 3166-1 Alpha-2 Codes, z.B. "CN,RU,KP,IR" -GEOIP_CHECK_INTERVAL=0 # 0 = nutzt CHECK_INTERVAL +GEOIP_CHECK_INTERVAL=0 # Legacy: Daemon nutzt den zentralen CHECK_INTERVAL-Poller GEOIP_NOTIFY=true GEOIP_SKIP_PRIVATE=true # Private IPs ausnehmen GEOIP_LICENSE_KEY="" # MaxMind GeoLite2 Key (optional, für Auto-Download) GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang) +GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden # --- Erweiterte Einstellungen --- STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db diff --git a/adguard-shield.service b/adguard-shield.service index 6f2638f..a37ebd7 100644 --- a/adguard-shield.service +++ b/adguard-shield.service @@ -1,5 +1,5 @@ [Unit] -Description=AdGuard Shield - DNS Rate-Limit Monitor +Description=AdGuard Shield - Go DNS Rate-Limit Monitor Documentation=https://git.techniverse.net/scriptos/adguard-shield After=network.target AdGuardHome.service Wants=AdGuardHome.service @@ -8,8 +8,7 @@ StartLimitIntervalSec=300 [Service] Type=simple -ExecStart=/opt/adguard-shield/adguard-shield.sh start -ExecStop=/opt/adguard-shield/adguard-shield.sh stop +ExecStart=/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run ExecReload=/bin/kill -HUP $MAINPID # Neustart-Verhalten @@ -18,7 +17,7 @@ RestartSec=30 # Sicherheits-Hardening ProtectSystem=full -ReadWritePaths=/var/log /var/lib/adguard-shield /var/lib/adguard-shield/external-blocklist /var/run +ReadWritePaths=/var/log /var/lib/adguard-shield /var/run /opt/adguard-shield/geoip ProtectHome=true NoNewPrivileges=false PrivateTmp=true diff --git a/adguard-shield.sh b/adguard-shield.sh deleted file mode 100644 index 557c737..0000000 --- a/adguard-shield.sh +++ /dev/null @@ -1,1571 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield -# Überwacht DNS-Anfragen und sperrt Clients bei Überschreitung des Limits -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -VERSION="v1.0.0" - -set -euo pipefail - -# Fehler-Trap: Bei unerwartetem Abbruch Fehlerdetails ausgeben -trap 'echo "[$(date "+%Y-%m-%d %H:%M:%S")] [ERROR] Unerwarteter Fehler in Zeile $LINENO (Exit-Code: $?)" >&2' ERR - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" - -# ─── Datenbank-Bibliothek laden ─────────────────────────────────────────────── -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Abhängigkeiten prüfen ──────────────────────────────────────────────────── -check_dependencies() { - local missing=() - for cmd in curl jq iptables ip6tables date sqlite3; do - if ! command -v "$cmd" &>/dev/null; then - missing+=("$cmd") - fi - done - if [[ ${#missing[@]} -gt 0 ]]; then - log "ERROR" "Fehlende Abhängigkeiten: ${missing[*]}" - echo "Bitte installieren: sudo apt install ${missing[*]}" >&2 - exit 1 - fi -} - -# ─── Logging ────────────────────────────────────────────────────────────────── -declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3) - -log() { - local level="$1" - shift - local message="$*" - local configured_level="${LOG_LEVEL:-INFO}" - - if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [$level] $message" - - echo "$log_entry" | tee -a "$LOG_FILE" - - # Log-Rotation prüfen - if [[ -f "$LOG_FILE" ]]; then - local size_kb - size_kb=$(du -k "$LOG_FILE" 2>/dev/null | cut -f1) - local max_kb=$((LOG_MAX_SIZE_MB * 1024)) - if [[ ${size_kb:-0} -gt $max_kb ]]; then - mv "$LOG_FILE" "${LOG_FILE}.old" - log "INFO" "Log-Datei rotiert" - fi - fi - fi -} - -# ─── Ban-History ───────────────────────────────────────────────────────────── -log_ban_history() { - local action="$1" - local client_ip="$2" - local domain="${3:-}" - local count="${4:-}" - local reason="${5:-}" - local duration="${6:-}" - local protocol="${7:-}" - - if [[ -z "$duration" && "$action" == "BAN" ]]; then - duration="${BAN_DURATION}s" - fi - [[ -z "$duration" ]] && duration="-" - [[ -z "$protocol" ]] && protocol="-" - - db_history_add "$action" "$client_ip" "${domain:--}" "${count:--}" "${reason:-rate-limit}" "$duration" "$protocol" -} - -# ─── Progressive Ban (Recidive) ───────────────────────────────────────────── -get_offense_level() { - local client_ip="$1" - local level - level=$(db_offense_get_level "$client_ip" "${PROGRESSIVE_BAN_RESET_AFTER:-86400}") - echo "$level" -} - -increment_offense_level() { - local client_ip="$1" - db_offense_increment "$client_ip" -} - -# Berechnet die Sperrdauer basierend auf der Offense-Stufe -calculate_ban_duration() { - local offense_level="$1" - local base_duration="${BAN_DURATION:-3600}" - local multiplier="${PROGRESSIVE_BAN_MULTIPLIER:-2}" - local max_level="${PROGRESSIVE_BAN_MAX_LEVEL:-5}" - - # Progressive Bans deaktiviert? → Standard-Dauer - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" != "true" ]]; then - echo "$base_duration" - return - fi - - # Permanente Sperre ab max_level (0 = nie permanent) - if [[ "$max_level" -gt 0 && "$offense_level" -ge "$max_level" ]]; then - echo "0" # 0 = permanent - return - fi - - # Exponentielle Steigerung: base × multiplier^(level-1) - # Stufe 1: base × 1, Stufe 2: base × mult, Stufe 3: base × mult², ... - if [[ "$offense_level" -le 1 ]]; then - echo "$base_duration" - else - local power=$((offense_level - 1)) - local factor=1 - for ((i=0; i/dev/null; then - hostname=$(dig +short -x "$ip" 2>/dev/null | head -1 | sed 's/\.$//') - fi - - # Fallback via host - if [[ -z "$hostname" ]] && command -v host &>/dev/null; then - hostname=$(host "$ip" 2>/dev/null | awk '/domain name pointer/ {print $NF}' | sed 's/\.$//' | head -1) - fi - - # Fallback via getent - if [[ -z "$hostname" ]] && command -v getent &>/dev/null; then - hostname=$(getent hosts "$ip" 2>/dev/null | awk '{print $2}' | head -1) - fi - - echo "${hostname:-(unbekannt)}" -} - -# ─── 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}" - local window="${5:-$RATE_LIMIT_WINDOW}" - - 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} in ${window}s (random subdomain attack). Banned by Adguard Shield 🔗 https://tnvs.de/as" - else - comment="DNS flooding on our DNS server: ${count}x ${domain} in ${window}s. Banned by Adguard Shield 🔗 https://tnvs.de/as" - 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 und Datenbank erstellen ─────────────────────────────────── -init_directories() { - mkdir -p "$STATE_DIR" - mkdir -p "$(dirname "$LOG_FILE")" - mkdir -p "$(dirname "$PID_FILE")" - - db_init - - # Migration von Flat-Files (einmalig beim ersten Start nach Update) - if [[ ! -f "$_DB_MIGRATION_MARKER" ]]; then - local migrated - migrated=$(db_migrate_from_files) - if [[ "${migrated:-0}" -gt 0 ]]; then - log "INFO" "SQLite-Migration abgeschlossen: $migrated Eintraege migriert" - log "INFO" "Backup der alten Dateien: ${STATE_DIR}/.backup_pre_sqlite/" - fi - fi -} - -# ─── PID-Management ────────────────────────────────────────────────────────── -write_pid() { - echo $$ > "$PID_FILE" -} - -cleanup() { - log "INFO" "AdGuard Shield wird beendet..." - # Service-Stop-Benachrichtigung senden - if [[ "${NOTIFY_ENABLED:-false}" == "true" ]]; then - send_notification "service_stop" "" "" "" - # Kurz warten damit die Benachrichtigung gesendet wird (curl läuft im Hintergrund) - sleep 1 - fi - stop_blocklist_worker - stop_whitelist_worker - stop_geoip_worker - stop_offense_cleanup_worker - rm -f "$PID_FILE" - exit 0 -} - -check_already_running() { - if [[ -f "$PID_FILE" ]]; then - local old_pid - old_pid=$(cat "$PID_FILE") - if kill -0 "$old_pid" 2>/dev/null; then - echo "Monitor läuft bereits (PID: $old_pid). Beende." >&2 - exit 1 - else - rm -f "$PID_FILE" - fi - fi -} - -# ─── Whitelist Prüfung ─────────────────────────────────────────────────────── -is_whitelisted() { - local ip="$1" - IFS=',' read -ra wl_entries <<< "$WHITELIST" - for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) - if [[ "$ip" == "$entry" ]]; then - return 0 - fi - done - - # Externe Whitelist prüfen (SQLite) - if db_whitelist_contains "$ip"; then - return 0 - fi - - return 1 -} - -# ─── DNS-Flood-Watchlist Prüfung ──────────────────────────────────────────── -is_dns_flood_watchlist_match() { - local domain="$1" - - if [[ "${DNS_FLOOD_WATCHLIST_ENABLED:-false}" != "true" ]]; then - return 1 - fi - - if [[ -z "${DNS_FLOOD_WATCHLIST:-}" ]]; then - return 1 - fi - - local entry - IFS=',' read -ra watchlist_entries <<< "$DNS_FLOOD_WATCHLIST" - for entry in "${watchlist_entries[@]}"; do - entry=$(echo "$entry" | xargs) - [[ -z "$entry" ]] && continue - - if [[ "$domain" == "$entry" || "$domain" == *".$entry" ]]; then - return 0 - fi - done - - return 1 -} - -# ─── iptables Chain Setup ──────────────────────────────────────────────────── -setup_iptables_chain() { - # IPv4 Chain erstellen falls nicht vorhanden - if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - log "INFO" "Erstelle iptables Chain: $IPTABLES_CHAIN (IPv4)" - iptables -N "$IPTABLES_CHAIN" - - # Chain in INPUT einhängen für alle relevanten Ports - for port in $BLOCKED_PORTS; do - iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi - - # IPv6 Chain erstellen falls nicht vorhanden - if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - log "INFO" "Erstelle ip6tables Chain: $IPTABLES_CHAIN (IPv6)" - ip6tables -N "$IPTABLES_CHAIN" - - for port in $BLOCKED_PORTS; do - ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi -} - -# ─── Client sperren ───────────────────────────────────────────────────────── -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 - if db_ban_exists "$client_ip"; then - log "DEBUG" "Client $client_ip ist bereits gesperrt" - return 0 - fi - - # Progressive Ban: Offense-Level ermitteln und Sperrdauer berechnen - local offense_level=0 - local effective_duration="$BAN_DURATION" - local is_permanent=false - - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - offense_level=$(increment_offense_level "$client_ip") - effective_duration=$(calculate_ban_duration "$offense_level") - - if [[ "$effective_duration" -eq 0 ]]; then - is_permanent=true - fi - fi - - # DNS-Flood-Watchlist: Sofort permanent sperren - if [[ "$reason" == "dns-flood-watchlist" ]]; then - is_permanent=true - effective_duration=0 - log "WARN" "DNS-Flood-Watchlist: Erzwinge permanente Sperre für $client_ip ($domain)" - fi - - local ban_until - local ban_until_display - if [[ "$is_permanent" == "true" ]]; then - ban_until=0 # 0 = permanent - ban_until_display="PERMANENT" - else - ban_until=$(date -d "+${effective_duration} seconds" '+%s' 2>/dev/null || date -v "+${effective_duration}S" '+%s') - ban_until_display=$(date -d "@$ban_until" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -r "$ban_until" '+%Y-%m-%d %H:%M:%S') - fi - - local duration_display - duration_display=$(format_duration "$effective_duration") - - if [[ "$DRY_RUN" == "true" ]]; then - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && "$reason" != "dns-flood-watchlist" ]]; then - 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 ${window}s via $protocol) für ${duration_display} [${reason}]" - fi - log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run (${reason})" "${duration_display}" "$protocol" - return 0 - fi - - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && "$reason" != "dns-flood-watchlist" ]]; then - 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 ${window}s via $protocol) für ${duration_display} [${reason}]" - fi - - # IPv4 oder IPv6 erkennen - if [[ "$client_ip" == *:* ]]; then - # IPv6 - ip6tables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - # IPv4 - iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - # State in Datenbank speichern - local perm_int=0 - [[ "$is_permanent" == "true" ]] && perm_int=1 - db_ban_insert "$client_ip" "$domain" "$count" "$(date '+%Y-%m-%d %H:%M:%S')" "$ban_until" "$effective_duration" "$offense_level" "$perm_int" "$reason" "$protocol" "monitor" - - # Ban-History Eintrag - local history_duration="${duration_display}" - [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && "$reason" != "dns-flood-watchlist" ]] && history_duration="${duration_display} (Stufe ${offense_level})" - 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" "$reason" "$window" "$protocol" "$is_permanent" - fi - - # AbuseIPDB Report (nur bei permanenter Sperre) - if [[ "$is_permanent" == "true" ]]; then - report_to_abuseipdb "$client_ip" "$domain" "$count" "$reason" "$window" & - fi -} - -# ─── Client entsperren ────────────────────────────────────────────────────── -unban_client() { - local client_ip="$1" - local reason="${2:-expired}" - - # Domain und Protokoll aus DB lesen bevor wir loeschen - local ban_data - ban_data=$(db_ban_get "$client_ip") - local domain="-" - local protocol="-" - if [[ -n "$ban_data" ]]; then - IFS='|' read -r _ b_domain _ _ _ _ _ _ _ b_protocol _ _ _ <<< "$ban_data" - domain="${b_domain:--}" - protocol="${b_protocol:--}" - fi - - log "INFO" "ENTSPERRE Client: $client_ip ($reason)" - - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - db_ban_delete "$client_ip" - - log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason" "-" "$protocol" - - if [[ "$NOTIFY_ENABLED" == "true" ]]; then - send_notification "unban" "$client_ip" "$domain" "" - fi -} - -# ─── Abgelaufene Sperren aufheben ─────────────────────────────────────────── -check_expired_bans() { - local expired_ips - expired_ips=$(db_ban_get_expired) - [[ -z "$expired_ips" ]] && return - - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - unban_client "$client_ip" "expired" - done <<< "$expired_ips" -} - -# ─── Benachrichtigungen ───────────────────────────────────────────────────── -send_notification() { - local action="$1" - local client_ip="$2" - local domain="$3" - 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}" - local is_permanent="${10:-false}" - - # 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" - [[ "$reason" == "dns-flood-watchlist" ]] && reason_label="DNS-Flood-Watchlist" - - local title - local message - local my_hostname - my_hostname=$(hostname) - - if [[ "$action" == "ban" ]]; then - title="🚨 🛡️ AdGuard Shield" - local client_hostname - client_hostname=$(resolve_hostname "$client_ip") - - # AbuseIPDB-Hinweis bei permanenter Sperre - local abuseipdb_hint="" - if [[ "$is_permanent" == "true" && "${ABUSEIPDB_ENABLED:-false}" == "true" ]]; then - abuseipdb_hint=$'\n⚠️ IP wurde an AbuseIPDB gemeldet' - fi - - # Dauer-Anzeige mit Stufe (nicht bei Watchlist – dort ist es immer permanent) - local dur_line - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -n "$offense_level" && "$reason" != "dns-flood-watchlist" ]]; then - dur_line="**${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]" - else - dur_line="**${duration_display}**" - fi - - message="🚫 AdGuard Shield Ban auf ${my_hostname}${abuseipdb_hint} ---- -IP: ${client_ip} -Hostname: ${client_hostname} -Grund: ${count}x ${domain} in ${window}s via ${protocol}, ${reason_label} -Dauer: ${dur_line} - -Whois: https://www.whois.com/whois/${client_ip} -AbuseIPDB: https://www.abuseipdb.com/check/${client_ip}" - - elif [[ "$action" == "unban" ]]; then - title="✅ AdGuard Shield" - local client_hostname - client_hostname=$(resolve_hostname "$client_ip") - - message="✅ AdGuard Shield Freigabe auf ${my_hostname} ---- -IP: ${client_ip} -Hostname: ${client_hostname} - -AbuseIPDB: https://www.abuseipdb.com/check/${client_ip}" - - elif [[ "$action" == "service_start" ]]; then - title="✅ AdGuard Shield" - message="🟢 AdGuard Shield ${VERSION} wurde auf ${my_hostname} gestartet." - elif [[ "$action" == "service_stop" ]]; then - title="🚨 🛡️ AdGuard Shield" - message="🔴 AdGuard Shield ${VERSION} wurde auf ${my_hostname} gestoppt." - fi - - case "$NOTIFY_TYPE" in - discord) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{content: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - slack) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{text: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - gotify) - local clean_message - clean_message=$(echo "$message" | sed 's/\*\*//g') - curl -s -X POST "$NOTIFY_WEBHOOK_URL" \ - -F "title=${title}" \ - -F "message=${clean_message}" \ - -F "priority=5" &>/dev/null & - ;; - ntfy) - send_ntfy_notification "$action" "$title" "$message" - ;; - generic) - local json_payload - json_payload=$(jq -nc --arg msg "$message" --arg act "$action" --arg cl "${client_ip:-}" --arg dom "${domain:-}" \ - '{message: $msg, action: $act, client: $cl, domain: $dom}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - esac -} - -# ─── Ntfy Benachrichtigung ─────────────────────────────────────────────────── -send_ntfy_notification() { - local action="$1" - local title="$2" - local message="$3" - - if [[ -z "${NTFY_TOPIC:-}" ]]; then - log "WARN" "Ntfy: Kein Topic konfiguriert (NTFY_TOPIC ist leer)" - return 1 - fi - - local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}" - local priority="${NTFY_PRIORITY:-4}" - local tags - - if [[ "$action" == "ban" ]]; then - tags="rotating_light,ban" - elif [[ "$action" == "service_start" ]]; then - tags="green_circle,start" - elif [[ "$action" == "service_stop" ]]; then - tags="red_circle,stop" - else - tags="white_check_mark,unban" - fi - - # Markdown-Formatierung entfernen für Ntfy - local clean_message - clean_message=$(echo "$message" | sed 's/\*\*//g') - - # Ntfy fügt Emojis über Tags hinzu → Titel ohne führende Emojis setzen - local ntfy_title - case "$action" in - ban) ntfy_title="🛡️ AdGuard Shield" ;; - *) ntfy_title="AdGuard Shield" ;; - esac - - local -a curl_args=( - -s - -X POST - "${ntfy_url}/${NTFY_TOPIC}" - -H "Title: ${ntfy_title}" - -H "Priority: ${priority}" - -H "Tags: ${tags}" - -d "${clean_message}" - ) - - # Token hinzufügen falls konfiguriert - if [[ -n "${NTFY_TOKEN:-}" ]]; then - curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}") - fi - - curl "${curl_args[@]}" &>/dev/null & -} - -# ─── AdGuard Home API abfragen ────────────────────────────────────────────── -query_adguard_log() { - # Hinweis: Zeitfilterung erfolgt client-seitig in analyze_queries(), - # da die AdGuard API keinen "newer_than" Parameter unterstützt. - - local response - response=$(curl -s -u "${ADGUARD_USER}:${ADGUARD_PASS}" \ - --connect-timeout 5 \ - --max-time 10 \ - "${ADGUARD_URL}/control/querylog?limit=${API_QUERY_LIMIT}&response_status=all" 2>/dev/null) - - if [[ -z "$response" || "$response" == "null" ]]; then - log "ERROR" "Keine Antwort von AdGuard Home API" - return 1 - fi - - # Prüfen ob die Antwort gültiges JSON ist - if ! echo "$response" | jq . &>/dev/null; then - log "ERROR" "Ungültige API-Antwort (kein JSON)" - return 1 - fi - - echo "$response" -} - -# ─── Anfragen analysieren ─────────────────────────────────────────────────── -analyze_queries() { - local api_response="$1" - local now_epoch - now_epoch=$(date '+%s') - local window_start=$((now_epoch - RATE_LIMIT_WINDOW)) - - # Anzahl der API-Einträge loggen - local entry_count - 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 + 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 - # Unterstützt: "2026-03-03T20:01:48Z", "2026-03-03T20:01:48.123Z", - # "2026-03-03T20:01:48+01:00", "2026-03-03T20:01:48.123+01:00" - def to_epoch: - sub("\\.[0-9]+(?=[+-Z])"; "") | - if endswith("Z") then - fromdateiso8601 - elif test("[+-][0-9]{2}:[0-9]{2}$") then - # Zeitzonen-Offset per String-Slicing extrahieren (zuverlässiger als Regex) - # Letzten 6 Zeichen = "+01:00" bzw. "-05:00" - (.[:-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) | - { - client: (.client // .client_info.ip // "unknown"), - domain: ((.question.name // .question.host // "unknown") | rtrimstr(".")), - proto: (.client_proto // "") - } - ] | - group_by(.client + "|" + .domain) | - map({ - client: .[0].client, - domain: .[0].domain, - count: length, - protocols: ([.[].proto | if . == "" then "dns" else . end] | unique | join(",")) - }) | - .[] | - select(.count > 0) | - "\(.client)|\(.domain)|\(.count)|\(.protocols)" - ') || { - log "ERROR" "jq Analyse fehlgeschlagen - API-Antwort-Format prüfen (ist AdGuard Home erreichbar?)" - return - } - - if [[ -z "$violations" ]]; then - log "INFO" "Keine Anfragen im Zeitfenster gefunden" - return - fi - - # Prüfe jede Kombination gegen das Limit - while IFS='|' read -r client domain count protocols; do - [[ -z "$client" || -z "$domain" || -z "$count" ]] && continue - - # 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 via $proto_display)" - continue - fi - - local ban_reason="rate-limit" - if is_dns_flood_watchlist_match "$domain"; then - ban_reason="dns-flood-watchlist" - log "WARN" "DNS-Flood-Watchlist Treffer: $client → $domain (${count}x in ${RATE_LIMIT_WINDOW}s) → permanenter Ban + AbuseIPDB" - fi - - ban_client "$client" "$domain" "$count" "$ban_reason" "$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 - if db_ban_exists "$client"; then - log "DEBUG" "Client $client ist bereits gesperrt (Subdomain-Flood übersprungen)" - continue - fi - - local ban_reason="subdomain-flood" - if is_dns_flood_watchlist_match "$base_domain"; then - ban_reason="dns-flood-watchlist" - log "WARN" "DNS-Flood-Watchlist Treffer (Subdomain-Flood): $client → *.${base_domain} → permanenter Ban + AbuseIPDB" - fi - - ban_client "$client" "*.${base_domain}" "$unique_count" "$ban_reason" "$window" "$proto_display" - done <<< "$violations" -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " AdGuard Shield - Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - # Progressive Ban Info - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - echo " 📈 Progressive Sperren: AKTIV" - echo " Multiplikator: ×${PROGRESSIVE_BAN_MULTIPLIER:-2}" - echo " Max-Stufe: ${PROGRESSIVE_BAN_MAX_LEVEL:-0} (0=kein Limit)" - echo " Zähler-Reset: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}") ohne Vergehen" - 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 - - # DNS-Flood-Watchlist Info - if [[ "${DNS_FLOOD_WATCHLIST_ENABLED:-false}" == "true" ]]; then - echo " 🎯 DNS-Flood-Watchlist: AKTIV" - echo " Domains: ${DNS_FLOOD_WATCHLIST:-}" - echo " Aktion: Sofort permanenter Ban + AbuseIPDB-Meldung" - echo "" - fi - - # GeoIP-Filter Info - if [[ "${GEOIP_ENABLED:-false}" == "true" ]]; then - local geoip_mode_label - [[ "${GEOIP_MODE:-blocklist}" == "blocklist" ]] && geoip_mode_label="Blocklist" || geoip_mode_label="Allowlist" - echo " 🌍 GeoIP-Filter: AKTIV" - echo " Modus: ${geoip_mode_label}" - echo " Länder: ${GEOIP_COUNTRIES:-}" - echo " Sperrdauer: PERMANENT (Auto-Unban bei Änderung der Länderliste)" - echo "" - fi - - # Aktive Sperren aus Datenbank - local ban_count=0 - local all_bans - all_bans=$(db_ban_get_all) - - if [[ -n "$all_bans" ]]; then - while IFS='|' read -r s_ip s_domain s_count s_ban_time s_ban_until_epoch s_dur s_level s_perm_int s_reason s_proto s_source s_geoip_country s_geoip_mode; do - [[ -z "$s_ip" ]] && continue - ban_count=$((ban_count + 1)) - s_reason="${s_reason:-rate-limit}" - s_proto="${s_proto:-?}" - - local reason_tag="" - [[ "$s_reason" == "subdomain-flood" ]] && reason_tag=" (Subdomain-Flood)" - [[ "$s_reason" == "dns-flood-watchlist" ]] && reason_tag=" (DNS-Flood-Watchlist)" - - local count_info="" - if [[ -n "$s_count" && "$s_count" != "0" && "$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}" - - local s_until_display - if [[ "$s_ban_until_epoch" == "0" || "$s_perm_int" == "1" ]]; then - s_until_display="PERMANENT" - else - s_until_display=$(date -d "@$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -r "$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?") - fi - - if [[ "$s_perm_int" == "1" && "$s_reason" == "dns-flood-watchlist" ]]; then - echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT${count_info}${proto_tag}]${reason_tag}" - elif [[ "$s_perm_int" == "1" ]]; then - 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_display${count_info}${proto_tag}]${reason_tag}" - else - echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until_display${count_info}${proto_tag}]${reason_tag}" - fi - done <<< "$all_bans" - fi - - echo "" - if [[ $ban_count -eq 0 ]]; then - echo " ✅ Keine aktiven Sperren" - else - echo " Gesamt: $ban_count aktive Sperren" - fi - - # Offense-Informationen anzeigen (Wiederholungstäter) - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - local offense_data - offense_data=$(db_offense_get_all) - local offense_count=0 - local offense_output="" - - if [[ -n "$offense_data" ]]; then - while IFS='|' read -r o_ip o_level o_last_epoch o_last o_first; do - [[ -z "$o_ip" ]] && continue - offense_count=$((offense_count + 1)) - local next_dur - next_dur=$(calculate_ban_duration "$((o_level + 1))") - if [[ "$next_dur" -eq 0 ]]; then - offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: PERMANENT\n" - else - offense_output+=" ⚠ $o_ip: Stufe $o_level (letztes Vergehen: $o_last) → nächste Sperre: $(format_duration "$next_dur")\n" - fi - done <<< "$offense_data" - fi - - if [[ $offense_count -gt 0 ]]; then - echo "" - echo " 📋 Wiederholungstäter ($offense_count IPs mit Vorgeschichte):" - echo -e "$offense_output" - fi - fi - - echo "" - - # iptables Regeln anzeigen - echo " iptables Regeln ($IPTABLES_CHAIN):" - if iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - iptables -n -L "$IPTABLES_CHAIN" --line-numbers 2>/dev/null | sed 's/^/ /' - else - echo " Chain existiert noch nicht" - fi - - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Ban-History anzeigen ──────────────────────────────────────────────────── -show_history() { - local lines="${1:-50}" - echo "═══════════════════════════════════════════════════════════════" - echo " AdGuard Shield - Ban History (letzte $lines Einträge)" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - local total - total=$(db_history_count) - - if [[ "${total:-0}" -eq 0 ]]; then - echo " Noch keine History vorhanden." - echo "" - return - fi - - echo " # Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" - echo " #──────────────────────────────────────────────────────────────────────────────────────────────────" - echo "" - - local recent - recent=$(db_history_get_recent "$lines") - if [[ -n "$recent" ]]; then - while IFS='|' read -r ts action ip domain count duration protocol reason; do - printf " %-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \ - "$ts" "$action" "$ip" "$domain" "$count" "$duration" "$protocol" "$reason" - done <<< "$recent" - fi - - echo "" - local bans unbans - bans=$(db_history_count_by_action "BAN") - unbans=$(db_history_count_by_action "UNBAN") - echo " Gesamt: $total Einträge ($bans Sperren, $unbans Entsperrungen)" - echo " Datenbank: $DB_FILE" - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Alle Sperren aufheben ────────────────────────────────────────────────── -flush_all_bans() { - log "INFO" "Alle Sperren werden aufgehoben..." - - local all_ips - all_ips=$(db_query "SELECT client_ip FROM active_bans;") - if [[ -n "$all_ips" ]]; then - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - unban_client "$client_ip" "manual-flush" - done <<< "$all_ips" - fi - - # Chain leeren - iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true - ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null || true - - log "INFO" "Alle Sperren aufgehoben" -} - -# ─── Alle Offense-Zähler zurücksetzen ──────────────────────────────────────── -flush_all_offenses() { - local count - count=$(db_offense_delete_all) - log "INFO" "$count Offense-Zähler zurückgesetzt" - echo "$count Offense-Zähler zurückgesetzt" -} - -# ─── Externer Blocklist-Worker starten ─────────────────────────────────────── -start_blocklist_worker() { - if [[ "${EXTERNAL_BLOCKLIST_ENABLED:-false}" != "true" ]]; then - log "DEBUG" "Externer Blocklist-Worker ist deaktiviert" - return - fi - - local worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh" - if [[ ! -f "$worker_script" ]]; then - log "WARN" "Blocklist-Worker Script nicht gefunden: $worker_script" - return - fi - - log "INFO" "Starte externen Blocklist-Worker im Hintergrund..." - bash "$worker_script" start & - BLOCKLIST_WORKER_PID=$! - log "INFO" "Blocklist-Worker gestartet (PID: $BLOCKLIST_WORKER_PID)" -} - -# ─── Externer Blocklist-Worker stoppen ─────────────────────────────────────── -stop_blocklist_worker() { - local worker_pid_file="/var/run/adguard-blocklist-worker.pid" - if [[ -f "$worker_pid_file" ]]; then - local wpid - wpid=$(cat "$worker_pid_file") - if kill -0 "$wpid" 2>/dev/null; then - log "INFO" "Stoppe Blocklist-Worker (PID: $wpid)..." - kill "$wpid" 2>/dev/null || true - rm -f "$worker_pid_file" - fi - fi -} - -# ─── Externer Whitelist-Worker starten ─────────────────────────────────────── -start_whitelist_worker() { - if [[ "${EXTERNAL_WHITELIST_ENABLED:-false}" != "true" ]]; then - log "DEBUG" "Externer Whitelist-Worker ist deaktiviert" - return - fi - - local worker_script="${SCRIPT_DIR}/external-whitelist-worker.sh" - if [[ ! -f "$worker_script" ]]; then - log "WARN" "Whitelist-Worker Script nicht gefunden: $worker_script" - return - fi - - log "INFO" "Starte externen Whitelist-Worker im Hintergrund..." - bash "$worker_script" start & - WHITELIST_WORKER_PID=$! - log "INFO" "Whitelist-Worker gestartet (PID: $WHITELIST_WORKER_PID)" -} - -# ─── Externer Whitelist-Worker stoppen ─────────────────────────────────────── -stop_whitelist_worker() { - local worker_pid_file="/var/run/adguard-whitelist-worker.pid" - if [[ -f "$worker_pid_file" ]]; then - local wpid - wpid=$(cat "$worker_pid_file") - if kill -0 "$wpid" 2>/dev/null; then - log "INFO" "Stoppe Whitelist-Worker (PID: $wpid)..." - kill "$wpid" 2>/dev/null || true - rm -f "$worker_pid_file" - fi - fi -} - -# ─── GeoIP-Worker starten ──────────────────────────────────────────────────── -start_geoip_worker() { - if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then - log "DEBUG" "GeoIP-Worker ist deaktiviert" - return - fi - - local worker_script="${SCRIPT_DIR}/geoip-worker.sh" - if [[ ! -f "$worker_script" ]]; then - log "WARN" "GeoIP-Worker Script nicht gefunden: $worker_script" - return - fi - - log "INFO" "Starte GeoIP-Worker im Hintergrund..." - bash "$worker_script" start & - GEOIP_WORKER_PID=$! - log "INFO" "GeoIP-Worker gestartet (PID: $GEOIP_WORKER_PID)" -} - -# ─── GeoIP-Worker stoppen ──────────────────────────────────────────────────── -stop_geoip_worker() { - local worker_pid_file="/var/run/adguard-geoip-worker.pid" - if [[ -f "$worker_pid_file" ]]; then - local wpid - wpid=$(cat "$worker_pid_file") - if kill -0 "$wpid" 2>/dev/null; then - log "INFO" "Stoppe GeoIP-Worker (PID: $wpid)..." - kill "$wpid" 2>/dev/null || true - rm -f "$worker_pid_file" - fi - fi -} - -# ─── Offense-Cleanup-Worker starten ────────────────────────────────────────── -start_offense_cleanup_worker() { - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" != "true" ]]; then - log "DEBUG" "Offense-Cleanup-Worker ist deaktiviert (Progressive Sperren inaktiv)" - return - fi - - local worker_script="${SCRIPT_DIR}/offense-cleanup-worker.sh" - if [[ ! -f "$worker_script" ]]; then - log "WARN" "Offense-Cleanup-Worker Script nicht gefunden: $worker_script" - return - fi - - log "INFO" "Starte Offense-Cleanup-Worker im Hintergrund (nice 19, idle I/O)..." - nice -n 19 ionice -c 3 bash "$worker_script" start & - OFFENSE_CLEANUP_WORKER_PID=$! - log "INFO" "Offense-Cleanup-Worker gestartet (PID: $OFFENSE_CLEANUP_WORKER_PID)" -} - -# ─── Offense-Cleanup-Worker stoppen ────────────────────────────────────────── -stop_offense_cleanup_worker() { - local worker_pid_file="/var/run/adguard-offense-cleanup-worker.pid" - if [[ -f "$worker_pid_file" ]]; then - local wpid - wpid=$(cat "$worker_pid_file") - if kill -0 "$wpid" 2>/dev/null; then - log "INFO" "Stoppe Offense-Cleanup-Worker (PID: $wpid)..." - kill "$wpid" 2>/dev/null || true - rm -f "$worker_pid_file" - fi - fi -} - -# ─── Hauptschleife ────────────────────────────────────────────────────────── -main_loop() { - log "INFO" "═══════════════════════════════════════════════════════════" - log "INFO" "AdGuard Shield ${VERSION} gestartet" - log "INFO" " Limit: ${RATE_LIMIT_MAX_REQUESTS} Anfragen pro ${RATE_LIMIT_WINDOW}s" - log "INFO" " Sperrdauer: $(format_duration "${BAN_DURATION}")" - log "INFO" " Prüfintervall: ${CHECK_INTERVAL}s" - log "INFO" " Dry-Run: ${DRY_RUN}" - log "INFO" " Whitelist: ${WHITELIST}" - log "INFO" " Externe Blocklist: ${EXTERNAL_BLOCKLIST_ENABLED:-false}" - log "INFO" " Externe Whitelist: ${EXTERNAL_WHITELIST_ENABLED:-false}" - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "INFO" " Progressive Sperren: AKTIV (×${PROGRESSIVE_BAN_MULTIPLIER:-2}, Max-Stufe: ${PROGRESSIVE_BAN_MAX_LEVEL:-0}, Reset: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}"))" - 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 [[ "${DNS_FLOOD_WATCHLIST_ENABLED:-false}" == "true" ]]; then - log "INFO" " DNS-Flood-Watchlist: AKTIV (Domains: ${DNS_FLOOD_WATCHLIST:-})" - else - log "INFO" " DNS-Flood-Watchlist: deaktiviert" - fi - if [[ "${ABUSEIPDB_ENABLED:-false}" == "true" ]]; then - log "INFO" " AbuseIPDB Reporting: AKTIV (Kategorien: ${ABUSEIPDB_CATEGORIES:-4})" - else - log "INFO" " AbuseIPDB Reporting: deaktiviert" - fi - if [[ "${GEOIP_ENABLED:-false}" == "true" ]]; then - log "INFO" " GeoIP-Filter: AKTIV (Modus: ${GEOIP_MODE:-blocklist}, Länder: ${GEOIP_COUNTRIES:-})" - else - log "INFO" " GeoIP-Filter: deaktiviert" - fi - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "INFO" " Offense-Cleanup: AKTIV (Reset: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}"), Prüfintervall: 1h)" - fi - log "INFO" "═══════════════════════════════════════════════════════════" - - # Service-Start-Benachrichtigung senden - if [[ "${NOTIFY_ENABLED:-false}" == "true" ]]; then - send_notification "service_start" "" "" "" - fi - - # Blocklist-Worker als Hintergrundprozess starten - start_blocklist_worker - - # Whitelist-Worker als Hintergrundprozess starten - start_whitelist_worker - - # GeoIP-Worker als Hintergrundprozess starten - start_geoip_worker - - # Offense-Cleanup-Worker als Hintergrundprozess starten - start_offense_cleanup_worker - - while true; do - # Abgelaufene Sperren prüfen - check_expired_bans - - # API abfragen - local api_response - if api_response=$(query_adguard_log); then - analyze_queries "$api_response" - analyze_subdomain_flood "$api_response" - fi - - sleep "$CHECK_INTERVAL" - done -} - -# ─── Signal-Handler ────────────────────────────────────────────────────────── -trap cleanup SIGTERM SIGINT SIGHUP - -# ─── Kommandozeilen-Argumente ──────────────────────────────────────────────── -case "${1:-start}" in - start) - echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] AdGuard Shield ${VERSION} wird gestartet..." - check_dependencies - check_already_running - init_directories - write_pid - setup_iptables_chain - main_loop - ;; - stop) - if [[ -f "$PID_FILE" ]]; then - kill "$(cat "$PID_FILE")" 2>/dev/null || true - rm -f "$PID_FILE" - echo "Monitor gestoppt" - else - echo "Monitor läuft nicht" - fi - ;; - blocklist-status) - init_directories - _worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" status - else - echo "Blocklist-Worker nicht gefunden" - fi - ;; - blocklist-sync) - init_directories - setup_iptables_chain - _worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" sync - else - echo "Blocklist-Worker nicht gefunden" - fi - ;; - blocklist-flush) - init_directories - _worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" flush - else - echo "Blocklist-Worker nicht gefunden" - fi - ;; - whitelist-status) - init_directories - _worker_script="${SCRIPT_DIR}/external-whitelist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" status - else - echo "Whitelist-Worker nicht gefunden" - fi - ;; - whitelist-sync) - init_directories - _worker_script="${SCRIPT_DIR}/external-whitelist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" sync - else - echo "Whitelist-Worker nicht gefunden" - fi - ;; - whitelist-flush) - init_directories - _worker_script="${SCRIPT_DIR}/external-whitelist-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" flush - else - echo "Whitelist-Worker nicht gefunden" - fi - ;; - geoip-status) - init_directories - _worker_script="${SCRIPT_DIR}/geoip-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" status - else - echo "GeoIP-Worker nicht gefunden" - fi - ;; - geoip-sync) - init_directories - setup_iptables_chain - _worker_script="${SCRIPT_DIR}/geoip-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" sync - else - echo "GeoIP-Worker nicht gefunden" - fi - ;; - geoip-flush) - init_directories - _worker_script="${SCRIPT_DIR}/geoip-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" flush - else - echo "GeoIP-Worker nicht gefunden" - fi - ;; - geoip-lookup) - if [[ -z "${2:-}" ]]; then - echo "Nutzung: $0 geoip-lookup " >&2 - exit 1 - fi - init_directories - _worker_script="${SCRIPT_DIR}/geoip-worker.sh" - if [[ -f "$_worker_script" ]]; then - bash "$_worker_script" lookup "$2" - else - echo "GeoIP-Worker nicht gefunden" - fi - ;; - status) - init_directories - show_status - ;; - flush) - init_directories - setup_iptables_chain - flush_all_bans - echo "Alle Sperren aufgehoben" - ;; - reset-offenses) - init_directories - if [[ -n "${2:-}" ]]; then - reset_offense_level "$2" - echo "Offense-Zähler für $2 zurückgesetzt" - else - flush_all_offenses - fi - ;; - unban) - if [[ -z "${2:-}" ]]; then - echo "Nutzung: $0 unban " >&2 - exit 1 - fi - init_directories - unban_client "$2" "manual" - echo "Client $2 entsperrt" - ;; - test) - echo "Teste Verbindung zur AdGuard Home API..." - check_dependencies - init_directories - if response=$(query_adguard_log); then - entry_count=$(echo "$response" | jq '.data | length' 2>/dev/null || echo "0") - echo "✅ Verbindung erfolgreich! $entry_count Log-Einträge gefunden." - else - echo "❌ Verbindung fehlgeschlagen! Prüfe URL und Zugangsdaten in $CONFIG_FILE" - exit 1 - fi - ;; - history) - init_directories - show_history "${2:-50}" - ;; - dry-run) - DRY_RUN=true - check_dependencies - check_already_running - init_directories - write_pid - setup_iptables_chain - main_loop - ;; - *) - cat << USAGE -AdGuard Shield ${VERSION} - -Service-Steuerung (empfohlen): - sudo systemctl start adguard-shield - sudo systemctl stop adguard-shield - sudo systemctl restart adguard-shield - sudo systemctl status adguard-shield - -Nutzung: $0 {status|history|flush|unban|reset-offenses|test|dry-run|blocklist-status|blocklist-sync|blocklist-flush|whitelist-status|whitelist-sync|whitelist-flush|geoip-status|geoip-sync|geoip-flush|geoip-lookup} - -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, Vordergrund!) - blocklist-status Zeigt Status der externen Blocklisten - blocklist-sync Einmalige Synchronisation der externen Blocklisten - blocklist-flush Entfernt alle Sperren der externen Blocklisten - whitelist-status Zeigt Status der externen Whitelisten - whitelist-sync Einmalige Synchronisation der externen Whitelisten - whitelist-flush Entfernt alle aufgelösten Whitelist-IPs - geoip-status Zeigt Status der GeoIP-Länderfilter - geoip-sync Einmalige GeoIP-Prüfung aller aktiven Clients - geoip-flush Alle GeoIP-Sperren aufheben - geoip-lookup IP GeoIP-Lookup für eine einzelne IP-Adresse - -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 -Datenbank: $DB_FILE -State: $STATE_DIR - -USAGE - exit 0 - ;; -esac diff --git a/cmd/adguard-shieldd/main.go b/cmd/adguard-shieldd/main.go new file mode 100644 index 0000000..3558455 --- /dev/null +++ b/cmd/adguard-shieldd/main.go @@ -0,0 +1,661 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "adguard-shield/internal/appinfo" + "adguard-shield/internal/config" + "adguard-shield/internal/daemon" + "adguard-shield/internal/installer" + "adguard-shield/internal/report" +) + +const statusBanLimit = 50 + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "FEHLER:", err) + os.Exit(1) + } +} + +func run() error { + args := os.Args[1:] + confPath := config.DefaultPath() + if len(args) >= 2 && args[0] == "-config" { + confPath = args[1] + args = args[2:] + } + cmd := "run" + if len(args) > 0 { + cmd = args[0] + args = args[1:] + } + switch cmd { + case "version", "--version", "-v": + fmt.Println(appinfo.Version) + return nil + case "install": + return installCommand(args) + case "update": + return updateCommand(args) + case "uninstall": + return uninstallCommand(args) + case "install-status": + return installStatusCommand(args) + } + cfg, err := config.Load(confPath) + if err != nil { + return err + } + d, err := daemon.New(cfg) + if err != nil { + return err + } + defer d.Close() + ctx, stop := commandContext(d, cmd == "run" || cmd == "start" || cmd == "dry-run") + defer stop() + switch cmd { + case "run", "start", "dry-run": + if cmd == "dry-run" { + cfg.DryRun = true + } + if err := writePID(cfg.PIDFile); err != nil { + return err + } + defer os.Remove(cfg.PIDFile) + err := d.Run(ctx) + if errors.Is(err, context.Canceled) { + return nil + } + return err + case "stop": + return stopDaemon(cfg.PIDFile) + case "test": + items, err := d.FetchQueryLog(ctx) + if err != nil { + return err + } + fmt.Printf("Verbindung erfolgreich. %d Querylog-Einträge gefunden.\n", len(items)) + case "status": + return status(d) + case "live", "watch": + return liveCommand(ctx, d, args) + case "logs": + return logsCommand(d, args) + case "logs-follow": + return logsFollowCommand(ctx, d, args) + case "history": + limit := 50 + if len(args) > 0 { + if n, err := strconv.Atoi(args[0]); err == nil && n > 0 { + limit = n + } + } + lines, err := d.Store.RecentHistory(limit) + if err != nil { + return err + } + for _, l := range lines { + fmt.Println(l) + } + case "flush": + bans, err := d.Store.ActiveBans() + if err != nil { + return err + } + for _, b := range bans { + _ = d.UnbanQuiet(ctx, b.IP, "manual-flush") + } + d.NotifyBulkUnban(ctx, "manual-flush", len(bans)) + fmt.Printf("%d Sperren aufgehoben\n", len(bans)) + case "unban": + if len(args) < 1 { + return fmt.Errorf("Nutzung: adguard-shield unban ") + } + return d.Unban(ctx, args[0], "manual") + case "ban": + if len(args) < 1 { + return fmt.Errorf("Nutzung: adguard-shield ban ") + } + return d.Ban(ctx, args[0], "manual", 0, "-", "manual", "manual", "", true) + case "reset-offenses": + ip := "" + if len(args) > 0 { + ip = args[0] + } + return d.Store.ResetOffense(ip) + case "offense-cleanup": + n, err := d.Store.CleanupOffenses(cfg.ProgressiveBanResetAfter) + if err != nil { + return err + } + fmt.Printf("%d abgelaufene Offense-Zaehler entfernt\n", n) + case "offense-status": + total, err := d.Store.CountOffenses() + if err != nil { + return err + } + expired, err := d.Store.CountExpiredOffenses(cfg.ProgressiveBanResetAfter) + if err != nil { + return err + } + fmt.Println("Offense-Cleanup") + fmt.Printf("Aktiv im Daemon: %v\n", cfg.ProgressiveBanEnabled) + fmt.Printf("Reset nach: %ds\n", cfg.ProgressiveBanResetAfter) + fmt.Printf("Offense-Zaehler gesamt: %d\n", total) + fmt.Printf("Davon abgelaufen: %d\n", expired) + case "geoip-lookup": + if len(args) < 1 { + return fmt.Errorf("Nutzung: adguard-shield geoip-lookup ") + } + if err := d.Geo.Open(ctx); err != nil { + return err + } + cc, err := d.Geo.Lookup(args[0]) + if err != nil { + return err + } + fmt.Printf("IP: %s -> Land: %s\n", args[0], empty(cc, "unbekannt")) + case "geoip-sync": + if err := d.Geo.Open(ctx); err != nil { + return err + } + items, err := d.FetchQueryLog(ctx) + if err != nil { + return err + } + events := d.ToEventsForCommand(items) + seen := map[string]bool{} + for _, ev := range events { + if seen[ev] { + continue + } + seen[ev] = true + d.CheckGeoIPForCommand(ctx, ev) + } + fmt.Printf("GeoIP-Sync abgeschlossen: %d Clients geprüft\n", len(seen)) + case "geoip-status": + return geoipStatus(d) + case "geoip-flush": + bans, err := d.Store.ActiveBans() + if err != nil { + return err + } + n := 0 + for _, b := range bans { + if b.Reason == "geoip" || b.Source == "geoip" { + _ = d.UnbanQuiet(ctx, b.IP, "geoip-flush") + n++ + } + } + d.NotifyBulkUnban(ctx, "geoip-flush", n) + fmt.Printf("%d GeoIP-Sperren aufgehoben\n", n) + case "geoip-flush-cache": + n, err := d.Store.ClearGeoIPCache() + if err != nil { + return err + } + fmt.Printf("%d GeoIP-Cache-Einträge entfernt\n", n) + case "blocklist-sync": + return d.SyncBlocklist(ctx) + case "whitelist-sync": + return d.SyncWhitelist(ctx) + case "blocklist-status": + return blocklistStatus(d) + case "whitelist-status": + return whitelistStatus(d) + case "blocklist-flush": + return flushSource(ctx, d, "external-blocklist") + case "whitelist-flush": + return d.Store.ReplaceWhitelist(nil, "external") + case "report-status": + fmt.Print(report.Status(cfg)) + case "report-generate": + format := "" + output := "" + if len(args) > 0 { + format = args[0] + } + if len(args) > 1 { + output = args[1] + } + body, err := report.Generate(cfg, d.Store, format) + if err != nil { + return err + } + if output != "" { + return os.WriteFile(output, []byte(body), 0644) + } + fmt.Print(body) + case "report-send": + return report.Send(ctx, cfg, d.Store) + case "report-test": + return report.SendTest(ctx, cfg) + case "report-install": + return report.InstallCron("/opt/adguard-shield/adguard-shield", cfg.Path, cfg) + case "report-remove": + return report.RemoveCron() + case "firewall-create": + return d.FW.Setup(ctx) + case "firewall-remove": + return d.FW.Remove(ctx) + case "firewall-flush": + return d.FW.Flush(ctx) + case "firewall-status": + return firewallStatus(ctx, d) + case "firewall-save": + return d.SaveFirewallRules(ctx) + case "firewall-restore": + return restoreFirewallRules(ctx, d) + default: + usage() + } + return nil +} + +func status(d *daemon.Daemon) error { + bans, err := d.Store.ActiveBans() + if err != nil { + return err + } + fmt.Println("AdGuard Shield Daemon Status") + fmt.Printf("Config: %s\n", d.Config.Path) + fmt.Printf("Firewall: %s/%s (Chain: %s)\n", d.Config.FirewallBackend, d.Config.FirewallMode, d.Config.Chain) + fmt.Printf("GeoIP: %v (%s %v)\n", d.Config.GeoIPEnabled, d.Config.GeoIPMode, d.Config.GeoIPCountries) + fmt.Printf("Externe Blocklist: %v (%d URLs)\n", d.Config.ExternalBlocklistEnabled, len(d.Config.ExternalBlocklistURLs)) + fmt.Printf("Externe Whitelist: %v (%d URLs)\n", d.Config.ExternalWhitelistEnabled, len(d.Config.ExternalWhitelistURLs)) + fmt.Printf("Aktive Sperren: %d\n", len(bans)) + limit := min(len(bans), statusBanLimit) + for _, b := range bans[:limit] { + until := "permanent" + if !b.Permanent && b.BanUntil > 0 { + until = time.Unix(b.BanUntil, 0).Format("2006-01-02 15:04:05") + } + fmt.Printf(" %s | %s | %s | %s\n", b.IP, b.Source, b.Reason, until) + } + if len(bans) > limit { + fmt.Printf(" ... %d weitere Sperren. Details mit: adguard-shield history oder direkt in SQLite.\n", len(bans)-limit) + } + return nil +} + +func blocklistStatus(d *daemon.Daemon) error { + count, err := d.Store.CountBySource("external-blocklist") + if err != nil { + return err + } + fmt.Println("Externe Blocklist") + fmt.Printf("Aktiv: %v\n", d.Config.ExternalBlocklistEnabled) + fmt.Printf("Intervall: %ds\n", d.Config.ExternalBlocklistInterval) + fmt.Printf("Auto-Unban: %v\n", d.Config.ExternalBlocklistAutoUnban) + fmt.Printf("Cache: %s\n", d.Config.ExternalBlocklistCacheDir) + fmt.Printf("Aktive Sperren: %d\n", count) + for i, u := range d.Config.ExternalBlocklistURLs { + fmt.Printf(" [%d] %s\n", i, u) + } + return nil +} + +func whitelistStatus(d *daemon.Daemon) error { + wl, err := d.Store.AllWhitelist() + if err != nil { + return err + } + fmt.Println("Externe Whitelist") + fmt.Printf("Aktiv: %v\n", d.Config.ExternalWhitelistEnabled) + fmt.Printf("Intervall: %ds\n", d.Config.ExternalWhitelistInterval) + fmt.Printf("Cache: %s\n", d.Config.ExternalWhitelistCacheDir) + fmt.Printf("Aufgelöste IPs: %d\n", len(wl)) + for i, u := range d.Config.ExternalWhitelistURLs { + fmt.Printf(" [%d] %s\n", i, u) + } + return nil +} + +func geoipStatus(d *daemon.Daemon) error { + fmt.Println("GeoIP Status") + fmt.Printf("Aktiv: %v\n", d.Config.GeoIPEnabled) + fmt.Printf("Modus: %s\n", d.Config.GeoIPMode) + fmt.Printf("Länder: %v\n", d.Config.GeoIPCountries) + fmt.Printf("Cache TTL: %ds\n", d.Config.GeoIPCacheTTL) + fmt.Printf("MMDB: %s\n", empty(d.Config.GeoIPMMDBPath, "")) + bans, err := d.Store.BansByReason("geoip") + if err != nil { + return err + } + fmt.Printf("Aktive GeoIP-Sperren: %d\n", len(bans)) + return nil +} + +func flushSource(ctx context.Context, d *daemon.Daemon, source string) error { + bans, err := d.Store.BansBySource(source) + if err != nil { + return err + } + for _, b := range bans { + _ = d.UnbanQuiet(ctx, b.IP, source+"-flush") + } + d.NotifyBulkUnban(ctx, source+"-flush", len(bans)) + fmt.Printf("%d Sperren aufgehoben\n", len(bans)) + return nil +} + +func writePID(path string) error { + if path == "" { + return nil + } + return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644) +} + +func commandContext(d *daemon.Daemon, notifyServiceStop bool) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + signals := make(chan os.Signal, 1) + done := make(chan struct{}) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP) + go func() { + select { + case <-signals: + if notifyServiceStop { + d.NotifyServiceStop(context.Background()) + } + cancel() + case <-done: + } + }() + return ctx, func() { + close(done) + signal.Stop(signals) + cancel() + } +} + +func stopDaemon(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("Daemon läuft nicht oder PID-Datei fehlt: %w", err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return err + } + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + return err + } + fmt.Printf("Daemon gestoppt (PID %d)\n", pid) + return nil +} + +func firewallStatus(ctx context.Context, d *daemon.Daemon) error { + fmt.Printf("Firewall Backend: %s\n", d.Config.FirewallBackend) + fmt.Printf("Firewall Modus: %s\n", d.Config.FirewallMode) + fmt.Printf("Chain: %s\n", d.Config.Chain) + for _, cmd := range [][]string{ + {"ipset", "list", "adguard_shield_v4"}, + {"ipset", "list", "adguard_shield_v6"}, + {"iptables", "-n", "-L", d.Config.Chain, "--line-numbers", "-v"}, + {"ip6tables", "-n", "-L", d.Config.Chain, "--line-numbers", "-v"}, + } { + out, err := exec.CommandContext(ctx, cmd[0], cmd[1:]...).CombinedOutput() + fmt.Printf("\n--- %s ---\n", cmd[0]) + if err != nil { + fmt.Printf("%v\n", err) + } + fmt.Print(string(out)) + } + return nil +} + +func restoreFirewallRules(ctx context.Context, d *daemon.Daemon) error { + files := []struct { + path string + cmd string + }{ + {filepath.Join(d.Config.StateDir, "iptables-rules.v4"), "iptables-restore"}, + {filepath.Join(d.Config.StateDir, "iptables-rules.v6"), "ip6tables-restore"}, + } + for _, f := range files { + data, err := os.ReadFile(f.path) + if err != nil { + continue + } + c := exec.CommandContext(ctx, f.cmd) + stdin, err := c.StdinPipe() + if err != nil { + return err + } + if err := c.Start(); err != nil { + return err + } + _, _ = stdin.Write(data) + _ = stdin.Close() + if err := c.Wait(); err != nil { + return err + } + } + return nil +} + +func empty(s, fallback string) string { + if s == "" { + return fallback + } + return s +} + +func usage() { + fmt.Println(`AdGuard Shield Daemon + +Nutzung: + adguard-shield version + adguard-shield install [--config-source PATH] [--skip-deps] + adguard-shield update [--config-source PATH] [--skip-deps] + adguard-shield uninstall [--keep-config] + adguard-shield install-status + adguard-shield [-config PATH] run|start|stop|dry-run + adguard-shield status|history [N]|test|flush|ban IP|unban IP|reset-offenses [IP] + adguard-shield live [--interval N] [--top N] [--recent N] [--logs LEVEL] [--once] + adguard-shield logs [--level LEVEL] [--limit N]|logs-follow [--level LEVEL] + adguard-shield offense-status|offense-cleanup + adguard-shield geoip-status|geoip-sync|geoip-flush|geoip-flush-cache|geoip-lookup IP + adguard-shield blocklist-status|blocklist-sync|blocklist-flush + adguard-shield whitelist-status|whitelist-sync|whitelist-flush + adguard-shield report-status|report-generate [html|txt] [OUTPUT]|report-send|report-test|report-install|report-remove + adguard-shield firewall-create|firewall-remove|firewall-flush|firewall-status|firewall-save|firewall-restore`) +} + +func liveCommand(ctx context.Context, d *daemon.Daemon, args []string) error { + fs := flag.NewFlagSet("live", flag.ContinueOnError) + interval := fs.Int("interval", d.Config.CheckInterval, "Aktualisierungsintervall in Sekunden") + top := fs.Int("top", 10, "Anzahl Top-Einträge") + recent := fs.Int("recent", 12, "Anzahl letzter Queries und Logs") + logLevel := fs.String("logs", "INFO", "Systemlogs ab Level anzeigen: DEBUG, INFO, WARN, ERROR oder off") + once := fs.Bool("once", false, "Nur einen Snapshot anzeigen") + if err := fs.Parse(args); err != nil { + return err + } + err := d.Live(ctx, os.Stdout, daemon.LiveOptions{ + Interval: time.Duration(*interval) * time.Second, + Top: *top, + Recent: *recent, + LogLevel: *logLevel, + Once: *once, + }) + if errors.Is(err, context.Canceled) { + return nil + } + return err +} + +func logsCommand(d *daemon.Daemon, args []string) error { + level, limit, err := parseLogArgs("logs", args, "INFO", 80) + if err != nil { + return err + } + lines := daemon.RecentLogLines(d.Config.LogFile, level, limit) + if len(lines) == 0 { + fmt.Printf("Keine Logeinträge ab Level %s in %s gefunden.\n", strings.ToUpper(level), d.Config.LogFile) + return nil + } + for _, line := range lines { + fmt.Println(line) + } + return nil +} + +func logsFollowCommand(ctx context.Context, d *daemon.Daemon, args []string) error { + level, limit, err := parseLogArgs("logs-follow", args, "INFO", 40) + if err != nil { + return err + } + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + fmt.Print("\033[H\033[2J") + fmt.Printf("AdGuard Shield Logs | %s | %s ab %s | Strg+C beendet\n", time.Now().Format("2006-01-02 15:04:05"), d.Config.LogFile, strings.ToUpper(level)) + fmt.Println(strings.Repeat("=", 92)) + lines := daemon.RecentLogLines(d.Config.LogFile, level, limit) + if len(lines) == 0 { + fmt.Println("Keine passenden Logeinträge.") + } else { + for _, line := range lines { + fmt.Println(line) + } + } + select { + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { + return nil + } + return ctx.Err() + case <-t.C: + } + } +} + +func parseLogArgs(name string, args []string, defaultLevel string, defaultLimit int) (string, int, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + level := fs.String("level", defaultLevel, "Mindestlevel: DEBUG, INFO, WARN, ERROR") + limit := fs.Int("limit", defaultLimit, "Anzahl der letzten Logzeilen") + if err := fs.Parse(args); err != nil { + return "", 0, err + } + rest := fs.Args() + if len(rest) > 0 { + if isLogLevel(rest[0]) { + *level = rest[0] + } else if n, err := strconv.Atoi(rest[0]); err == nil && n > 0 { + *limit = n + } + } + if len(rest) > 1 { + if n, err := strconv.Atoi(rest[1]); err == nil && n > 0 { + *limit = n + } + } + if !isLogLevel(*level) { + return "", 0, fmt.Errorf("ungültiges Log-Level %q (erlaubt: DEBUG, INFO, WARN, ERROR)", *level) + } + if *limit <= 0 { + *limit = defaultLimit + } + return strings.ToUpper(*level), *limit, nil +} + +func isLogLevel(s string) bool { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "ERR": + return true + default: + return false + } +} + +func installCommand(args []string) error { + opts, err := parseInstallFlags("install", args) + if err != nil { + return err + } + err = installer.Install(opts) + if le, ok := installer.IsLegacyError(err); ok { + return fmt.Errorf("%s", installer.FormatLegacyMessage(le, opts.InstallDir)) + } + if err != nil { + return err + } + fmt.Println("AdGuard Shield Go-Installation abgeschlossen.") + fmt.Println(installer.PrintStatus(installer.GetStatus(opts.InstallDir))) + return nil +} + +func updateCommand(args []string) error { + opts, err := parseInstallFlags("update", args) + if err != nil { + return err + } + err = installer.Update(opts) + if le, ok := installer.IsLegacyError(err); ok { + return fmt.Errorf("%s", installer.FormatLegacyMessage(le, opts.InstallDir)) + } + if err != nil { + return err + } + fmt.Println("AdGuard Shield Go-Update abgeschlossen.") + fmt.Println(installer.PrintStatus(installer.GetStatus(opts.InstallDir))) + return nil +} + +func uninstallCommand(args []string) error { + fs := flag.NewFlagSet("uninstall", flag.ContinueOnError) + opts := installer.DefaultOptions() + fs.StringVar(&opts.InstallDir, "install-dir", opts.InstallDir, "Installationsverzeichnis") + fs.BoolVar(&opts.KeepConfig, "keep-config", false, "Konfiguration behalten") + if err := fs.Parse(args); err != nil { + return err + } + if err := installer.Uninstall(opts); err != nil { + return err + } + fmt.Println("AdGuard Shield wurde deinstalliert.") + return nil +} + +func installStatusCommand(args []string) error { + fs := flag.NewFlagSet("install-status", flag.ContinueOnError) + installDir := installer.DefaultInstallDir + fs.StringVar(&installDir, "install-dir", installDir, "Installationsverzeichnis") + if err := fs.Parse(args); err != nil { + return err + } + fmt.Print(installer.PrintStatus(installer.GetStatus(installDir))) + return nil +} + +func parseInstallFlags(name string, args []string) (installer.Options, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + opts := installer.DefaultOptions() + fs.StringVar(&opts.InstallDir, "install-dir", opts.InstallDir, "Installationsverzeichnis") + fs.StringVar(&opts.ConfigSource, "config-source", "", "Konfiguration fuer Neuinstallation uebernehmen") + fs.BoolVar(&opts.SkipDeps, "skip-deps", false, "Paketpruefung ueberspringen") + noEnable := fs.Bool("no-enable", false, "systemd Autostart nicht aktivieren") + if err := fs.Parse(args); err != nil { + return opts, err + } + opts.Enable = !*noEnable + return opts, nil +} diff --git a/db.sh b/db.sh deleted file mode 100644 index 6583ea4..0000000 --- a/db.sh +++ /dev/null @@ -1,641 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - SQLite Datenbank-Bibliothek -# Zentrale Datenbankfunktionen fuer alle Scripte. -# Wird per "source db.sh" eingebunden. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -DB_FILE="${STATE_DIR}/adguard-shield.db" -DB_SCHEMA_VERSION=1 -_DB_MIGRATION_MARKER="${STATE_DIR}/.migration_v1_complete" - -# ─── SQL-Wert escapen (Single Quotes verdoppeln) ──────────────────────────── -_db_escape() { - echo "${1//\'/\'\'}" -} - -# ─── SQL ausfuehren (INSERT/UPDATE/DELETE) ─────────────────────────────────── -db_exec() { - sqlite3 "$DB_FILE" < 0 AND is_permanent = 0 AND ban_until_epoch <= $now;" -} - -db_ban_get_expired_by_source() { - local source=$(_db_escape "$1") - local now - now=$(date '+%s') - db_query "SELECT client_ip FROM active_bans WHERE source='$source' AND ban_until_epoch > 0 AND is_permanent = 0 AND ban_until_epoch <= $now;" -} - -db_ban_get_by_source() { - local source=$(_db_escape "$1") - db_query "SELECT client_ip FROM active_bans WHERE source='$source';" -} - -db_ban_count() { - db_query "SELECT COUNT(*) FROM active_bans;" -} - -db_ban_count_by_source() { - local source=$(_db_escape "$1") - db_query "SELECT COUNT(*) FROM active_bans WHERE source='$source';" -} - -db_ban_get_all() { - db_query "SELECT client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode FROM active_bans ORDER BY created_at DESC;" -} - -db_ban_get_by_reason() { - local reason=$(_db_escape "$1") - db_query "SELECT client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode FROM active_bans WHERE reason='$reason';" -} - -# ─── Offense-Funktionen ───────────────────────────────────────────────────── - -db_offense_get_level() { - local ip=$(_db_escape "$1") - local reset_after="${2:-86400}" - local now - now=$(date '+%s') - - local row - row=$(db_query "SELECT offense_level, last_offense_epoch FROM offense_tracking WHERE client_ip='$ip' LIMIT 1;") - - if [[ -z "$row" ]]; then - echo "0" - return - fi - - local level last_epoch - IFS='|' read -r level last_epoch <<< "$row" - - if [[ -n "$last_epoch" && $((now - last_epoch)) -gt "$reset_after" ]]; then - db_exec "DELETE FROM offense_tracking WHERE client_ip='$ip';" - echo "0" - return - fi - - echo "${level:-0}" -} - -db_offense_increment() { - local ip=$(_db_escape "$1") - local current_level - current_level=$(db_offense_get_level "$1" "${PROGRESSIVE_BAN_RESET_AFTER:-86400}") - local new_level=$((current_level + 1)) - local now - now=$(date '+%s') - local now_readable - now_readable=$(date '+%Y-%m-%d %H:%M:%S') - - local first_offense - first_offense=$(db_query "SELECT first_offense FROM offense_tracking WHERE client_ip='$ip' LIMIT 1;") - [[ -z "$first_offense" ]] && first_offense="$now_readable" - - db_exec "INSERT OR REPLACE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense, updated_at) VALUES ('$ip', $new_level, $now, '$now_readable', '$first_offense', '$now_readable');" - - echo "$new_level" -} - -db_offense_delete() { - local ip=$(_db_escape "$1") - db_exec "DELETE FROM offense_tracking WHERE client_ip='$ip';" -} - -db_offense_delete_all() { - local count - count=$(db_query "SELECT COUNT(*) FROM offense_tracking;") - db_exec "DELETE FROM offense_tracking;" - echo "${count:-0}" -} - -db_offense_delete_expired() { - local reset_after="${1:-86400}" - local now - now=$(date '+%s') - local cutoff=$((now - reset_after)) - - local expired - expired=$(db_query "SELECT client_ip, offense_level, last_offense_epoch FROM offense_tracking WHERE last_offense_epoch <= $cutoff;") - local count=0 - if [[ -n "$expired" ]]; then - count=$(echo "$expired" | wc -l) - db_exec "DELETE FROM offense_tracking WHERE last_offense_epoch <= $cutoff;" - fi - echo "$count" -} - -db_offense_get_all() { - db_query "SELECT client_ip, offense_level, last_offense_epoch, last_offense, first_offense FROM offense_tracking ORDER BY last_offense_epoch DESC;" -} - -db_offense_count() { - db_query "SELECT COUNT(*) FROM offense_tracking;" -} - -db_offense_count_expired() { - local reset_after="${1:-86400}" - local now - now=$(date '+%s') - local cutoff=$((now - reset_after)) - db_query "SELECT COUNT(*) FROM offense_tracking WHERE last_offense_epoch <= $cutoff;" -} - -# ─── Ban-History-Funktionen ───────────────────────────────────────────────── - -db_history_add() { - local action=$(_db_escape "$1") - local client_ip=$(_db_escape "$2") - local domain=$(_db_escape "${3:--}") - local count=$(_db_escape "${4:--}") - local reason=$(_db_escape "${5:--}") - local duration=$(_db_escape "${6:--}") - local protocol=$(_db_escape "${7:--}") - local now_epoch - now_epoch=$(date '+%s') - local now_text - now_text=$(date '+%Y-%m-%d %H:%M:%S') - - db_exec "INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES ($now_epoch, '$now_text', '$action', '$client_ip', '$domain', '$count', '$duration', '$protocol', '$reason');" -} - -db_history_cleanup() { - local retention_days="${1:-0}" - [[ "$retention_days" == "0" || -z "$retention_days" ]] && return - - local cutoff_epoch - cutoff_epoch=$(date -d "-${retention_days} days" '+%s' 2>/dev/null) - [[ -z "$cutoff_epoch" ]] && return - - local before after removed - before=$(db_query "SELECT COUNT(*) FROM ban_history;") - db_exec "DELETE FROM ban_history WHERE timestamp_epoch < $cutoff_epoch;" - after=$(db_query "SELECT COUNT(*) FROM ban_history;") - removed=$((before - after)) - echo "$removed" -} - -db_history_get_recent() { - local limit="${1:-50}" - db_query "SELECT timestamp_text, action, client_ip, domain, count, duration, protocol, reason FROM ban_history ORDER BY id DESC LIMIT $limit;" -} - -db_history_count() { - db_query "SELECT COUNT(*) FROM ban_history;" -} - -db_history_count_by_action() { - local action=$(_db_escape "$1") - db_query "SELECT COUNT(*) FROM ban_history WHERE action='$action';" -} - -db_history_stats_for_range() { - local start_epoch="$1" - local end_epoch="$2" - - db_query "SELECT - COALESCE(SUM(CASE WHEN action='BAN' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN action='UNBAN' THEN 1 ELSE 0 END), 0), - COALESCE(COUNT(DISTINCT CASE WHEN action='BAN' THEN client_ip END), 0), - COALESCE(SUM(CASE WHEN action='BAN' AND (duration LIKE '%PERMANENT%' OR duration LIKE '%permanent%') THEN 1 ELSE 0 END), 0) - FROM ban_history - WHERE timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch;" -} - -db_history_report_stats() { - local start_epoch="$1" - local end_epoch="$2" - local busiest_start="$3" - - db_query "SELECT - COALESCE(SUM(CASE WHEN action='BAN' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN action='UNBAN' THEN 1 ELSE 0 END), 0), - COALESCE(COUNT(DISTINCT CASE WHEN action='BAN' THEN client_ip END), 0), - COALESCE(SUM(CASE WHEN action='BAN' AND (duration LIKE '%PERMANENT%' OR duration LIKE '%permanent%') THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%rate%limit%' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%subdomain%flood%' THEN 1 ELSE 0 END), 0), - COALESCE(SUM(CASE WHEN action='BAN' AND reason LIKE '%external%blocklist%' THEN 1 ELSE 0 END), 0) - FROM ban_history - WHERE timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch;" -} - -db_history_busiest_day() { - local start_epoch="$1" - local end_epoch="$2" - - db_query "SELECT substr(timestamp_text, 1, 10), COUNT(*) - FROM ban_history - WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch - GROUP BY substr(timestamp_text, 1, 10) - ORDER BY COUNT(*) DESC - LIMIT 1;" -} - -db_history_top_ips() { - local start_epoch="$1" - local end_epoch="$2" - local limit="${3:-10}" - - db_query "SELECT COUNT(*), client_ip - FROM ban_history - WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch - GROUP BY client_ip - ORDER BY COUNT(*) DESC - LIMIT $limit;" -} - -db_history_top_domains() { - local start_epoch="$1" - local end_epoch="$2" - local limit="${3:-10}" - - db_query "SELECT COUNT(*), domain - FROM ban_history - WHERE action='BAN' AND domain != '-' AND domain != '' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch - GROUP BY domain - ORDER BY COUNT(*) DESC - LIMIT $limit;" -} - -db_history_protocol_stats() { - local start_epoch="$1" - local end_epoch="$2" - - db_query "SELECT COUNT(*), COALESCE(NULLIF(protocol, ''), 'unbekannt') - FROM ban_history - WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch - GROUP BY COALESCE(NULLIF(protocol, ''), 'unbekannt') - ORDER BY COUNT(*) DESC;" -} - -db_history_recent_bans() { - local start_epoch="$1" - local end_epoch="$2" - local limit="${3:-10}" - - db_query "SELECT timestamp_text, action, client_ip, domain, count, duration, protocol, reason - FROM ban_history - WHERE action='BAN' AND timestamp_epoch >= $start_epoch AND timestamp_epoch <= $end_epoch - ORDER BY id DESC - LIMIT $limit;" -} - -# ─── Whitelist-Funktionen ─────────────────────────────────────────────────── - -db_whitelist_contains() { - local ip=$(_db_escape "$1") - local result - result=$(db_query "SELECT 1 FROM whitelist_cache WHERE ip_address='$ip' LIMIT 1;") - [[ -n "$result" ]] -} - -db_whitelist_sync() { - local source=$(_db_escape "${1:-external}") - local tmp_sql="" - tmp_sql="BEGIN TRANSACTION; DELETE FROM whitelist_cache;" - while IFS= read -r ip; do - [[ -z "$ip" ]] && continue - local safe_ip=$(_db_escape "$ip") - tmp_sql+=" INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES ('$safe_ip', '$source');" - done - tmp_sql+=" COMMIT;" - db_exec "$tmp_sql" -} - -db_whitelist_count() { - db_query "SELECT COUNT(*) FROM whitelist_cache;" -} - -db_whitelist_get_all() { - db_query "SELECT ip_address FROM whitelist_cache;" -} - -db_whitelist_clear() { - db_exec "DELETE FROM whitelist_cache;" -} - -# ─── Migration von Flat-Files ─────────────────────────────────────────────── - -db_migrate_from_files() { - # Bereits migriert? - if [[ -f "$_DB_MIGRATION_MARKER" ]]; then - return 0 - fi - - local migrated=0 - local backup_dir="${STATE_DIR}/.backup_pre_sqlite" - - # ─── .ban-Dateien migrieren ────────────────────────────────────────── - local ban_sql="BEGIN TRANSACTION;" - local ban_count=0 - - for state_file in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban; do - [[ -f "$state_file" ]] || continue - local basename_f - basename_f=$(basename "$state_file") - - local s_ip s_domain s_count s_ban_time s_ban_until_epoch s_ban_duration - local s_offense_level s_is_permanent s_reason s_protocol s_source - local s_geoip_country s_geoip_mode - - s_ip=$(grep '^CLIENT_IP=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - [[ -z "$s_ip" ]] && continue - - s_domain=$(grep '^DOMAIN=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_count=$(grep '^COUNT=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_ban_time=$(grep '^BAN_TIME=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_ban_duration=$(grep '^BAN_DURATION=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_offense_level=$(grep '^OFFENSE_LEVEL=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_is_permanent=$(grep '^IS_PERMANENT=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_reason=$(grep '^REASON=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_protocol=$(grep '^PROTOCOL=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_geoip_country=$(grep '^GEOIP_COUNTRY=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - s_geoip_mode=$(grep '^GEOIP_MODE=' "$state_file" 2>/dev/null | cut -d= -f2 || true) - - # Source bestimmen - if [[ "$basename_f" == ext_* ]]; then - s_source="external-blocklist" - elif [[ "$s_reason" == "geoip" ]]; then - s_source="geoip" - else - s_source="monitor" - fi - - # Boolean zu Integer - local perm_int=0 - [[ "$s_is_permanent" == "true" ]] && perm_int=1 - - s_ip=$(_db_escape "$s_ip") - s_domain=$(_db_escape "${s_domain:--}") - s_ban_time=$(_db_escape "${s_ban_time:-}") - s_reason=$(_db_escape "${s_reason:-rate-limit}") - s_protocol=$(_db_escape "${s_protocol:-DNS}") - s_geoip_country=$(_db_escape "${s_geoip_country:-}") - s_geoip_mode=$(_db_escape "${s_geoip_mode:-}") - - ban_sql+=" INSERT OR IGNORE INTO active_bans (client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode) VALUES ('$s_ip', '$s_domain', ${s_count:-0}, '$s_ban_time', ${s_ban_until_epoch:-0}, ${s_ban_duration:-0}, ${s_offense_level:-0}, $perm_int, '$s_reason', '$s_protocol', '$s_source', '$s_geoip_country', '$s_geoip_mode');" - ban_count=$((ban_count + 1)) - done - ban_sql+=" COMMIT;" - - if [[ $ban_count -gt 0 ]]; then - db_exec "$ban_sql" - migrated=$((migrated + ban_count)) - fi - - # ─── .offenses-Dateien migrieren ───────────────────────────────────── - local offense_sql="BEGIN TRANSACTION;" - local offense_count=0 - - for offense_file in "${STATE_DIR}"/*.offenses; do - [[ -f "$offense_file" ]] || continue - - local o_ip o_level o_last_epoch o_last o_first - o_ip=$(grep '^CLIENT_IP=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) - [[ -z "$o_ip" ]] && continue - - o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) - o_last_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) - o_last=$(grep '^LAST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) - o_first=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) - - o_ip=$(_db_escape "$o_ip") - o_last=$(_db_escape "${o_last:-}") - o_first=$(_db_escape "${o_first:-}") - - offense_sql+=" INSERT OR IGNORE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense) VALUES ('$o_ip', ${o_level:-0}, ${o_last_epoch:-0}, '$o_last', '$o_first');" - offense_count=$((offense_count + 1)) - done - offense_sql+=" COMMIT;" - - if [[ $offense_count -gt 0 ]]; then - db_exec "$offense_sql" - migrated=$((migrated + offense_count)) - fi - - # ─── Ban-History-Log migrieren ─────────────────────────────────────── - local history_count=0 - if [[ -f "$BAN_HISTORY_FILE" ]]; then - local history_sql - history_sql=$(awk ' - /^#/ || /^[[:space:]]*$/ { next } - { - n = split($0, f, "|") - if (n < 2) next - ts = f[1]; gsub(/^[[:space:]]+|[[:space:]]+$/, "", ts) - if (length(ts) < 19) next - ep = mktime(substr(ts,1,4) " " substr(ts,6,2) " " substr(ts,9,2) " " \ - substr(ts,12,2) " " substr(ts,15,2) " " substr(ts,18,2)) - if (ep < 0) next - for (i = 1; i <= n; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", f[i]) - # Single quotes escapen - gsub(/'\''/, "'\'''\''", f[1]) - gsub(/'\''/, "'\'''\''", f[2]) - gsub(/'\''/, "'\'''\''", f[3]) - gsub(/'\''/, "'\'''\''", f[4]) - gsub(/'\''/, "'\'''\''", f[5]) - gsub(/'\''/, "'\'''\''", f[6]) - gsub(/'\''/, "'\'''\''", f[7]) - gsub(/'\''/, "'\'''\''", f[8]) - printf "INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (%d, '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'', '\''%s'\'');\n", \ - ep, f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8] - count++ - } - END { print "-- migrated " count+0 " history entries" } - ' "$BAN_HISTORY_FILE") - - if [[ -n "$history_sql" ]]; then - echo "BEGIN TRANSACTION; $history_sql COMMIT;" | sqlite3 "$DB_FILE" 2>/dev/null - history_count=$(echo "$history_sql" | grep -c '^INSERT' || true) - migrated=$((migrated + history_count)) - fi - fi - - # ─── Whitelist-Cache migrieren ─────────────────────────────────────── - local wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt" - local wl_count=0 - if [[ -f "$wl_file" ]]; then - local wl_sql="BEGIN TRANSACTION;" - while IFS= read -r ip; do - [[ -z "$ip" ]] && continue - local safe_ip=$(_db_escape "$ip") - wl_sql+=" INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES ('$safe_ip', 'external');" - wl_count=$((wl_count + 1)) - done < "$wl_file" - wl_sql+=" COMMIT;" - - if [[ $wl_count -gt 0 ]]; then - db_exec "$wl_sql" - migrated=$((migrated + wl_count)) - fi - fi - - # ─── Alte Dateien in Backup verschieben ────────────────────────────── - if [[ $migrated -gt 0 ]]; then - mkdir -p "$backup_dir" - - for f in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban; do - [[ -f "$f" ]] || continue - mv "$f" "$backup_dir/" 2>/dev/null || true - done - - for f in "${STATE_DIR}"/*.offenses; do - [[ -f "$f" ]] || continue - mv "$f" "$backup_dir/" 2>/dev/null || true - done - - if [[ -f "$BAN_HISTORY_FILE" ]]; then - cp "$BAN_HISTORY_FILE" "${backup_dir}/adguard-shield-bans.log.bak" 2>/dev/null || true - fi - - if [[ -f "$wl_file" ]]; then - cp "$wl_file" "${backup_dir}/resolved_ips.txt.bak" 2>/dev/null || true - fi - fi - - # Migrations-Marker setzen - echo "migrated_at=$(date '+%Y-%m-%d %H:%M:%S')" > "$_DB_MIGRATION_MARKER" - echo "bans=$ban_count" >> "$_DB_MIGRATION_MARKER" - echo "offenses=$offense_count" >> "$_DB_MIGRATION_MARKER" - echo "history=$history_count" >> "$_DB_MIGRATION_MARKER" - echo "whitelist=$wl_count" >> "$_DB_MIGRATION_MARKER" - - echo "$migrated" -} diff --git a/docs/README.md b/docs/README.md index 62855dd..34f77ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,59 @@ # Dokumentation -Hier findest du die vollständige Dokumentation zu AdGuard Shield. +Willkommen in der Dokumentation von AdGuard Shield. -## Inhaltsverzeichnis +AdGuard Shield ist ein Go-Daemon, der das Query Log von AdGuard Home auswertet, auffällige DNS-Clients erkennt und diese über eine eigene Firewall-Struktur sperrt. Die Dokumentation ist bewusst ausführlich gehalten: Sie soll nicht nur Befehle auflisten, sondern erklären, was im Hintergrund passiert, welche Werte sinnvoll sind und wie du Fehler sauber eingrenzt. -| Dokument | Beschreibung | +## Schnellnavigation + +| Dokument | Wofür es gedacht ist | |---|---| -| [Architektur & Funktionsweise](architektur.md) | Überblick über den Systemaufbau, Datenfluss und die internen Komponenten | -| [Befehle & Nutzung](befehle.md) | Alle verfügbaren Befehle des Installers, des Hauptskripts und des Watchdogs | -| [Konfiguration](konfiguration.md) | Beschreibung aller Konfigurationsparameter in `adguard-shield.conf` | -| [Webhook-Benachrichtigungen](benachrichtigungen.md) | Einrichtung von Push-Benachrichtigungen über Telegram, Discord, Gotify u.a. | -| [E-Mail Report](report.md) | Konfiguration des automatischen Statistik-Reports per E-Mail | -| [Update-Anleitung](update.md) | Schritt-für-Schritt-Anleitung zum Aktualisieren einer bestehenden Installation | -| [Tipps & Troubleshooting](tipps-und-troubleshooting.md) | Best Practices, häufige Probleme und deren Lösungen | +| [Architektur & Funktionsweise](architektur.md) | Erklärt den Aufbau, den Datenfluss, Firewall, SQLite, Hintergrundjobs und Sperrlogik | +| [Befehle & Nutzung](befehle.md) | Vollständige CLI-Referenz mit typischen Betriebsabläufen | +| [Konfiguration](konfiguration.md) | Alle Parameter aus `adguard-shield.conf` mit Beispielen und Empfehlungen | +| [Docker-Installationen](docker.md) | Firewall-Modi für klassische Installation, Docker Host Network und veröffentlichte Docker-Ports | +| [Benachrichtigungen](benachrichtigungen.md) | Einrichtung von Ntfy, Discord, Slack, Gotify und Generic Webhooks | +| [E-Mail Report](report.md) | Report-Inhalte, Mailversand, Cron-Job und manuelle Tests | +| [Update-Anleitung](update.md) | Update der Go-Version und Migration von alten Shell-Installationen | +| [Tipps & Troubleshooting](tipps-und-troubleshooting.md) | Diagnosewege für API, Firewall, GeoIP, Reports, Listen und falsch gesetzte Sperren | + +## Wichtigster Unterschied zur alten Shell-Version + +Die frühere Version bestand aus mehreren Shell-Skripten, Hilfs-Workern, Cron-Jobs und einem separaten Watchdog. Die Go-Version bündelt diese Aufgaben in einem einzelnen Binary: + +```text +/opt/adguard-shield/adguard-shield +``` + +Dieses Binary ist gleichzeitig: + +- Daemon für den produktiven Betrieb +- CLI für Status, History, Logs, Firewall, Listen, GeoIP und Reports +- Installer, Updater und Uninstaller +- Report-Generator +- Hintergrundprozess für externe Whitelist, externe Blocklist, GeoIP und Offense-Cleanup + +Die meisten Befehle beginnen daher mit: + +```bash +sudo /opt/adguard-shield/adguard-shield +``` + +Für Installation oder Update nutzt du das neue Binary aus dem Repository, Release oder Build-Verzeichnis: + +```bash +sudo ./adguard-shield install +sudo ./adguard-shield update +``` + +## Empfohlener Lesefluss + +Wenn du AdGuard Shield neu einrichtest: + +1. Lies zuerst [Architektur & Funktionsweise](architektur.md), damit klar ist, was genau gesperrt wird. +2. Passe danach [Konfiguration](konfiguration.md) an, besonders API-Zugang, Whitelist und Rate-Limits. +3. Nutze [Befehle & Nutzung](befehle.md) für Installation, Dry-Run und Service-Start. +4. Richte optional [Benachrichtigungen](benachrichtigungen.md), [Reports](report.md), GeoIP oder externe Listen ein. +5. Bei Problemen hilft [Tipps & Troubleshooting](tipps-und-troubleshooting.md). + +Wenn du von der alten Shell-Version kommst, beginne mit [Update-Anleitung](update.md). diff --git a/docs/architektur.md b/docs/architektur.md index b3adbae..ca5d312 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -1,232 +1,407 @@ # Architektur & Funktionsweise -## Überblick +Dieses Dokument erklärt, wie AdGuard Shield intern arbeitet. Es geht dabei nicht nur um die Dateien auf dem System, sondern auch um den Weg einer DNS-Anfrage vom AdGuard-Home-Querylog bis zur Firewall-Sperre. -``` -┌─────────────────────┐ -│ Client Anfragen │ -│ (DNS/DoH/DoT/DoQ) │ -└──────────┬──────────┘ - │ - ▼ -┌─────────────────────┐ ┌──────────────────────┐ -│ AdGuard Home │────▶ │ Query Log (API) │ -│ DNS Server │ └──────────┬───────────┘ -└─────────────────────┘ │ - ▼ - ┌──────────────────────┐ - │ adguard-shield.sh │ - │ (Monitor Script) │ - └──────────┬───────────┘ - │ - ┌──────────────┼──────────────┐ - ▼ ▼ ▼ - ┌──────────┐ ┌──────────┐ ┌──────────┐ - │ iptables │ │ Log │ │ Webhook │ - │ DROP │ │ Datei │ │ Notify │ - └──────────┘ └──────────┘ └──────────┘ +## Kurzüberblick + +AdGuard Shield besteht in der Go-Version aus einem einzelnen Binary: + +```text +/opt/adguard-shield/adguard-shield ``` -## Ablauf einer Sperre +Das Binary übernimmt alle Aufgaben, die früher auf mehrere Shell-Skripte verteilt waren: -### Rate-Limit-Sperre +- Querylog-Polling über die AdGuard-Home-API +- Erkennung von Rate-Limit-Verstößen +- Erkennung von Random-Subdomain-Floods +- DNS-Flood-Watchlist mit sofortigem Permanent-Ban +- Verwaltung aktiver Sperren in SQLite +- Firewall-Steuerung über `ipset`, `iptables` und `ip6tables` +- automatische Freigabe abgelaufener temporärer Sperren +- externe Blocklisten und externe Whitelists +- GeoIP-Länderfilter +- progressive Sperren für Wiederholungstäter +- Benachrichtigungen und AbuseIPDB-Reporting +- E-Mail-Reports -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 -4. Monitor erkennt: 45 > 30 (Limit überschritten) -5. Prüfung: Ist der Client auf der Whitelist? → Nein -6. **Progressive Sperren:** Offense-Level wird geprüft/erhöht, Sperrdauer berechnet -7. iptables-Regel wird erstellt: `DROP` für `192.168.1.50` auf allen DNS-Ports -8. State-Datei wird angelegt: `/var/lib/adguard-shield/192.168.1.50.ban` -9. Offense-Datei wird aktualisiert: `/var/lib/adguard-shield/192.168.1.50.offenses` -10. Ban-History Eintrag wird in `/var/log/adguard-shield-bans.log` geschrieben -11. Log-Eintrag + optionale Webhook-Benachrichtigung -12. Nach Ablauf der (progressiven) Sperrdauer: automatische Entsperrung + History-Eintrag +## Datenfluss -### 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. - -### DNS-Flood-Watchlist-Sperre - -1. Client `10.0.0.42` fragt `microsoft.com` 35x in 60 Sekunden an -2. Monitor erkennt: 35 > 30 (Limit überschritten) -3. Domain `microsoft.com` steht auf der DNS-Flood-Watchlist → **sofortige permanente Sperre** -4. Progressive-Ban-Stufe wird ignoriert — kein stufenweises Hochstufen -5. IP wird an AbuseIPDB gemeldet (falls aktiviert) -6. Permanente Sperre bleibt bis zur manuellen Freigabe aktiv - -> **Hinweis:** Die Watchlist greift sowohl bei normalen Rate-Limit-Verstößen als auch bei Subdomain-Flood-Erkennungen. Subdomains werden automatisch erkannt: `foo.microsoft.com` matcht den Watchlist-Eintrag `microsoft.com`. - -## iptables Strategie - -Das Tool erstellt eine eigene Chain `ADGUARD_SHIELD`: - -``` -INPUT Chain - ├── ... (bestehende Regeln bleiben unberührt) - ├── -p tcp --dport 53 → ADGUARD_SHIELD - ├── -p udp --dport 53 → ADGUARD_SHIELD - ├── -p tcp --dport 443 → ADGUARD_SHIELD - ├── -p udp --dport 443 → ADGUARD_SHIELD - ├── -p tcp --dport 853 → ADGUARD_SHIELD - ├── -p udp --dport 853 → ADGUARD_SHIELD - └── ... - -ADGUARD_SHIELD Chain - ├── -s 192.168.1.50 → DROP (gesperrter Client) - ├── -s 10.0.0.25 → DROP (gesperrter Client) - └── RETURN (alle anderen passieren) +```text +Clients + | + | DNS, DoH, DoT, DoQ, DNSCrypt + v +AdGuard Home + | + | /control/querylog + v +AdGuard Shield Go-Daemon + | + |-- Rate-Limit-Prüfung pro Client + Domain + |-- Subdomain-Flood-Prüfung pro Client + Basisdomain + |-- Watchlist-Prüfung + |-- Whitelist-Prüfung + |-- GeoIP-Prüfung + |-- externe Listen + v +SQLite State + | + v +ipset + iptables/ip6tables + | + v +DNS-relevante Ports werden für gesperrte Clients blockiert ``` -**Vorteile der eigenen Chain:** -- Greift nicht in bestehende Firewall-Regeln ein -- Kann komplett geflusht werden ohne andere Regeln zu beeinflussen -- Einfaches Debugging per `iptables -L ADGUARD_SHIELD` +Wichtig: AdGuard Shield analysiert nicht den Netzwerkverkehr direkt. Es liest das Querylog von AdGuard Home. Dadurch erkennt es auch Anfragen über moderne DNS-Protokolle, solange diese in AdGuard Home sichtbar sind. -## State-Management (SQLite) +## Laufzeit im produktiven Betrieb -Alle Laufzeitdaten werden in einer zentralen SQLite-Datenbank gespeichert: +Der systemd-Service startet den Daemon so: +```bash +/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run ``` + +Beim Start passiert in dieser Reihenfolge: + +1. Konfiguration wird geladen. +2. SQLite-Datenbank unter `STATE_DIR` wird geöffnet oder angelegt. +3. Logdatei wird geöffnet. +4. Firewall-Chain und ipsets werden vorbereitet. +5. GeoIP-Datenbank wird geöffnet, falls GeoIP aktiv ist. +6. Whitelist-Cache wird aus SQLite geladen. +7. GeoIP-Sperren werden gegen die aktuelle GeoIP-Konfiguration geprüft. +8. Aktive Sperren aus SQLite werden wieder in die Firewall geschrieben. +9. Hintergrundjobs für externe Whitelist, externe Blocklist und Offense-Cleanup starten, falls aktiviert. +10. Der zentrale Querylog-Poller beginnt mit der regelmäßigen Auswertung. + +Das Reconcile beim Start ist wichtig: Wenn der Server neu startet oder iptables-Regeln verloren gehen, bleiben die Sperren in SQLite erhalten und werden beim nächsten Start wieder in die Firewall übertragen. + +## Querylog-Poller + +Der Daemon ruft regelmäßig den AdGuard-Home-Endpunkt ab: + +```text +/control/querylog?limit=&response_status=all +``` + +Gesteuert wird das über: + +```bash +CHECK_INTERVAL=10 +API_QUERY_LIMIT=500 +``` + +Aus jedem Querylog-Eintrag werden diese Informationen extrahiert: + +| Feld | Verwendung | +|---|---| +| Zeitstempel | Bestimmt, ob die Anfrage im aktuellen Zeitfenster liegt | +| Client-IP | Schlüssel für Rate-Limit, Whitelist, GeoIP und Firewall | +| Domain | Schlüssel für Rate-Limit und Subdomain-Flood | +| `client_proto` | Anzeige von DNS, DoH, DoT, DoQ oder DNSCrypt | + +Bereits gesehene Querylog-Einträge werden im Speicher dedupliziert. Der Daemon hält nur Ereignisse aus dem relevanten Zeitfenster plus kleinem Puffer vor. + +## Rate-Limit-Sperre + +Eine Rate-Limit-Sperre entsteht, wenn ein Client dieselbe Domain innerhalb des konfigurierten Fensters zu oft abfragt. + +Beispiel: + +```bash +RATE_LIMIT_MAX_REQUESTS=30 +RATE_LIMIT_WINDOW=60 +``` + +Ablauf: + +1. Client `192.168.1.50` fragt `example.com` 45-mal innerhalb von 60 Sekunden ab. +2. Der Poller sieht diese Einträge im Querylog. +3. AdGuard Shield zählt pro Client und Domain. +4. `45 > 30`, also ist das Limit überschritten. +5. Die IP wird gegen statische und externe Whitelists geprüft. +6. Falls die Domain nicht auf der Watchlist steht, entsteht eine normale `rate-limit`-Sperre. +7. Bei aktivem Progressive-Ban wird die aktuelle Offense-Stufe berechnet. +8. Die IP wird in SQLite gespeichert und per Firewall blockiert. +9. History, Log und optionale Benachrichtigung werden geschrieben. + +## Subdomain-Flood-Erkennung + +Random-Subdomain-Floods sehen anders aus als normale Wiederholungen. Ein Client fragt nicht eine Domain ständig neu ab, sondern viele zufällige Subdomains: + +```text +a8f3.example.com +k29x.example.com +z9p1.example.com +``` + +AdGuard Shield extrahiert daraus die Basisdomain `example.com` und zählt pro Client, wie viele unterschiedliche Subdomains im Fenster vorkommen. + +Gesteuert wird das über: + +```bash +SUBDOMAIN_FLOOD_ENABLED=true +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 +SUBDOMAIN_FLOOD_WINDOW=60 +``` + +Ablauf: + +1. Client `10.0.0.99` fragt 63 verschiedene Subdomains von `example.com` ab. +2. Direkte Anfragen an `example.com` zählen für diese Erkennung nicht. +3. Sobald mehr als `SUBDOMAIN_FLOOD_MAX_UNIQUE` eindeutige Subdomains erkannt werden, wird gesperrt. +4. In der History erscheint die Domain als `*.example.com`. +5. Der Grund lautet `subdomain-flood`, außer die Basisdomain steht auf der DNS-Flood-Watchlist. + +## DNS-Flood-Watchlist + +Die Watchlist ist für Domains gedacht, bei denen du nicht stufenweise reagieren möchtest. Wenn eine Domain auf der Watchlist steht und gleichzeitig ein Rate-Limit- oder Subdomain-Flood-Verstoß erkannt wird, wird sofort permanent gesperrt. + +```bash +DNS_FLOOD_WATCHLIST_ENABLED=true +DNS_FLOOD_WATCHLIST="microsoft.com,google.com" +``` + +Matching: + +- `microsoft.com` matcht `microsoft.com` +- `login.microsoft.com` matcht ebenfalls `microsoft.com` +- `evil-microsoft.com` matcht nicht + +Bei einem Treffer: + +- Reason wird `dns-flood-watchlist` +- Sperre ist permanent +- Progressive-Ban-Stufen werden für die Dauer ignoriert +- AbuseIPDB-Reporting kann ausgelöst werden, wenn es aktiviert und ein API-Key vorhanden ist + +## Progressive Sperren + +Progressive Sperren erhöhen die Sperrdauer bei wiederholten Monitor-Sperren. Das Verhalten ähnelt fail2ban. + +Standard: + +```bash +BAN_DURATION=3600 +PROGRESSIVE_BAN_ENABLED=true +PROGRESSIVE_BAN_MULTIPLIER=2 +PROGRESSIVE_BAN_MAX_LEVEL=5 +PROGRESSIVE_BAN_RESET_AFTER=86400 +``` + +Beispiel: + +| Vergehen | Stufe | Dauer | +|---|---:|---| +| 1 | 1 | 1 Stunde | +| 2 | 2 | 2 Stunden | +| 3 | 3 | 4 Stunden | +| 4 | 4 | 8 Stunden | +| 5 | 5 | permanent | + +Der Offense-Zähler wird in SQLite gespeichert. Wenn eine IP länger als `PROGRESSIVE_BAN_RESET_AFTER` nicht auffällig war, kann der Cleanup sie entfernen. + +Progressive Sperren gelten für Monitor-Sperren. GeoIP- und externe Blocklist-Sperren haben eigene Regeln. + +## Firewall-Modell + +AdGuard Shield nutzt eine eigene Chain und zwei ipsets: + +```text +ADGUARD_SHIELD +adguard_shield_v4 +adguard_shield_v6 +``` + +Die Chain wird je nach `FIREWALL_MODE` in die passende Host-Chain eingehängt: + +| Modus | Parent-Chain | +|---|---| +| `host` / `docker-host` | `INPUT` | +| `docker-bridge` | `DOCKER-USER` | +| `hybrid` | `INPUT` und `DOCKER-USER` | + +Für klassische Installationen und Docker mit Host-Netzwerk sieht das so aus: + +```text +INPUT + |- tcp/53 -> ADGUARD_SHIELD + |- udp/53 -> ADGUARD_SHIELD + |- tcp/443 -> ADGUARD_SHIELD + |- udp/443 -> ADGUARD_SHIELD + |- tcp/853 -> ADGUARD_SHIELD + |- udp/853 -> ADGUARD_SHIELD + +ADGUARD_SHIELD + |- src in adguard_shield_v4 -> DROP + |- src in adguard_shield_v6 -> DROP +``` + +Bei Docker Bridge mit veröffentlichten Ports ersetzt `DOCKER-USER` die `INPUT`-Chain im oberen Teil des Diagramms. Docker leitet solche Pakete nach DNAT über `FORWARD`; `INPUT` sieht sie dort nicht zuverlässig. + +Die Ports kommen aus: + +```bash +BLOCKED_PORTS="53 443 853" +``` + +Das blockiert klassische DNS-Anfragen und die üblichen Ports für DoH, DoT und DoQ. Die Erkennung selbst basiert weiterhin auf dem AdGuard-Home-Querylog. + +Warum `ipset`? + +- viele gesperrte IPs erzeugen nicht tausende einzelne iptables-Regeln +- IPv4 und IPv6 werden getrennt sauber verwaltet +- Sperren und Freigaben sind schneller +- die eigene Chain bleibt übersichtlich + +## SQLite-State + +Der zentrale Zustand liegt standardmäßig hier: + +```text /var/lib/adguard-shield/adguard-shield.db ``` -Die Datenbank enthält folgende Tabellen: +Wichtige Tabellen: -| Tabelle | Beschreibung | -|---------|--------------| -| `active_bans` | Aktive Sperren (IP, Domain, Sperrdauer, Offense-Level, Grund, Quelle, GeoIP) | -| `offense_tracking` | Offense-Zähler für progressive Sperren (Level, letztes/erstes Vergehen) | -| `ban_history` | Vollständige Ban-History (alle Sperren und Entsperrungen) | -| `whitelist_cache` | Cache der aufgelösten externen Whitelist-IPs | -| `schema_version` | Datenbank-Schema-Version für zukünftige Migrationen | +| Tabelle | Inhalt | +|---|---| +| `active_bans` | aktuell aktive Sperren mit IP, Grund, Dauer, Quelle und Ablaufzeit | +| `ban_history` | dauerhafte Historie von `BAN`, `UNBAN` und `DRY` | +| `offense_tracking` | Progressive-Ban-Stufen pro Client-IP | +| `whitelist_cache` | aufgelöste IPs aus externen Whitelists | +| `geoip_cache` | gecachte GeoIP-Ergebnisse | -**Vorteile gegenüber Flat-Files:** -- Schnellere Abfragen, besonders bei vielen aktiven Sperren -- Atomare Transaktionen — kein Datenverlust bei Stromausfall -- WAL-Modus für parallelen Lese-/Schreibzugriff -- Indexierte Suche nach IP, Zeitstempel, Quelle und Aktion -- Kompakte Speicherung statt tausender Einzeldateien +Die Datenbank nutzt WAL-Modus und einen Busy-Timeout, damit Daemon und CLI-Befehle gleichzeitig lesen können. -Die zentrale Datenbankbibliothek (`db.sh`) wird von allen Scripts per `source db.sh` eingebunden und stellt typisierte Funktionen für alle Tabellen bereit (z.B. `db_ban_insert`, `db_offense_get_level`, `db_history_add`). +## Verzeichnisstruktur -### Migration von Flat-Files +Nach einer Standardinstallation sieht die Struktur so aus: -Beim Update auf die SQLite-Version werden bestehende Flat-File-Daten (`.ban`, `.offenses`, Ban-History-Log, Whitelist-Cache) automatisch in die Datenbank migriert. Die alten Dateien werden als Backup nach `/var/lib/adguard-shield/.backup_pre_sqlite/` verschoben. Die Migration läuft einmalig beim Update und zeigt den Fortschritt im Terminal an. - -## Dateistruktur nach Installation - -``` +```text /opt/adguard-shield/ -├── adguard-shield.sh # Haupt-Monitor-Script -├── adguard-shield.conf # Konfiguration (chmod 600) -├── adguard-shield.conf.old # Backup der Konfig nach Update -├── adguard-shield-watchdog.sh # Watchdog Health-Check-Script -├── iptables-helper.sh # iptables Verwaltung -├── external-blocklist-worker.sh # Externer Blocklist-Worker -├── external-whitelist-worker.sh # Externer Whitelist-Worker (DNS-Auflösung) -├── geoip-worker.sh # GeoIP-Länderfilter-Worker -├── offense-cleanup-worker.sh # Aufräumen abgelaufener Offense-Zähler (nice 19, idle I/O) -├── db.sh # SQLite Datenbank-Bibliothek (wird von allen Scripts eingebunden) -├── unban-expired.sh # Cron-basiertes Entsperren -└── geoip/ # Auto-Download MaxMind GeoLite2 DB (optional) +├── adguard-shield # Go-Binary +├── adguard-shield.conf # Konfiguration, chmod 600 +├── adguard-shield.conf.old # Backup nach Konfigurationsmigration +└── geoip/ # automatische MaxMind-Downloads /etc/systemd/system/ -├── adguard-shield.service # systemd Service (Autostart aktiv) -├── adguard-shield-watchdog.service # systemd Watchdog-Unit (oneshot) -└── adguard-shield-watchdog.timer # systemd Timer (alle 5 Min.) +└── adguard-shield.service /var/lib/adguard-shield/ -├── adguard-shield.db # SQLite-Datenbank (Bans, Offenses, History, Whitelist-Cache) -├── .migration_v1_complete # Marker: Flat-File-Migration abgeschlossen -├── .backup_pre_sqlite/ # Backup der alten Flat-Files nach Migration -├── external-blocklist/ # Cache für externe Blocklisten -├── external-whitelist/ # Cache für externe Whitelisten -└── geoip-cache/ # Cache für GeoIP-Lookups (24h) +├── adguard-shield.db +├── external-blocklist/ +├── external-whitelist/ +├── iptables-rules.v4 +└── iptables-rules.v6 /var/log/ -├── adguard-shield.log # Laufzeit-Log -└── adguard-shield-bans.log # Ban-History (Legacy, wird nach Migration nicht mehr geschrieben) +└── adguard-shield.log ``` -## Installer-Architektur +## Hintergrundjobs im Daemon -Der Installer (`install.sh`) bietet ein interaktives Menü und folgende Funktionen: +Es gibt in der Go-Version keine separaten Worker-Skripte mehr. Diese Aufgaben laufen als Goroutines im Daemon: -| Befehl | Beschreibung | -|--------|--------------| -| `install` | Vollständige Neuinstallation (Abhängigkeiten, Dateien, Konfiguration, Service, Watchdog) | -| `update` | Update mit automatischer Konfigurations-Migration, Datenbank-Migration, Watchdog-Aktivierung und Service-Neustart | -| `uninstall` | Deinstallation mit optionalem Behalten der Konfiguration | -| `status` | Installationsstatus, Version und Service-Status anzeigen | -| `--help` | Hilfe und Befehlsübersicht | +| Aufgabe | Wann aktiv | Zweck | +|---|---|---| +| Querylog-Poller | immer | liest und analysiert AdGuard-Home-Querylogs | +| externe Whitelist | `EXTERNAL_WHITELIST_ENABLED=true` | lädt Listen, löst Hostnamen auf, aktualisiert Whitelist-Cache | +| externe Blocklist | `EXTERNAL_BLOCKLIST_ENABLED=true` | lädt Listen, sperrt gewünschte IPs und hebt entfernte IPs optional auf | +| Offense-Cleanup | `PROGRESSIVE_BAN_ENABLED=true` | entfernt abgelaufene Offense-Zähler | +| GeoIP-Lookups | `GEOIP_ENABLED=true` | prüft neue öffentliche Client-IPs gegen Länderregeln | -### Konfigurations-Migration beim Update +Externe Whitelist und Blocklist laufen sofort beim Start einmalig und danach im jeweiligen Intervall. -``` -┌─────────────────────────┐ ┌─────────────────────────┐ -│ Bestehende Konfig │ │ Neue Konfig (Repo) │ -│ (Benutzer-Settings) │ │ (mit neuen Parametern) │ -└───────────┬─────────────┘ └───────────┬─────────────┘ - │ │ - ▼ ▼ - ┌──────────────────────────────────────────┐ - │ Konfigurations-Migration │ - │ 1. Backup als .conf.old erstellen │ - │ 2. Alle Schlüssel vergleichen │ - │ 3. Neue Schlüssel zur Konfig ergänzen │ - │ 4. Bestehende Werte NICHT ändern │ - └──────────────────────┬───────────────────┘ - ▼ - ┌──────────────────────────┐ - │ Aktualisierte Konfig │ - │ (alte Werte + neue Keys) │ - └──────────────────────────┘ -``` +## Whitelist-Logik -## Ban-History +Vor jeder Sperre wird geprüft, ob die IP vertrauenswürdig ist. -Jede Sperre und Entsperrung wird dauerhaft in der SQLite-Datenbank protokolliert (Tabelle `ban_history`). Das ermöglicht eine lückenlose Nachvollziehbarkeit mit indexierter Suche nach IP, Zeitstempel und Aktion. +Quellen: -**Gespeicherte Felder pro Eintrag:** -| Feld | Beschreibung | -|------|--------------| -| `timestamp_epoch` | Unix-Zeitstempel | -| `timestamp_text` | Lesbarer Zeitstempel | -| `action` | `BAN` oder `UNBAN` | -| `client_ip` | Betroffene IP-Adresse | -| `domain` | Angefragte Domain | -| `count` | Anzahl der Anfragen | -| `duration` | Sperrdauer | -| `protocol` | Verwendetes DNS-Protokoll | -| `reason` | Sperrgrund | +- statische `WHITELIST` aus der Konfiguration +- aufgelöste IPs aus externen Whitelists -**Mögliche Gründe (GRUND-Spalte):** -| Grund | Bedeutung | -|-------|----------| -| `rate-limit` | Automatische Sperre wegen Limit-Überschreitung | -| `subdomain-flood` | Sperre wegen zu vieler eindeutiger Subdomains einer Basisdomain | -| `dns-flood-watchlist` | Sofortige permanente Sperre + AbuseIPDB-Meldung (Domain auf der Watchlist) | -| `dry-run` | Im Dry-Run erkannt (nicht wirklich gesperrt) | -| `dry-run (subdomain-flood)` | Subdomain-Flood im Dry-Run erkannt | -| `dry-run (dns-flood-watchlist)` | Watchlist-Treffer 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 | -| `manual-flush` | Entsperrt durch `flush`-Befehl (alle Sperren aufgehoben) | +Eine gewhitelistete IP wird nicht gesperrt. Wenn eine externe Whitelist später eine bereits gesperrte IP enthält, hebt der Daemon diese Sperre automatisch auf. + +## GeoIP-Logik + +GeoIP arbeitet nur mit öffentlichen IPs, wenn `GEOIP_SKIP_PRIVATE=true` gesetzt ist. Private Netze, Loopback, Link-Local und CGNAT werden übersprungen. + +Modi: + +| Modus | Verhalten | +|---|---| +| `blocklist` | Länder aus `GEOIP_COUNTRIES` werden gesperrt | +| `allowlist` | nur Länder aus `GEOIP_COUNTRIES` sind erlaubt, alle anderen öffentlichen Länder werden gesperrt | + +GeoIP-Sperren sind permanent, werden aber beim Start gegen die aktuelle Konfiguration geprüft. Wenn GeoIP deaktiviert wird, der Modus wechselt oder ein Land nicht mehr blockiert werden müsste, kann die Sperre automatisch aufgehoben werden. + +## AbuseIPDB-Reporting + +AbuseIPDB wird nur für permanente Monitor-Sperren genutzt: + +- DNS-Flood-Watchlist-Treffer +- Progressive-Ban-Sperren, die die maximale Stufe erreicht haben + +Nicht gemeldet werden: + +- temporäre Rate-Limit-Sperren +- manuelle Sperren +- GeoIP-Sperren +- externe Blocklist-Sperren + +Voraussetzung: -**History anzeigen:** ```bash -sudo /opt/adguard-shield/adguard-shield.sh history # letzte 50 -sudo /opt/adguard-shield/adguard-shield.sh history 200 # letzte 200 +ABUSEIPDB_ENABLED=true +ABUSEIPDB_API_KEY="..." ``` + +## History und Logs + +Es gibt zwei unterschiedliche Blickwinkel: + +| Quelle | Inhalt | +|---|---| +| `ban_history` in SQLite | Sperren, Freigaben und Dry-Run-Ereignisse | +| `LOG_FILE` | Daemon-Ereignisse, Worker-Läufe, Warnungen, Fehler | + +Query-Inhalte werden nicht dauerhaft in die Logdatei geschrieben. Für aktuelle Queries gibt es die Live-Ansicht: + +```bash +sudo /opt/adguard-shield/adguard-shield live +``` + +History: + +```bash +sudo /opt/adguard-shield/adguard-shield history +sudo /opt/adguard-shield/adguard-shield history 200 +``` + +Logs: + +```bash +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 +sudo journalctl -u adguard-shield -f +``` + +## Unterschied zur alten Shell-Architektur + +Früher gab es unter anderem: + +- `adguard-shield.sh` +- `iptables-helper.sh` +- `external-blocklist-worker.sh` +- `external-whitelist-worker.sh` +- `geoip-worker.sh` +- `offense-cleanup-worker.sh` +- `report-generator.sh` +- `unban-expired.sh` +- Watchdog-Service und Watchdog-Timer + +In der Go-Version gibt es diese Skripte nicht mehr. Der systemd-Service nutzt `Restart=on-failure`; die eigentlichen Worker laufen im Daemon. Alte Artefakte werden vom Installer erkannt und müssen vor der Go-Installation entfernt werden, damit keine zwei Implementierungen parallel dieselbe Firewall und dieselben Dateien verwalten. diff --git a/docs/befehle.md b/docs/befehle.md index 7c35f49..d8b9919 100644 --- a/docs/befehle.md +++ b/docs/befehle.md @@ -1,501 +1,742 @@ # Befehle & Nutzung -## Installer / Updater - -Der Installer bietet ein interaktives Menü wenn er ohne Argumente aufgerufen wird: +AdGuard Shield wird in der Go-Version über ein einzelnes Binary bedient: ```bash -# Interaktives Menü anzeigen -sudo bash install.sh - -# Neuinstallation -sudo bash install.sh install - -# Update (mit automatischer Konfigurations-Migration) -sudo bash install.sh update - -# Deinstallation (delegiert automatisch an den installierten Uninstaller) -sudo bash install.sh uninstall - -# Installationsstatus anzeigen -sudo bash install.sh status - -# Hilfe anzeigen -sudo bash install.sh --help +/opt/adguard-shield/adguard-shield ``` -## Uninstaller (eigenständig) +Dieses Binary ist Daemon, CLI, Installer, Updater, Uninstaller und Report-Generator. Dadurch gibt es keine getrennten Shell-Skripte mehr. -Ab Version 0.5.2 wird bei der Installation ein eigenständiger Uninstaller nach `/opt/adguard-shield/uninstall.sh` kopiert. Die Deinstallation kann damit **ohne die originalen Installationsdateien** durchgeführt werden: +## Grundform ```bash -# Direkt aus dem Installationsverzeichnis — kein install.sh benötigt -sudo bash /opt/adguard-shield/uninstall.sh +sudo /opt/adguard-shield/adguard-shield ``` -Der Uninstaller kennt seinen Speicherort und leitet daraus automatisch das Installationsverzeichnis ab. `install.sh uninstall` delegiert intern ebenfalls dorthin — beide Wege führen zum selben Ergebnis. - -### Update-Verhalten - -Beim Update passiert automatisch: -1. Alle Scripts werden aktualisiert -2. Die bestehende Konfiguration wird als `adguard-shield.conf.old` gesichert -3. Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig hinzugefügt -4. Bestehende Einstellungen bleiben **immer** erhalten -5. Bestehende Flat-File-Daten werden einmalig (mit einem Update kommend von einer v0.9.0 oder älter) in die SQLite-Datenbank migriert (mit Fortschrittsanzeige und Backup) -6. Der systemd Service und Watchdog-Timer werden per `daemon-reload` neu geladen -7. Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) -8. 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`): -- `curl` — API-Kommunikation mit AdGuard Home -- `jq` — JSON-Verarbeitung der API-Antworten -- `iptables` — Firewall-Regeln für IP-Sperren -- `gawk` — Textverarbeitung -- `systemd` — Service-Management -- `sqlite3` — Datenbank für State-Management, Ban-History und Offense-Tracking - -## systemd Service - -AdGuard Shield wird als systemd Service betrieben. **Zum Starten, Stoppen und Neustarten immer `systemctl` verwenden:** +Wenn du eine andere Konfigurationsdatei verwenden möchtest, muss `-config` direkt vor dem Befehl stehen: ```bash -# Start / Stop / Restart +sudo /opt/adguard-shield/adguard-shield -config /pfad/zur/adguard-shield.conf status +``` + +Standardpfade: + +```text +Konfiguration: /opt/adguard-shield/adguard-shield.conf +SQLite-State: /var/lib/adguard-shield/adguard-shield.db +Logdatei: /var/log/adguard-shield.log +PID-Datei: /var/run/adguard-shield.pid +``` + +## Schnellübersicht + +```bash +# Version +/opt/adguard-shield/adguard-shield version + +# Installation und Update +sudo ./adguard-shield install +sudo ./adguard-shield update +sudo ./adguard-shield install-status +sudo /opt/adguard-shield/adguard-shield uninstall --keep-config + +# Service sudo systemctl start adguard-shield sudo systemctl stop adguard-shield sudo systemctl restart adguard-shield - -# Status sudo systemctl status adguard-shield -# Autostart aktivieren / deaktivieren +# Diagnose +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield live +sudo /opt/adguard-shield/adguard-shield history 100 +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 + +# Manuelle Eingriffe +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 +sudo /opt/adguard-shield/adguard-shield ban 192.168.1.100 +sudo /opt/adguard-shield/adguard-shield flush +``` + +## Installation + +Das installierte Binary landet standardmäßig unter: + +```text +/opt/adguard-shield/adguard-shield +``` + +Typischer Ablauf: + +```bash +# Binary ausführbar machen +chmod +x ./adguard-shield + +# Standardinstallation +sudo ./adguard-shield install + +# Bestehende Konfigurationsdatei als Vorlage übernehmen +sudo ./adguard-shield install --config-source ./adguard-shield.conf +``` + +Am Ende fragt der Installer, ob AdGuard Shield direkt gestartet oder neu gestartet werden soll. + +Weitere Optionen: + +```bash +# Paketprüfung überspringen +sudo ./adguard-shield install --skip-deps + +# systemd-Autostart nicht aktivieren +sudo ./adguard-shield install --no-enable + +# abweichendes Installationsverzeichnis +sudo ./adguard-shield install --install-dir /opt/adguard-shield-test +``` + +Der Installer erledigt: + +1. Linux- und root-Prüfung +2. Prüfung auf alte Shell-Artefakte +3. Installation fehlender Abhängigkeiten über `apt-get`, sofern möglich +4. Anlage von Installations- und State-Verzeichnissen +5. Kopieren des laufenden Binarys +6. Anlage oder Migration der Konfiguration +7. Schreiben der systemd-Unit +8. `systemctl daemon-reload` +9. optional Autostart aktivieren +10. fragen, ob der Service direkt gestartet oder neu gestartet werden soll + +Benötigte Systembefehle: + +```text +iptables +ip6tables +ipset +systemctl +``` + +Auf Debian/Ubuntu installiert der Installer passende Pakete automatisch, sofern `apt-get` verfügbar ist und `--skip-deps` nicht gesetzt wurde. + +## Update + +Ein Update wird mit dem neuen Binary ausgeführt, nicht mit dem bereits installierten alten Binary. + +```bash +chmod +x ./adguard-shield +sudo ./adguard-shield update +``` + +Am Ende fragt der Updater, ob AdGuard Shield direkt neu gestartet werden soll. + +Mit expliziter Konfigurationsquelle: + +```bash +sudo ./adguard-shield update --config-source ./adguard-shield.conf +``` + +Beim Update: + +- wird die Installation wie bei `install` aktualisiert +- bleibt die vorhandene Konfiguration erhalten +- werden neue Konfigurationsparameter ergänzt +- wird bei einer Migration `adguard-shield.conf.old` geschrieben +- wird die systemd-Unit neu geschrieben +- wird systemd neu geladen + +## Installationsstatus + +```bash +sudo ./adguard-shield install-status +``` + +Für ein anderes Installationsverzeichnis: + +```bash +sudo ./adguard-shield install-status --install-dir /opt/adguard-shield-test +``` + +`install-status` zeigt: + +- Installationspfad +- Binary vorhanden +- installierte Version +- Konfiguration vorhanden +- systemd-Service vorhanden +- Autostart aktiv +- Service aktiv +- gefundene Legacy-Artefakte + +## Deinstallation + +```bash +# Alles entfernen +sudo /opt/adguard-shield/adguard-shield uninstall + +# Konfiguration behalten +sudo /opt/adguard-shield/adguard-shield uninstall --keep-config +``` + +Bei der Deinstallation wird: + +1. der Service gestoppt +2. der Autostart deaktiviert +3. die Shield-Firewall-Struktur entfernt +4. die systemd-Unit gelöscht +5. systemd neu geladen +6. je nach Option Installationsverzeichnis, State und Log entfernt + +Mit `--keep-config` bleiben Konfigurationsdaten erhalten. Das ist sinnvoll, wenn du neu installieren oder migrieren möchtest. + +## Alte Shell-Installation + +Die Go-Version darf nicht parallel zur alten Shell-Version laufen. Der Installer bricht ab, wenn er alte Artefakte findet, zum Beispiel: + +```text +/opt/adguard-shield/adguard-shield.sh +/opt/adguard-shield/iptables-helper.sh +/opt/adguard-shield/external-blocklist-worker.sh +/opt/adguard-shield/geoip-worker.sh +/etc/systemd/system/adguard-shield-watchdog.timer +``` + +Empfohlener Ablauf: + +1. Bestehende `/opt/adguard-shield/adguard-shield.conf` sichern. +2. Alte Shell-Version mit deren Uninstaller entfernen und die Konfiguration behalten. +3. Go-Binary erneut installieren. +4. Konfiguration prüfen. +5. Zuerst `dry-run`, dann produktiven Service starten. + +## systemd-Service + +Im produktiven Betrieb sollte AdGuard Shield über systemd laufen: + +```bash +sudo systemctl start adguard-shield +sudo systemctl stop adguard-shield +sudo systemctl restart adguard-shield +sudo systemctl status adguard-shield +``` + +Autostart: + +```bash 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. - -## Watchdog (automatischer Health Check) - -Der Watchdog prüft alle 5 Minuten ob der Hauptservice läuft und startet ihn bei Bedarf automatisch neu. Er wird als systemd Timer betrieben und bei der Installation automatisch aktiviert. +Nach manuellen Änderungen an der Unit: ```bash -# Watchdog-Status -sudo systemctl status adguard-shield-watchdog.timer - -# Nächste geplante Ausführung anzeigen -sudo systemctl list-timers adguard-shield-watchdog.timer - -# Watchdog aktivieren / deaktivieren -sudo systemctl enable adguard-shield-watchdog.timer -sudo systemctl disable adguard-shield-watchdog.timer - -# Watchdog starten / stoppen -sudo systemctl start adguard-shield-watchdog.timer -sudo systemctl stop adguard-shield-watchdog.timer - -# Watchdog-Logs anzeigen -sudo journalctl -u adguard-shield-watchdog.service --no-pager -n 20 +sudo systemctl daemon-reload ``` -> **Hinweis:** Der Watchdog sendet automatisch Benachrichtigungen (falls `NOTIFY_ENABLED=true`), wenn er den Service wiederbeleben muss oder die Recovery fehlschlägt. - -## Monitor — Verwaltungsbefehle - -Die folgenden Befehle dienen der **Verwaltung und Diagnose** und können jederzeit ausgeführt werden, auch während der Service läuft: +Die Unit startet: ```bash -# Status + aktive Sperren anzeigen -sudo /opt/adguard-shield/adguard-shield.sh status - -# Ban-History anzeigen (letzte 50 Einträge) -sudo /opt/adguard-shield/adguard-shield.sh history - -# Ban-History anzeigen (letzte 100 Einträge) -sudo /opt/adguard-shield/adguard-shield.sh history 100 - -# Alle Sperren aufheben -sudo /opt/adguard-shield/adguard-shield.sh flush - -# Einzelne IP entsperren -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 — läuft im Vordergrund!) -sudo /opt/adguard-shield/adguard-shield.sh dry-run - -# Offense-Zähler für alle IPs zurücksetzen (Progressive Sperren) -sudo /opt/adguard-shield/adguard-shield.sh reset-offenses - -# Offense-Zähler für eine bestimmte IP zurücksetzen -sudo /opt/adguard-shield/adguard-shield.sh reset-offenses 192.168.1.100 - -# Externe Blocklist - Status anzeigen -sudo /opt/adguard-shield/adguard-shield.sh blocklist-status - -# Externe Blocklist - Einmalige Synchronisation -sudo /opt/adguard-shield/adguard-shield.sh blocklist-sync - -# Externe Blocklist - Alle Sperren der externen Liste aufheben -sudo /opt/adguard-shield/adguard-shield.sh blocklist-flush +/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run ``` -> **⚠ 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. +Die Go-Version nutzt `Restart=on-failure`. Einen separaten Watchdog-Service oder Watchdog-Timer gibt es nicht mehr. -## iptables Helper +## Daemon direkt starten -Für die manuelle Verwaltung der Firewall-Regeln: +Für Debugging oder Dry-Run kann der Daemon im Vordergrund gestartet werden: ```bash -# Chain erstellen -sudo /opt/adguard-shield/iptables-helper.sh create +# normaler Vordergrundlauf +sudo /opt/adguard-shield/adguard-shield run -# Alle Regeln anzeigen -sudo /opt/adguard-shield/iptables-helper.sh status +# Alias für run +sudo /opt/adguard-shield/adguard-shield start -# IP manuell sperren -sudo /opt/adguard-shield/iptables-helper.sh ban 192.168.1.100 - -# IP entsperren -sudo /opt/adguard-shield/iptables-helper.sh unban 192.168.1.100 - -# Alle Regeln leeren -sudo /opt/adguard-shield/iptables-helper.sh flush - -# Chain komplett entfernen -sudo /opt/adguard-shield/iptables-helper.sh remove - -# Regeln speichern / wiederherstellen -sudo /opt/adguard-shield/iptables-helper.sh save -sudo /opt/adguard-shield/iptables-helper.sh restore +# analysieren ohne echte Sperren +sudo /opt/adguard-shield/adguard-shield dry-run ``` -## Externer Whitelist-Worker - -Der Whitelist-Worker löst Domains aus externen Listen regelmäßig per DNS auf und stellt die IPs als dynamische Whitelist bereit: +Stop über PID-Datei: ```bash -# Status anzeigen (aufgelöste IPs, konfigurierte Listen) -sudo /opt/adguard-shield/adguard-shield.sh whitelist-status - -# Einmalige Synchronisation (z.B. nach Konfigurationsänderung) -sudo /opt/adguard-shield/adguard-shield.sh whitelist-sync - -# Alle aufgelösten Whitelist-IPs entfernen -sudo /opt/adguard-shield/adguard-shield.sh whitelist-flush +sudo /opt/adguard-shield/adguard-shield stop ``` -Der Worker kann auch standalone gesteuert werden: +Für den Alltag gilt: Nutze `systemctl`. Der direkte Vordergrundlauf endet, sobald die Shell beendet wird oder du `Strg+C` drückst. + +## API-Test ```bash -# Worker manuell starten (normalerweise automatisch per Hauptscript) -sudo /opt/adguard-shield/external-whitelist-worker.sh start - -# Worker stoppen -sudo /opt/adguard-shield/external-whitelist-worker.sh stop - -# Einmalige Synchronisation -sudo /opt/adguard-shield/external-whitelist-worker.sh sync - -# Status anzeigen -sudo /opt/adguard-shield/external-whitelist-worker.sh status - -# Aufgelöste IPs entfernen -sudo /opt/adguard-shield/external-whitelist-worker.sh flush +sudo /opt/adguard-shield/adguard-shield test ``` -## Externer Blocklist-Worker +Der Test ruft `/control/querylog` auf und prüft damit: -Der Worker kann auch standalone gesteuert werden: +- ist `ADGUARD_URL` erreichbar? +- funktionieren HTTP/TLS und Netzwerk? +- stimmen `ADGUARD_USER` und `ADGUARD_PASS`? +- liefert AdGuard Home Querylog-Daten? + +Bei Erfolg erscheint sinngemäß: + +```text +Verbindung erfolgreich. 123 Querylog-Einträge gefunden. +``` + +Wenn der Test fehlschlägt, zuerst die Konfiguration und die AdGuard-Home-Weboberfläche prüfen. + +## Status ```bash -# Worker manuell starten (normalerweise automatisch per Hauptscript) -sudo /opt/adguard-shield/external-blocklist-worker.sh start - -# Worker stoppen -sudo /opt/adguard-shield/external-blocklist-worker.sh stop - -# Einmalige Synchronisation (z.B. nach Konfigurationsänderung) -sudo /opt/adguard-shield/external-blocklist-worker.sh sync - -# Status anzeigen -sudo /opt/adguard-shield/external-blocklist-worker.sh status - -# Alle externen Sperren aufheben -sudo /opt/adguard-shield/external-blocklist-worker.sh flush +sudo /opt/adguard-shield/adguard-shield status ``` -## GeoIP-Worker (Länderfilter) +`status` zeigt: -Der GeoIP-Worker prüft Client-IPs auf ihr Herkunftsland und sperrt/erlaubt sie basierend auf der Konfiguration: +- verwendete Konfigurationsdatei +- Firewall-Backend und Chain +- GeoIP-Aktivierung, Modus und Länder +- externe Blocklist und Anzahl der URLs +- externe Whitelist und Anzahl der URLs +- aktive Sperren mit IP, Quelle, Grund und Ablaufzeit + +Bei sehr vielen aktiven Sperren werden nur die ersten 50 angezeigt. Details stehen in der History oder direkt in SQLite. + +## Live-Ansicht ```bash -# GeoIP-Status anzeigen (Modus, Länder, aktive Sperren, verfügbare Tools) -sudo /opt/adguard-shield/adguard-shield.sh geoip-status - -# Einmalige GeoIP-Prüfung aller aktiven Clients -sudo /opt/adguard-shield/adguard-shield.sh geoip-sync - -# Alle GeoIP-Sperren aufheben -sudo /opt/adguard-shield/adguard-shield.sh geoip-flush - -# GeoIP-Lookup für eine einzelne IP -sudo /opt/adguard-shield/adguard-shield.sh geoip-lookup 8.8.8.8 +sudo /opt/adguard-shield/adguard-shield live ``` -Der Worker kann auch standalone gesteuert werden: +`live` ist die beste Ansicht, wenn du verstehen möchtest, was gerade passiert. + +Sie zeigt: + +- Query-Poller, API-Einträge, Zeitfenster und Rate-Limit +- Top Client/Domain-Kombinationen +- Subdomain-Flood-Kandidaten +- letzte Querylog-Einträge +- aktive Sperren +- externe Listen +- GeoIP-Status +- Offense-Cleanup-Status +- Systemereignisse aus der Logdatei + +Optionen: ```bash -# Worker manuell starten (normalerweise automatisch per Hauptscript) -sudo /opt/adguard-shield/geoip-worker.sh start +# alle 2 Sekunden aktualisieren +sudo /opt/adguard-shield/adguard-shield live --interval 2 -# Worker stoppen -sudo /opt/adguard-shield/geoip-worker.sh stop +# Top 20 anzeigen +sudo /opt/adguard-shield/adguard-shield live --top 20 -# Einmalige Synchronisation -sudo /opt/adguard-shield/geoip-worker.sh sync +# mehr letzte Queries und Logs anzeigen +sudo /opt/adguard-shield/adguard-shield live --recent 25 -# Status anzeigen -sudo /opt/adguard-shield/geoip-worker.sh status +# DEBUG-Logs einblenden +sudo /opt/adguard-shield/adguard-shield live --logs debug -# IP nachschlagen -sudo /opt/adguard-shield/geoip-worker.sh lookup 1.2.3.4 +# Logbereich ausblenden +sudo /opt/adguard-shield/adguard-shield live --logs off -# Alle GeoIP-Sperren aufheben -sudo /opt/adguard-shield/geoip-worker.sh flush - -# GeoIP-Lookup-Cache leeren -sudo /opt/adguard-shield/geoip-worker.sh flush-cache +# nur einmaligen Snapshot ausgeben +sudo /opt/adguard-shield/adguard-shield live --once ``` -## Offense-Cleanup-Worker - -Der Offense-Cleanup-Worker räumt abgelaufene Offense-Zähler (progressive Sperren) automatisch auf. Er startet automatisch mit dem Hauptservice, wenn progressive Sperren aktiviert sind, und prüft stündlich ob Zähler aufgeräumt werden können. Der Worker läuft mit niedrigster CPU- und I/O-Priorität (`nice 19`, `ionice idle`), um den DNS-Dienst nicht zu beeinträchtigen. - -Der Worker kann auch standalone gesteuert werden: +Alias: ```bash -# Worker manuell starten (normalerweise automatisch per Hauptscript) -sudo /opt/adguard-shield/offense-cleanup-worker.sh start - -# Worker stoppen -sudo /opt/adguard-shield/offense-cleanup-worker.sh stop - -# Einmaliger Cleanup-Durchlauf -sudo /opt/adguard-shield/offense-cleanup-worker.sh run-once - -# Status anzeigen (aktive/abgelaufene Zähler) -sudo /opt/adguard-shield/offense-cleanup-worker.sh status +sudo /opt/adguard-shield/adguard-shield watch ``` -## E-Mail Report +## History ```bash -# Report sofort generieren und per E-Mail versenden -sudo /opt/adguard-shield/report-generator.sh send +# letzte 50 Einträge +sudo /opt/adguard-shield/adguard-shield history -# Test-E-Mail senden (prüft alle Voraussetzungen + Mailversand) -sudo /opt/adguard-shield/report-generator.sh test - -# Report als Datei generieren (Ausgabe auf stdout) -sudo /opt/adguard-shield/report-generator.sh generate - -# Report im HTML-Format in Datei speichern -sudo /opt/adguard-shield/report-generator.sh generate html > report.html - -# Report im TXT-Format in Datei speichern -sudo /opt/adguard-shield/report-generator.sh generate txt > report.txt - -# Cron-Job für automatischen Versand einrichten -sudo /opt/adguard-shield/report-generator.sh install - -# Cron-Job entfernen -sudo /opt/adguard-shield/report-generator.sh remove - -# Report-Konfiguration und Cron-Status anzeigen -sudo /opt/adguard-shield/report-generator.sh status +# letzte 200 Einträge +sudo /opt/adguard-shield/adguard-shield history 200 ``` -> Voraussetzung: Ein funktionierender Mail-Transport (z.B. msmtp). Anleitung: [Linux: Einfach E-Mails versenden mit msmtp](https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/) +Die History kommt aus der SQLite-Tabelle `ban_history`. Sie enthält: + +- `BAN`: echte Sperre +- `UNBAN`: Freigabe +- `DRY`: im Dry-Run erkannt, aber nicht gesperrt + +Format: + +```text +Zeit | Aktion | Client-IP | Domain | Anzahl | Dauer | Protokoll | Grund +``` + +Typische Gründe: + +| Grund | Bedeutung | +|---|---| +| `rate-limit` | gleiche Domain zu oft angefragt | +| `subdomain-flood` | zu viele eindeutige Subdomains einer Basisdomain | +| `dns-flood-watchlist` | Watchlist-Treffer mit Permanent-Ban | +| `external-blocklist` | Sperre aus externer Blocklist | +| `geoip` | GeoIP-Länderfilter | +| `manual` | manuelle Freigabe | +| `manual-flush` | Freigabe durch `flush` | +| `expired` | temporäre Sperre abgelaufen | +| `external-whitelist` | Freigabe durch externe Whitelist | ## Logs +AdGuard Shield schreibt Daemon-Ereignisse in `LOG_FILE`, standardmäßig: + +```text +/var/log/adguard-shield.log +``` + +CLI: + +```bash +# letzte INFO/WARN/ERROR-Einträge +sudo /opt/adguard-shield/adguard-shield logs + +# letzte 100 Warnungen und Fehler +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 + +# Kurzform +sudo /opt/adguard-shield/adguard-shield logs debug + +# laufende Ansicht +sudo /opt/adguard-shield/adguard-shield logs-follow --level info +``` + +Erlaubte Level: + +```text +DEBUG +INFO +WARN +ERROR +``` + +systemd-Journal: + ```bash -# systemd Journal sudo journalctl -u adguard-shield -f - -# Log-Datei direkt -sudo tail -f /var/log/adguard-shield.log - -# Nur Sperr-Einträge -sudo grep "SPERRE" /var/log/adguard-shield.log - -# Nur Entsperr-Einträge -sudo grep "ENTSPERRE" /var/log/adguard-shield.log +sudo journalctl -u adguard-shield --no-pager -n 100 ``` -## Cron-basiertes Entsperren +Hinweis: Query-Inhalte werden nicht dauerhaft in die Logdatei geschrieben. Für Query-nahe Diagnose ist `live` gedacht. -Als Alternative oder Ergänzung zum Haupt-Monitor: +## Manuelle Sperren und Freigaben ```bash -# Crontab bearbeiten -sudo crontab -e +# IP permanent sperren +sudo /opt/adguard-shield/adguard-shield ban 192.168.1.100 -# Alle 5 Minuten abgelaufene Sperren prüfen -*/5 * * * * /opt/adguard-shield/unban-expired.sh +# IP entsperren +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 + +# alle aktiven Sperren aufheben +sudo /opt/adguard-shield/adguard-shield flush ``` -## DNS-Abfragen zum Testen (von einem Linux-Client) +`ban` legt eine manuelle permanente Sperre an. `unban` entfernt die IP aus Firewall und Datenbank. `flush` hebt alle aktiven Sperren auf. -> **⚠ 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. +Whitelist-Regeln gelten auch für manuelle Sperren. Eine IP aus `WHITELIST` oder externer Whitelist wird nicht gesperrt. -### Voraussetzungen +Bulk-Kommandos senden bei aktivierten Benachrichtigungen eine zusammenfassende Freigabe-Meldung, nicht eine Nachricht pro IP. -Die folgenden Tools müssen auf dem **Linux-Client** installiert sein (nicht auf dem Server): +## Progressive Sperren und Offenses ```bash -# Für DNS-Abfragen (dig) +# Offense-Zähler anzeigen +sudo /opt/adguard-shield/adguard-shield offense-status + +# abgelaufene Zähler entfernen +sudo /opt/adguard-shield/adguard-shield offense-cleanup + +# alle Offense-Zähler zurücksetzen +sudo /opt/adguard-shield/adguard-shield reset-offenses + +# Zähler für eine IP zurücksetzen +sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100 +``` + +Nützlich nach Fehlkonfigurationen: + +```bash +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 +sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100 +``` + +## Firewall-Befehle + +```bash +# Chain und ipsets anlegen +sudo /opt/adguard-shield/adguard-shield firewall-create + +# Status anzeigen +sudo /opt/adguard-shield/adguard-shield firewall-status + +# ipsets leeren +sudo /opt/adguard-shield/adguard-shield firewall-flush + +# Chain, Regeln und ipsets entfernen +sudo /opt/adguard-shield/adguard-shield firewall-remove + +# aktuelle iptables-Regeln sichern +sudo /opt/adguard-shield/adguard-shield firewall-save + +# gespeicherte Regeln wiederherstellen +sudo /opt/adguard-shield/adguard-shield firewall-restore +``` + +Normalerweise musst du diese Befehle nicht manuell ausführen. Der Daemon erstellt die Firewall beim Start und schreibt aktive Sperren aus SQLite wieder hinein. + +Welche Host-Chain genutzt wird, hängt von `FIREWALL_MODE` ab. Klassische Installationen und Docker Host Network nutzen `INPUT`; Docker mit veröffentlichten Ports nutzt `DOCKER-USER`. Details stehen in [Docker-Installationen](docker.md). + +Gespeicherte Regeln: + +```text +/var/lib/adguard-shield/iptables-rules.v4 +/var/lib/adguard-shield/iptables-rules.v6 +``` + +## Externe Whitelist + +```bash +# Status anzeigen +sudo /opt/adguard-shield/adguard-shield whitelist-status + +# sofort synchronisieren +sudo /opt/adguard-shield/adguard-shield whitelist-sync + +# aufgelöste externe Whitelist entfernen +sudo /opt/adguard-shield/adguard-shield whitelist-flush +``` + +Die externe Whitelist kann IPs, CIDR-Netze und Hostnamen enthalten. Hostnamen werden per DNS aufgelöst und als IPs in SQLite gespeichert. + +Wichtig: + +- Eine gewhitelistete IP wird nicht gesperrt. +- Wird eine bereits gesperrte IP später gewhitelistet, wird sie freigegeben. +- Die dauerhafte Synchronisation läuft im Daemon. +- `whitelist-sync` erzwingt nur einen einzelnen Lauf. + +## Externe Blocklist + +```bash +# Status anzeigen +sudo /opt/adguard-shield/adguard-shield blocklist-status + +# sofort synchronisieren +sudo /opt/adguard-shield/adguard-shield blocklist-sync + +# alle Sperren aus externer Blocklist aufheben +sudo /opt/adguard-shield/adguard-shield blocklist-flush +``` + +Die externe Blocklist kann IPs, CIDR-Netze und Hostnamen enthalten. Hostnamen werden aufgelöst. Einträge aus der Whitelist werden übersprungen. + +Wenn `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` gesetzt ist, hebt der Daemon Blocklist-Sperren wieder auf, sobald sie nicht mehr in der externen Liste vorkommen. + +## GeoIP + +```bash +# Status anzeigen +sudo /opt/adguard-shield/adguard-shield geoip-status + +# aktuelle Clients aus dem Querylog einmalig prüfen +sudo /opt/adguard-shield/adguard-shield geoip-sync + +# alle GeoIP-Sperren aufheben +sudo /opt/adguard-shield/adguard-shield geoip-flush + +# Cache leeren +sudo /opt/adguard-shield/adguard-shield geoip-flush-cache + +# einzelne IP nachschlagen +sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8 +``` + +GeoIP-Sperren sind permanent, werden aber bei Konfigurationsänderungen automatisch neu bewertet. + +## Reports + +```bash +# Konfiguration und Cron-Status anzeigen +sudo /opt/adguard-shield/adguard-shield report-status + +# HTML-Report in Datei schreiben +sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html + +# Text-Report auf stdout ausgeben +sudo /opt/adguard-shield/adguard-shield report-generate txt + +# Testmail senden +sudo /opt/adguard-shield/adguard-shield report-test + +# aktuellen Report senden +sudo /opt/adguard-shield/adguard-shield report-send + +# Cron-Job installieren +sudo /opt/adguard-shield/adguard-shield report-install + +# Cron-Job entfernen +sudo /opt/adguard-shield/adguard-shield report-remove +``` + +Der Cron-Job liegt hier: + +```text +/etc/cron.d/adguard-shield-report +``` + +## Dry-Run + +```bash +sudo /opt/adguard-shield/adguard-shield dry-run +``` + +Der Dry-Run ist der sicherste Weg, neue Konfigurationen zu prüfen. + +Im Dry-Run: + +- werden Querylogs normal gelesen +- Rate-Limit, Subdomain-Flood, Watchlist, externe Blocklist und GeoIP werden ausgewertet +- mögliche Sperren landen als `DRY` in der History +- es werden keine aktiven Bans angelegt +- es werden keine Firewall-Regeln gesetzt + +Typischer Ablauf nach größeren Änderungen: + +```bash +sudo /opt/adguard-shield/adguard-shield dry-run +sudo /opt/adguard-shield/adguard-shield history 50 +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 80 +``` + +## Typische Betriebsabläufe + +### Nach Konfigurationsänderung + +```bash +sudo systemctl restart adguard-shield +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield logs --level info --limit 80 +``` + +### Falsch gesperrte IP freigeben + +```bash +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 +sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100 +``` + +Danach die IP dauerhaft in `WHITELIST` oder eine externe Whitelist aufnehmen. + +### Externe Listen neu laden + +```bash +sudo /opt/adguard-shield/adguard-shield whitelist-sync +sudo /opt/adguard-shield/adguard-shield blocklist-sync +sudo /opt/adguard-shield/adguard-shield status +``` + +### Firewall neu aufbauen + +```bash +sudo /opt/adguard-shield/adguard-shield firewall-remove +sudo /opt/adguard-shield/adguard-shield firewall-create +sudo systemctl restart adguard-shield +``` + +Nach dem Neustart schreibt der Daemon aktive Sperren aus SQLite wieder in die Firewall. + +### Service-Problem eingrenzen + +```bash +sudo systemctl status adguard-shield +sudo journalctl -u adguard-shield --no-pager -n 100 +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield logs --level debug --limit 100 +``` + +## DNS-Abfragen zum Testen + +Die folgenden Befehle sind ausschließlich für kontrollierte Tests gegen deinen eigenen DNS-Server gedacht. Ersetze `203.0.113.50` durch deine eigene DNS-Server-IP und `example.com` durch eine Testdomain. + +Nicht gegen fremde DNS-Server, fremde Dienste oder fremde Infrastruktur verwenden. + +### Voraussetzungen auf dem Testclient + +```bash +# klassisches DNS sudo apt install dnsutils -# Für DoH-Abfragen (curl) +# DoH sudo apt install curl -# Für DoT-Abfragen (knotc) +# DoT 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 ---- - -### 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: +Gleiche Domain mehrfach abfragen: ```bash -for i in {1..200}; do \ - dig @203.0.113.50 microsoft.com +short +cookie=$(openssl rand -hex 8) > /dev/null & \ +for i in {1..40}; do \ + dig @203.0.113.50 example.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: +Viele zufällige Subdomains: ```bash -for i in {1..200}; do \ - dig @203.0.113.50 $(openssl rand -hex 6).microsoft.com +short > /dev/null & \ +for i in {1..60}; do \ + dig @203.0.113.50 $(openssl rand -hex 6).example.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 +### DNS over HTTPS ```bash -for i in {1..200}; do \ +for i in {1..40}; do \ curl -s -H "accept: application/dns-json" \ - "https://203.0.113.50/dns-query?name=microsoft.com&type=A" > /dev/null & \ + "https://203.0.113.50/dns-query?name=example.com&type=A" > /dev/null & \ done; wait ``` -#### Zufällige Subdomain-Abfragen via DoH +Bei selbstsigniertem Zertifikat auf dem eigenen Testserver kann für diesen lokalen Test `-k` ergänzt werden. + +### DNS over TLS ```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 & \ +for i in {1..40}; do \ + kdig @203.0.113.50 example.com +tls +short > /dev/null & \ done; wait ``` -> **Hinweis:** Falls der Server ein selbstsigniertes Zertifikat verwendet, muss `-k` (unsicherer Modus) an `curl` angehängt werden. +Die Beispielzahlen liegen bewusst nahe an den Standardlimits `RATE_LIMIT_MAX_REQUESTS=30` und `SUBDOMAIN_FLOOD_MAX_UNIQUE=50`. ---- - -### DNS over TLS (DoT) - -DoT verwendet TLS über Port 853. Mit `kdig` (aus dem Paket `knot-dnsutils`): - -#### Direkte Abfragen via DoT +## Eingebaute Hilfe ```bash -for i in {1..200}; do \ - kdig @203.0.113.50 microsoft.com +tls +short > /dev/null & \ -done; wait +/opt/adguard-shield/adguard-shield --help ``` -#### Zufällige Subdomain-Abfragen via DoT +Bei unbekannten Befehlen gibt das Binary die Usage-Ausgabe aus. Der wichtigste Merksatz für die Go-Version: ```bash -for i in {1..200}; do \ - kdig @203.0.113.50 $(openssl rand -hex 6).microsoft.com +tls +short > /dev/null & \ -done; wait +sudo /opt/adguard-shield/adguard-shield ``` ---- - -### 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 +Nicht mehr: ```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: - -```bash -sudo bash install.sh --help -sudo bash install.sh -h +sudo /opt/adguard-shield/adguard-shield.sh ``` diff --git a/docs/benachrichtigungen.md b/docs/benachrichtigungen.md index a89148e..f554fee 100644 --- a/docs/benachrichtigungen.md +++ b/docs/benachrichtigungen.md @@ -1,19 +1,47 @@ -# Webhook-Benachrichtigungen +# Benachrichtigungen -Das Tool kann beim Starten und Stoppen des Services sowie bei Sperren und Entsperrungen Benachrichtigungen an verschiedene Dienste senden. +AdGuard Shield kann Ereignisse an Ntfy, Discord, Slack, Gotify oder einen eigenen Webhook senden. Benachrichtigungen sind optional und werden über `adguard-shield.conf` gesteuert. -## Aktivierung +Typische Ereignisse: -In der Konfiguration (`adguard-shield.conf`): +- Service gestartet +- Service gestoppt +- automatische Sperre +- manuelle Sperre +- GeoIP-Sperre +- externe Blocklist-Sperre, falls separat aktiviert +- Freigabe +- Bulk-Freigabe, zum Beispiel durch `flush` + +## Grundkonfiguration ```bash NOTIFY_ENABLED=true -NOTIFY_TYPE="" -NOTIFY_WEBHOOK_URL="" +NOTIFY_TYPE="ntfy" ``` +Mögliche Typen: + +```text +ntfy +discord +slack +gotify +generic +``` + +Nach Änderungen: + +```bash +sudo systemctl restart adguard-shield +``` + +Zum Prüfen kannst du den Service neu starten oder im Dry-Run eine Erkennung auslösen. + ## Ntfy +Ntfy ist der einfachste Einstieg, weil kein komplexer Webhook-Body benötigt wird. + ```bash NOTIFY_ENABLED=true NOTIFY_TYPE="ntfy" @@ -23,28 +51,29 @@ NTFY_TOKEN="" NTFY_PRIORITY="4" ``` -> **Hinweis:** Bei Ntfy wird `NOTIFY_WEBHOOK_URL` nicht benötigt – Server-URL und Topic werden separat konfiguriert. +Eigene Ntfy-Instanz: -**Eigene Ntfy-Instanz:** ```bash -NTFY_SERVER_URL="https://ntfy.mein-server.de" +NTFY_SERVER_URL="https://ntfy.example.com" NTFY_TOPIC="dns-security" -NTFY_TOKEN="tk_mein_geheimer_token" +NTFY_TOKEN="tk_geheimer_token" ``` -**Prioritäten:** -| Wert | Bedeutung | -|------|-----------| -| 1 | Minimum | -| 2 | Niedrig | -| 3 | Standard | -| 4 | Hoch | -| 5 | Maximum | +Prioritäten: -**Token erstellen (Self-hosted):** -1. Ntfy Web-UI → Benutzer/Tokens -2. Token kopieren und in `NTFY_TOKEN` eintragen -3. Bei ntfy.sh: Account erstellen → Access Token generieren +| Wert | Bedeutung | +|---:|---| +| `1` | Minimum | +| `2` | Niedrig | +| `3` | Standard | +| `4` | Hoch | +| `5` | Maximum | + +Hinweise: + +- Bei `NOTIFY_TYPE="ntfy"` wird `NOTIFY_WEBHOOK_URL` nicht verwendet. +- Bei privaten Topics oder eigener Instanz ist ein Token empfehlenswert. +- Der Topic-Name sollte nicht öffentlich erratbar sein. ## Discord @@ -54,21 +83,16 @@ NOTIFY_TYPE="discord" NOTIFY_WEBHOOK_URL="https://discord.com/api/webhooks/xxx/yyy" ``` -**Webhook erstellen:** -1. Discord Server → Servereinstellungen → Integrationen → Webhooks -2. Neuer Webhook → URL kopieren +Webhook erstellen: -## Gotify +1. Discord-Server öffnen. +2. Servereinstellungen öffnen. +3. Integrationen auswählen. +4. Webhooks öffnen. +5. Neuen Webhook erstellen. +6. URL kopieren und in `NOTIFY_WEBHOOK_URL` eintragen. -```bash -NOTIFY_ENABLED=true -NOTIFY_TYPE="gotify" -NOTIFY_WEBHOOK_URL="https://gotify.example.com/message?token=xxx" -``` - -**Token erstellen:** -1. Gotify Web-UI → Apps → App erstellen -2. Token kopieren und in die URL einfügen +Discord erhält den Inhalt als `content`. ## Slack @@ -78,144 +102,223 @@ NOTIFY_TYPE="slack" NOTIFY_WEBHOOK_URL="https://hooks.slack.com/services/xxx/yyy/zzz" ``` -**Webhook erstellen:** -1. Slack App → Incoming Webhooks aktivieren -2. Webhook-URL kopieren +Slack erhält den Inhalt als `text`. -## Generic (eigener Endpoint) +Einrichtung grob: + +1. Slack-App mit Incoming Webhooks einrichten. +2. Webhook für den gewünschten Channel aktivieren. +3. Webhook-URL in die Konfiguration kopieren. + +## Gotify + +```bash +NOTIFY_ENABLED=true +NOTIFY_TYPE="gotify" +NOTIFY_WEBHOOK_URL="https://gotify.example.com/message?token=xxx" +``` + +Gotify erhält `title`, `message` und `priority` als Formularwerte. + +Token erstellen: + +1. Gotify-Weboberfläche öffnen. +2. Apps auswählen. +3. App erstellen. +4. Token in die URL einsetzen. + +## Generic Webhook + +Für eigene Automatisierung: ```bash NOTIFY_ENABLED=true NOTIFY_TYPE="generic" -NOTIFY_WEBHOOK_URL="https://your-server.com/webhook" +NOTIFY_WEBHOOK_URL="https://example.com/adguard-shield-webhook" ``` -Sendet einen POST mit JSON-Body: +AdGuard Shield sendet einen `POST` mit JSON: ```json { - "message": "🚫 AdGuard Shield Ban auf dns1\n---\nIP: 192.168.1.50\nHostname: client.local\nGrund: 45x microsoft.com in 60s via DNS, Rate-Limit\nDauer: 1h 0m\n\nWhois: https://www.whois.com/whois/192.168.1.50\nAbuseIPDB: https://www.abuseipdb.com/check/192.168.1.50", - "action": "ban", + "title": "AdGuard Shield", + "message": "AdGuard Shield Ban auf dns1\n---\nIP: 192.168.1.50\nHostname: client.local\nGrund: 45x example.com in 60s via DNS, Rate-Limit\nDauer: 1h 0m [Stufe 1/5]\n\nAbuseIPDB: https://www.abuseipdb.com/check/192.168.1.50", "client": "192.168.1.50", - "domain": "microsoft.com" + "action": "ban" } ``` -## Benachrichtigungen und externe Blocklisten +Mögliche `action`-Werte: -Bei Sperren aus der **externen Blocklist** werden Benachrichtigungen separat über `EXTERNAL_BLOCKLIST_NOTIFY` gesteuert — unabhängig von `NOTIFY_ENABLED`. +| Aktion | Bedeutung | +|---|---| +| `ban` | Sperre | +| `unban` | Freigabe | +| `manual-flush` | Bulk-Freigabe | +| `geoip-flush` | Bulk-Freigabe von GeoIP-Sperren | +| `external-blocklist-flush` | Bulk-Freigabe externer Blocklist-Sperren | +| `service_start` | Service gestartet | +| `service_stop` | Service gestoppt | -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `EXTERNAL_BLOCKLIST_NOTIFY` | `false` | Benachrichtigungen bei Blocklist-Sperren aktivieren | +## Externe Blocklist und Benachrichtigungen -> **Wichtig:** Bei großen Listen `EXTERNAL_BLOCKLIST_NOTIFY=false` belassen. Beim ersten Sync (oder nach einem `blocklist-flush`) werden alle IPs der Liste auf einmal gesperrt — mit `true` würde das zu einer Nachrichten-Flut im Notification-Channel führen. Nur auf `true` setzen, wenn die Liste sehr klein ist. +Für Sperren aus externen Blocklisten gibt es einen zusätzlichen Schalter: -## Beispiel-Nachrichten +```bash +EXTERNAL_BLOCKLIST_NOTIFY=false +``` + +Warum separat? + +Eine große Blocklist kann beim ersten Sync hunderte oder tausende IPs sperren. Wenn dafür jede Sperre eine Nachricht erzeugt, wird dein Benachrichtigungskanal unbrauchbar. + +Empfehlung: + +```bash +EXTERNAL_BLOCKLIST_NOTIFY=false +``` + +Nur bei kleinen, kuratierten Listen: + +```bash +EXTERNAL_BLOCKLIST_NOTIFY=true +``` + +## GeoIP-Benachrichtigungen + +GeoIP hat ebenfalls einen eigenen Schalter: + +```bash +GEOIP_NOTIFY=true +``` + +Wenn GeoIP aktiv ist, aber keine Nachrichten für GeoIP-Sperren gesendet werden sollen: + +```bash +GEOIP_NOTIFY=false +``` + +## Bulk-Freigaben + +Diese Befehle können viele IPs auf einmal freigeben: + +```bash +sudo /opt/adguard-shield/adguard-shield flush +sudo /opt/adguard-shield/adguard-shield geoip-flush +sudo /opt/adguard-shield/adguard-shield blocklist-flush +``` + +AdGuard Shield sendet dafür nicht eine Nachricht pro IP, sondern eine zusammenfassende Meldung mit der Anzahl der freigegebenen Sperren. + +## AbuseIPDB-Hinweis in Nachrichten + +Bei permanenten Monitor-Sperren kann AdGuard Shield zusätzlich an AbuseIPDB melden. + +Voraussetzungen: + +```bash +ABUSEIPDB_ENABLED=true +ABUSEIPDB_API_KEY="..." +``` + +Wenn eine Meldung ausgelöst wurde, enthält die Ban-Nachricht einen entsprechenden Hinweis. Außerdem enthält jede Ban- und Unban-Nachricht einen Link zur AbuseIPDB-Check-Seite der IP. + +AbuseIPDB wird nicht für GeoIP- oder externe Blocklist-Sperren verwendet. + +## Beispielinhalte ### Service gestartet -**Überschrift:** ✅ AdGuard Shield -> 🟢 AdGuard Shield v0.9.0 wurde auf dns1 gestartet. +```text +AdGuard Shield v1.0.0 wurde auf dns1 gestartet. +``` ### Service gestoppt -**Überschrift:** 🚨 🛡️ AdGuard Shield -> 🔴 AdGuard Shield v0.9.0 wurde auf dns1 gestoppt. +```text +AdGuard Shield v1.0.0 wurde auf dns1 gestoppt. +``` -### Watchdog — Service wiederhergestellt -**Überschrift:** 🔄 AdGuard Shield Watchdog +### Rate-Limit-Sperre -> 🔄 AdGuard Shield Watchdog auf dns1 -> --- -> Der Service war ausgefallen und wurde automatisch neu gestartet. -> Versuch: 1 +```text +AdGuard Shield Ban auf dns1 +--- +IP: 192.0.2.50 +Hostname: client.example.com +Grund: 45x example.com in 60s via DNS, Rate-Limit +Dauer: 1h 0m [Stufe 1/5] -### Watchdog — Recovery fehlgeschlagen -**Überschrift:** 🚨 AdGuard Shield Watchdog +AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50 +``` -> 🚨 AdGuard Shield Watchdog auf dns1 -> --- -> Der Service konnte NICHT automatisch neu gestartet werden! -> Manuelles Eingreifen erforderlich. -> Fehlversuche: 1 -> Letzter Fehler: (systemd Statusausgabe) +### Watchlist-Sperre -### Sperre (Ban) -**Überschrift:** 🚨 🛡️ AdGuard Shield +```text +AdGuard Shield Ban auf dns1 +IP wurde an AbuseIPDB gemeldet +--- +IP: 192.0.2.51 +Hostname: unknown +Grund: 75x microsoft.com in 60s via DoH, DNS-Flood-Watchlist +Dauer: PERMANENT -> 🚫 AdGuard Shield Ban auf dns1 -> --- -> IP: 95.71.42.116 -> Hostname: example-host.provider.net -> Grund: 153x radioportal.techniverse.net in 60s via DNS, Rate-Limit -> Dauer: 1h 0m [Stufe 1/5] -> -> Whois: https://www.whois.com/whois/95.71.42.116 -> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116 +AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.51 +``` -Bei permanenter Sperre mit aktiviertem AbuseIPDB-Reporting erscheint zusätzlich: +### GeoIP-Sperre -> 🚫 AdGuard Shield Ban auf dns1 -> ⚠️ IP wurde an AbuseIPDB gemeldet -> --- -> IP: 95.71.42.116 -> Hostname: example-host.provider.net -> Grund: 153x radioportal.techniverse.net in 60s via DNS, Rate-Limit -> Dauer: PERMANENT [Stufe 5/5] -> -> Whois: https://www.whois.com/whois/95.71.42.116 -> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116 +```text +AdGuard Shield GeoIP-Sperre auf dns1 +--- +IP: 203.0.113.10 +Land: BR +Modus: Blocklist +Dauer: PERMANENT -Bei DNS-Flood-Watchlist-Treffer (sofort permanent, ohne Stufe): +AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.10 +``` -> 🚫 AdGuard Shield Ban auf dns1 -> ⚠️ IP wurde an AbuseIPDB gemeldet -> --- -> IP: 95.71.42.116 -> Hostname: example-host.provider.net -> Grund: 45x microsoft.com in 60s via DNS, DNS-Flood-Watchlist -> Dauer: PERMANENT -> -> Whois: https://www.whois.com/whois/95.71.42.116 -> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116 +### Freigabe -### Entsperrung (Unban) -**Überschrift:** ✅ AdGuard Shield +```text +AdGuard Shield Freigabe auf dns1 +--- +IP: 192.0.2.50 +Hostname: client.example.com -> ✅ AdGuard Shield Freigabe auf dns1 -> --- -> IP: 95.71.42.116 -> Hostname: example-host.provider.net -> -> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116 +AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50 +``` -### Externe Blocklist – Sperre -**Überschrift:** 🚨 🛡️ AdGuard Shield +### Bulk-Freigabe -> 🚫 AdGuard Shield Ban auf dns1 (Externe Blocklist) -> --- -> IP: 203.0.113.50 -> Hostname: bad-actor.example.com -> -> Whois: https://www.whois.com/whois/203.0.113.50 -> AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.50 +```text +AdGuard Shield Bulk-Freigabe auf dns1 +--- +Freigegebene IPs: 28 +Aktion: Manual-Flush +``` -### Externe Blocklist – Entsperrung -**Überschrift:** ✅ AdGuard Shield +## Fehlersuche -> ✅ AdGuard Shield Freigabe auf dns1 (Externe Blocklist) -> --- -> IP: 203.0.113.50 -> Hostname: bad-actor.example.com -> -> AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.50 +Wenn keine Benachrichtigung ankommt: -### Hinweise +```bash +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 +sudo journalctl -u adguard-shield --no-pager -n 100 +``` -- Der **Hostname** der IP wird automatisch per Reverse-DNS aufgelöst (`dig`, `host` oder `getent`). Ist kein PTR-Record vorhanden, wird `(unbekannt)` angezeigt. -- Der **Servername** (`dns1` in den Beispielen) wird dynamisch über `$(hostname)` ermittelt und zeigt, auf welchem Server das Ereignis stattfand. -- Die **Überschrift** unterscheidet sich je nach Aktion: - - 🚨 🛡️ bei Sperren und Service-Stopp - - ✅ bei Freigaben und Service-Start -- Bei **permanenten Sperren** mit aktiviertem AbuseIPDB-Reporting wird ein Hinweis eingeblendet, dass die IP an AbuseIPDB gemeldet wurde. +Prüfe: + +- `NOTIFY_ENABLED=true` +- `NOTIFY_TYPE` korrekt geschrieben +- Ziel-URL oder Ntfy-Topic gesetzt +- Token gültig +- Server kann den Webhook erreichen +- Firewall des Servers blockiert ausgehende HTTPS-Verbindungen nicht + +Bei `generic` kannst du testweise einen lokalen HTTP-Empfänger oder einen Request-Inspector verwenden. + +## Datenschutz + +Benachrichtigungen können IP-Adressen, Domainnamen und Hostnamen enthalten. Sende sie nur an Dienste, denen du vertraust. Für öffentliche oder geteilte Kanäle ist Ntfy mit privatem Topic oder eine eigene Ntfy/Gotify-Instanz oft die bessere Wahl. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..c390a66 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,52 @@ +# Docker-Installationen + +AdGuard Shield liest weiterhin das Querylog von AdGuard Home. Der Unterschied zwischen klassisch und Docker betrifft nur die Stelle, an der die Firewall eine gesperrte Client-IP abfangen muss. + +## Modus wählen + +| Installation | Einstellung | +|---|---| +| AdGuard Home direkt auf dem Host | `FIREWALL_MODE="host"` | +| Docker mit `network_mode: host` | `FIREWALL_MODE="docker-host"` | +| Docker Bridge mit veröffentlichten Ports | `FIREWALL_MODE="docker-bridge"` | +| gemischtes Setup oder Migration | `FIREWALL_MODE="hybrid"` | + +`docker-host` verhält sich technisch wie `host`: Die DNS-Pakete landen in der Host-`INPUT`-Chain. + +Bei Docker Bridge mit `ports:` oder `-p` landen veröffentlichte Ports nach Dockers NAT-Regeln im Forwarding-Pfad. Deshalb nutzt AdGuard Shield dort `DOCKER-USER`. Diese Chain ist genau für eigene Admin-Regeln vor Dockers Container-Regeln vorgesehen. + +## Beispiele + +Klassisch oder Docker Host Network: + +```bash +FIREWALL_MODE="host" +BLOCKED_PORTS="53 443 853" +``` + +Docker Bridge mit Port-Publishing: + +```bash +FIREWALL_MODE="docker-bridge" +BLOCKED_PORTS="53 443 853" +``` + +Unklarer Übergangszustand: + +```bash +FIREWALL_MODE="hybrid" +``` + +## Wichtige Details + +- `docker-bridge` benötigt eine vorhandene IPv4-Chain `DOCKER-USER`. Wenn Docker nicht läuft oder iptables für Docker deaktiviert ist, meldet `firewall-create` einen Fehler. +- IPv6 über Docker wird nur eingehängt, wenn Docker auch eine `ip6tables`-Chain `DOCKER-USER` angelegt hat. Fehlt sie, wird IPv4 trotzdem geschützt. +- In `DOCKER-USER` wird nach Dockers DNAT gematcht. Wenn du ungewöhnliche Port-Mappings nutzt, sollten `BLOCKED_PORTS` die Container-Zielports enthalten. +- `hybrid` ist praktisch für Migrationen, kann aber mehr Verkehr treffen, weil sowohl Host-Ports als auch Docker-Forwarding geprüft werden. + +Nach einer Änderung: + +```bash +sudo systemctl restart adguard-shield +sudo /opt/adguard-shield/adguard-shield firewall-status +``` diff --git a/docs/konfiguration.md b/docs/konfiguration.md index 5a3dae9..ccdab7f 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -1,349 +1,557 @@ # Konfiguration -Die Konfigurationsdatei liegt nach der Installation unter: +Die zentrale Konfigurationsdatei liegt nach der Installation hier: -``` +```text /opt/adguard-shield/adguard-shield.conf ``` -## Automatische Konfigurations-Migration +Die Datei ist eine einfache Shell-ähnliche Key-Value-Datei. Kommentare beginnen mit `#`. Werte können ohne Anführungszeichen, mit doppelten Anführungszeichen oder mit einfachen Anführungszeichen geschrieben werden. -Bei einem **Update** (`sudo bash install.sh update`) wird die Konfiguration automatisch migriert: +Beispiel: -1. Die aktuelle Konfiguration wird als **Backup** gespeichert: `adguard-shield.conf.old` -2. Neue Parameter (die in der alten Konfig noch nicht existieren) werden **automatisch** zur bestehenden Konfiguration hinzugefügt -3. Alle bestehenden Einstellungen bleiben **unverändert** erhalten +```bash +ADGUARD_URL="https://dns1.example.com" +RATE_LIMIT_MAX_REQUESTS=30 +WHITELIST="127.0.0.1,::1,192.168.1.1" +``` -Dadurch muss der Benutzer bei Updates die Konfiguration nicht manuell austauschen oder vergleichen. +Nach Änderungen solltest du den Service neu starten: -> **Hinweis:** Nach einem Update empfiehlt es sich, die eventuell neu hinzugefügten Parameter zu prüfen und bei Bedarf anzupassen. +```bash +sudo systemctl restart adguard-shield +sudo /opt/adguard-shield/adguard-shield status +``` -## Alle Parameter +## Automatische Migration -### AdGuard Home API +Beim Installieren oder Aktualisieren wird eine vorhandene Konfiguration nicht überschrieben. Der Installer vergleicht vorhandene Schlüssel mit der aktuellen Standardkonfiguration. + +Wenn neue Parameter fehlen: + +1. Die alte Datei wird als `adguard-shield.conf.old` gesichert. +2. Fehlende Schlüssel werden am Ende ergänzt. +3. Vorhandene Werte bleiben erhalten. +4. Dateirechte werden auf `0600` gesetzt. + +Das ist besonders wichtig beim Umstieg von der Shell-Version auf die Go-Version. Prüfe nach einem Update trotzdem die neu ergänzten Parameter. + +## Empfohlene Startprüfung + +Nach dem Bearbeiten der Konfiguration: + +```bash +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield dry-run +``` + +`test` prüft die AdGuard-Home-API. `dry-run` zeigt, was AdGuard Shield sperren würde, ohne die Firewall zu verändern. + +## AdGuard Home API | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `ADGUARD_URL` | `http://127.0.0.1:3000` | AdGuard Home Web-UI URL | -| `ADGUARD_USER` | `admin` | API Benutzername | -| `ADGUARD_PASS` | `changeme` | API Passwort | +|---|---|---| +| `ADGUARD_URL` | `https://dns1.domain.com` | URL der AdGuard-Home-Weboberfläche/API | +| `ADGUARD_USER` | `admin` | Benutzername für die API | +| `ADGUARD_PASS` | `changeme` | Passwort für die API | -### Rate-Limit +Beispiel lokal: + +```bash +ADGUARD_URL="http://127.0.0.1:3000" +ADGUARD_USER="admin" +ADGUARD_PASS="sehr-geheim" +``` + +Beispiel mit HTTPS: + +```bash +ADGUARD_URL="https://dns.example.com" +``` + +AdGuard Shield ruft intern diesen Endpunkt ab: + +```text +/control/querylog?limit=&response_status=all +``` + +Hinweis: Der HTTP-Client akzeptiert auch selbstsignierte TLS-Zertifikate. Das erleichtert lokale Setups, ersetzt aber keine saubere Absicherung der AdGuard-Home-Oberfläche. + +## Querylog und Polling | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `RATE_LIMIT_MAX_REQUESTS` | `30` | Max. Anfragen pro Domain/Client innerhalb des Zeitfensters | +|---|---:|---| +| `CHECK_INTERVAL` | `10` | Abstand zwischen Querylog-Abfragen in Sekunden | +| `API_QUERY_LIMIT` | `500` | Anzahl der Querylog-Einträge pro API-Abfrage | + +Empfehlung: + +- `CHECK_INTERVAL=10` ist ein guter Standard. +- Bei sehr hohem DNS-Aufkommen kann `API_QUERY_LIMIT` erhöht werden. +- Wenn `API_QUERY_LIMIT` zu niedrig ist, können Spitzen im Querylog zwischen zwei Polls teilweise verpasst werden. +- Sehr kurze Intervalle erzeugen mehr API-Last auf AdGuard Home. + +## Rate-Limit + +| Parameter | Standard | Beschreibung | +|---|---:|---| +| `RATE_LIMIT_MAX_REQUESTS` | `30` | maximale Anfragen pro Client und Domain im Zeitfenster | | `RATE_LIMIT_WINDOW` | `60` | Zeitfenster in Sekunden | -| `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) +Beispiel: -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. +```bash +RATE_LIMIT_MAX_REQUESTS=30 +RATE_LIMIT_WINDOW=60 +``` + +Das bedeutet: Wenn ein Client dieselbe Domain mehr als 30-mal innerhalb von 60 Sekunden abfragt, wird er auffällig. + +Gute Startwerte: + +| Umgebung | Vorschlag | +|---|---| +| kleines Heimnetz | `30` in `60s` | +| viele Clients | `60` bis `120` in `60s` | +| sehr aktive Resolver/Forwarder | zuerst Whitelist prüfen, dann höher setzen | + +Wichtig: Wenn ein Router, Reverse Proxy oder lokaler DNS-Forwarder stellvertretend für viele Clients fragt, sollte dieser Client in die Whitelist. Sonst sieht AdGuard Shield nur eine sehr aktive IP. + +## Subdomain-Flood-Erkennung | 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_ENABLED` | `true` | aktiviert die Erkennung zufälliger Subdomains | +| `SUBDOMAIN_FLOOD_MAX_UNIQUE` | `50` | maximale Anzahl eindeutiger Subdomains pro Client und Basisdomain | | `SUBDOMAIN_FLOOD_WINDOW` | `60` | Zeitfenster in Sekunden | -#### Wie funktioniert die Erkennung? +Diese Erkennung zielt auf Muster wie: -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) +```text +a1b2.example.com +f8x9.example.com +zz12.example.com ``` -→ Alle Anfragen haben die gleiche Basisdomain `microsoft.com`. Sobald mehr als 50 eindeutige Subdomains erkannt werden, wird der Client gesperrt. +Dabei zählt AdGuard Shield nicht die Gesamtzahl der Anfragen, sondern die Anzahl unterschiedlicher Subdomains unter derselben Basisdomain. -> **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. +Beispiel: -> **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. +```bash +SUBDOMAIN_FLOOD_ENABLED=true +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 +SUBDOMAIN_FLOOD_WINDOW=60 +``` -### DNS-Flood-Watchlist +Wenn ein Client innerhalb von 60 Sekunden mehr als 50 unterschiedliche Subdomains von `example.com` abfragt, wird er gesperrt. -Domains bei denen eine Rate-Limit-Überschreitung **sofort** zu einer **permanenten Sperre** und einer **AbuseIPDB-Meldung** führt — ohne progressive Eskalation. Ideal für bekannte Angriffsziele, die regelmäßig geflutet werden (z.B. `microsoft.com`, `google.com`). +Hinweise: + +- Direkte Anfragen an `example.com` zählen hier nicht. +- Multi-Part-TLDs wie `.co.uk` werden berücksichtigt. +- CDNs und manche Apps nutzen viele Subdomains. Wenn legitime Clients betroffen sind, den Grenzwert erhöhen oder passende Clients whitelisten. + +## DNS-Flood-Watchlist | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `DNS_FLOOD_WATCHLIST_ENABLED` | `false` | DNS-Flood-Watchlist aktivieren | -| `DNS_FLOOD_WATCHLIST` | *(leer)* | Überwachte Domains, kommagetrennt (z.B. `"microsoft.com,google.com"`) | +|---|---|---| +| `DNS_FLOOD_WATCHLIST_ENABLED` | `false` | aktiviert die Watchlist | +| `DNS_FLOOD_WATCHLIST` | leer | kommagetrennte Domainliste | -#### Wie funktioniert die Watchlist? +Die Watchlist ist für Domains gedacht, bei denen eine Überschreitung sofort hart behandelt werden soll. -1. Die reguläre Rate-Limit-Prüfung erkennt, dass ein Client mehr als `RATE_LIMIT_MAX_REQUESTS` Anfragen für eine Domain gestellt hat -2. Zusätzlich wird geprüft, ob die angefragte Domain in der Watchlist steht (inkl. Subdomains: `foo.microsoft.com` matcht `microsoft.com`) -3. Trifft beides zu → **sofortige permanente Sperre** + **AbuseIPDB-Meldung** (falls aktiviert) - -Die Watchlist greift sowohl bei normalen Rate-Limit-Verstößen als auch bei Subdomain-Flood-Erkennungen. - -#### Beispiel +Beispiel: ```bash DNS_FLOOD_WATCHLIST_ENABLED=true DNS_FLOOD_WATCHLIST="microsoft.com,google.com,apple.com" ``` -→ Ein Client der `35x foo.microsoft.com` in 60s abfragt (bei `RATE_LIMIT_MAX_REQUESTS=30`) wird **sofort permanent** gesperrt und an AbuseIPDB gemeldet. +Wenn ein Client dann `login.microsoft.com` über das Rate-Limit bringt, wird sofort permanent gesperrt, weil `login.microsoft.com` zur Watchlist-Domain `microsoft.com` gehört. -> **Hinweis:** Damit die AbuseIPDB-Meldung funktioniert, muss `ABUSEIPDB_ENABLED=true` und ein gültiger `ABUSEIPDB_API_KEY` konfiguriert sein. Ohne AbuseIPDB-Konfiguration wird nur permanent gesperrt. +Folgen: -### Sperr-Einstellungen +- Grund: `dns-flood-watchlist` +- Sperrdauer: permanent +- Progressive-Ban-Dauer wird übersprungen +- AbuseIPDB-Reporting kann ausgelöst werden, wenn aktiviert + +## Sperrdauer und Firewall | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `BAN_DURATION` | `3600` | Basis-Sperrdauer in Sekunden (3600 = 1 Stunde) | -| `IPTABLES_CHAIN` | `ADGUARD_SHIELD` | Name der iptables Chain | -| `BLOCKED_PORTS` | `53 443 853` | Ports die gesperrt werden (IPv4 + IPv6) | -| `WHITELIST` | `127.0.0.1,::1` | IPs die nie gesperrt werden (kommagetrennt) | +|---|---|---| +| `BAN_DURATION` | `3600` | Basisdauer temporärer Monitor-Sperren in Sekunden | +| `IPTABLES_CHAIN` | `ADGUARD_SHIELD` | Name der eigenen Firewall-Chain | +| `BLOCKED_PORTS` | `53 443 853` | Ports, die für gesperrte Clients blockiert werden | +| `FIREWALL_BACKEND` | `ipset` | Firewall-Backend der Go-Version | +| `FIREWALL_MODE` | `host` | Verkehrsweg der AdGuard-Home-Installation | +| `DRY_RUN` | `false` | Konfigurationsweiter Testmodus ohne echte Sperren | -### Progressive Sperren (Recidive) +Standardports: -Wiederholungstäter werden wie bei fail2ban stufenweise länger gesperrt. Wird eine IP nach dem Ablauf ihrer Sperre erneut auffällig, steigt die Sperrdauer exponentiell. +| Port | Zweck | +|---:|---| +| `53` | klassisches DNS über UDP/TCP | +| `443` | DNS-over-HTTPS, sofern AdGuard Home darüber erreichbar ist | +| `853` | DNS-over-TLS und DNS-over-QUIC | -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `PROGRESSIVE_BAN_ENABLED` | `true` | Progressive Sperren aktivieren | -| `PROGRESSIVE_BAN_MULTIPLIER` | `2` | Multiplikator pro Stufe (2 = Verdopplung) | -| `PROGRESSIVE_BAN_MAX_LEVEL` | `5` | Ab dieser Stufe wird permanent gesperrt (0 = nie) | -| `PROGRESSIVE_BAN_RESET_AFTER` | `86400` | Zähler-Reset nach X Sekunden ohne Vergehen (86400 = 24h) | - -#### Beispiel bei Standardwerten - -| Vergehen | Stufe | Sperrdauer | Berechnung | -|----------|-------|------------|------------| -| 1. Mal | 1 | 1 Stunde | 3600 × 1 | -| 2. Mal | 2 | 2 Stunden | 3600 × 2 | -| 3. Mal | 3 | 4 Stunden | 3600 × 4 | -| 4. Mal | 4 | 8 Stunden | 3600 × 8 | -| 5. Mal | 5 | **PERMANENT** | Max-Stufe erreicht | - -> **Hinweis:** Abgelaufene Offense-Zähler werden automatisch vom **Offense-Cleanup-Worker** aufgeräumt, der stündlich prüft, ob das letzte Vergehen einer IP länger als `PROGRESSIVE_BAN_RESET_AFTER` zurückliegt. Der Worker startet automatisch zusammen mit dem Hauptservice, wenn progressive Sperren aktiviert sind. Er läuft mit niedrigster CPU- und I/O-Priorität (`nice 19`, `ionice idle`), sodass andere Dienste nicht beeinträchtigt werden. Manuelles Zurücksetzen ist jederzeit mit `reset-offenses` möglich. Permanente Sperren werden **nicht** automatisch aufgehoben – sie müssen manuell mit `unban` oder `flush` entfernt werden. - -### Logging - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `LOG_FILE` | `/var/log/adguard-shield.log` | Pfad zur Log-Datei | -| `LOG_LEVEL` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` | -| `LOG_MAX_SIZE_MB` | `50` | Max. Log-Größe bevor rotiert wird | -| `BAN_HISTORY_FILE` | `/var/log/adguard-shield-bans.log` | Legacy: Pfad zur alten Ban-History-Datei (wird bei der SQLite-Migration als Quelle verwendet). Neue Einträge werden direkt in die SQLite-Datenbank geschrieben. | -| `BAN_HISTORY_RETENTION_DAYS` | `0` | Aufbewahrungsdauer der Ban-History in Tagen. `0` = unbegrenzt (niemals löschen). Alte Einträge werden beim nächsten Report automatisch entfernt. | - -### Benachrichtigungen - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `NOTIFY_ENABLED` | `false` | Benachrichtigungen aktivieren | -| `NOTIFY_TYPE` | `ntfy` | Typ: `ntfy`, `discord`, `slack`, `gotify`, `generic` | -| `NOTIFY_WEBHOOK_URL` | *(leer)* | Webhook-URL (nur für discord, slack, gotify, generic) | -| `NTFY_SERVER_URL` | `https://ntfy.sh` | Ntfy Server-URL | -| `NTFY_TOPIC` | *(leer)* | Ntfy Topic-Name | -| `NTFY_TOKEN` | *(leer)* | Optionaler Ntfy Access-Token | -| `NTFY_PRIORITY` | `4` | Ntfy Priorität (1–5) | - -### E-Mail Report - -Regelmäßige Statistik-Reports per E-Mail. Voraussetzung ist ein funktionierender Mail-Transport (z.B. msmtp). - -> **Anleitung für msmtp:** [Linux: Einfach E-Mails versenden mit msmtp](https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/) - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `REPORT_ENABLED` | `false` | Report-Funktion aktivieren | -| `REPORT_INTERVAL` | `weekly` | Intervall: `daily`, `weekly`, `biweekly`, `monthly` | -| `REPORT_TIME` | `08:00` | Versanduhrzeit (HH:MM, 24h) | -| `REPORT_EMAIL_TO` | *(leer)* | E-Mail-Empfänger | -| `REPORT_EMAIL_FROM` | `adguard-shield@hostname` | E-Mail-Absender | -| `REPORT_FORMAT` | `html` | Format: `html` oder `txt` | -| `REPORT_MAIL_CMD` | `msmtp` | Mail-Befehl (`msmtp`, `sendmail`, `mail`) | -| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum in Tagen für „Aktivster Tag“. `30` = letzte 30 Tage. `0` = nur Berichtszeitraum (altes Verhalten) | - -> Siehe [E-Mail Report Dokumentation](report.md) für Details zu Inhalten, Templates und Befehlen. - -### Erweitert - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für die SQLite-Datenbank (`adguard-shield.db`) und Caches | -| `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei | -| `DRY_RUN` | `false` | Testmodus — nur loggen, nicht sperren | - -### Externe Whitelist - -Ermöglicht das Einbinden externer Whitelist-Dateien mit Domains und IP-Adressen. Der Worker löst Domains regelmäßig per DNS auf — ideal für DynDNS-Einträge mit wechselnden IP-Adressen. Aufgelöste IPs werden automatisch zur Whitelist hinzugefügt und bei jeder Prüfung aktualisiert. - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `EXTERNAL_WHITELIST_ENABLED` | `false` | Aktiviert den externen Whitelist-Worker | -| `EXTERNAL_WHITELIST_URLS` | *(leer)* | URL(s) zu Whitelist-Textdateien (kommagetrennt). Unterstützt IPv4, IPv6, CIDR und Hostnamen | -| `EXTERNAL_WHITELIST_INTERVAL` | `300` | Prüfintervall in Sekunden (300 = 5 Min.). Bei DynDNS-Einträgen ggf. kürzer wählen | -| `EXTERNAL_WHITELIST_CACHE_DIR` | `/var/lib/adguard-shield/external-whitelist` | Lokaler Cache für heruntergeladene Listen und aufgelöste IPs | - -#### Externe Whitelist einrichten - -1. Erstelle eine Textdatei auf einem Webserver. Pro Zeile ein Eintrag — Domain, IPv4, IPv6 oder CIDR: +Die Firewall wird über `ipset` und `iptables`/`ip6tables` gesteuert. Für IPv4 und IPv6 gibt es getrennte Sets: ```text -# Domains (werden regelmäßig per DNS aufgelöst — ideal für DynDNS) -mein-router.dyndns.org -homeserver.example.com - -# Feste IPs -192.168.1.100 -10.0.0.0/24 -2001:db8::1 - -# Kommentare und Inline-Kommentare werden unterstützt -192.168.1.200 # Backup-Server +adguard_shield_v4 +adguard_shield_v6 ``` -2. Aktiviere die Whitelist in der Konfiguration: +`FIREWALL_MODE` legt fest, in welche Host-Chain AdGuard Shield die Schutzregeln einhängt: + +| Modus | Einsatz | +|---|---| +| `host` | klassische AdGuard-Home-Installation direkt auf dem Host | +| `docker-host` | AdGuard Home läuft in Docker mit `network_mode: host`; Alias von `host` | +| `docker-bridge` | AdGuard Home läuft in Docker mit veröffentlichten Ports, z.B. `53:53` | +| `hybrid` | schützt Host-Ports und Docker-Forwarding gleichzeitig | + +Bei `host`/`docker-host` wird die eigene Chain aus `INPUT` angesprungen. Bei `docker-bridge` wird sie aus `DOCKER-USER` angesprungen, weil Docker veröffentlichte Ports über NAT und `FORWARD` verarbeitet. Details stehen in [Docker-Installationen](docker.md). + +## Whitelist + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `WHITELIST` | `127.0.0.1,::1` | IPs, die nie gesperrt werden | + +Beispiel: + +```bash +WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10,fd00::1" +``` + +Empfohlen sind: + +- Localhost: `127.0.0.1`, `::1` +- Router/Gateway +- Admin- oder Management-IPs +- Monitoring-Systeme +- interne Resolver oder Forwarder +- eigene VPN-Endpunkte, falls sie viele Anfragen bündeln + +Wichtig: Die Whitelist wird vor jeder Sperre geprüft. Das gilt für automatische, manuelle, GeoIP- und externe Blocklist-Sperren. + +## Progressive Sperren + +| Parameter | Standard | Beschreibung | +|---|---:|---| +| `PROGRESSIVE_BAN_ENABLED` | `true` | Wiederholungstäter stufenweise länger sperren | +| `PROGRESSIVE_BAN_MULTIPLIER` | `2` | Multiplikator pro Stufe | +| `PROGRESSIVE_BAN_MAX_LEVEL` | `5` | ab dieser Stufe permanent sperren, `0` bedeutet nie permanent durch Stufe | +| `PROGRESSIVE_BAN_RESET_AFTER` | `86400` | Offense-Zähler nach so vielen Sekunden ohne neues Vergehen zurücksetzen | + +Beispiel mit Standardwerten: + +| Vergehen | Stufe | Dauer | +|---:|---:|---| +| 1 | 1 | 1 Stunde | +| 2 | 2 | 2 Stunden | +| 3 | 3 | 4 Stunden | +| 4 | 4 | 8 Stunden | +| 5 | 5 | permanent | + +Progressive Sperren gelten für Monitor-Sperren wie `rate-limit` und `subdomain-flood`. Watchlist-Treffer sind sofort permanent. GeoIP und externe Blocklisten haben eigene Regeln. + +Wartung: + +```bash +sudo /opt/adguard-shield/adguard-shield offense-status +sudo /opt/adguard-shield/adguard-shield offense-cleanup +sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100 +``` + +## Logging + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `LOG_FILE` | `/var/log/adguard-shield.log` | Datei für Daemon-Ereignisse | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARN` oder `ERROR` | + +`LOG_FILE` enthält Start/Stop, Worker-Läufe, Sperren, Freigaben, Warnungen und Fehler. Query-Inhalte werden nicht dauerhaft ins Log geschrieben. + +CLI: + +```bash +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 +sudo /opt/adguard-shield/adguard-shield logs-follow debug +sudo /opt/adguard-shield/adguard-shield live +``` + +Für produktiven Betrieb ist `INFO` sinnvoll. Für Fehlersuche kurzzeitig `DEBUG` verwenden. + +## State und Runtime + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für SQLite-Datenbank und Caches | +| `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei für direkten Vordergrundlauf | + +SQLite-Datei: + +```text +${STATE_DIR}/adguard-shield.db +``` + +Weitere Dateien in `STATE_DIR`: + +- Caches für externe Listen +- gespeicherte Firewall-Regeln bei `firewall-save` +- SQLite-WAL-Dateien + +## Benachrichtigungen + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `NOTIFY_ENABLED` | `false` | Benachrichtigungen aktivieren | +| `NOTIFY_TYPE` | `ntfy` | `ntfy`, `discord`, `slack`, `gotify` oder `generic` | +| `NOTIFY_WEBHOOK_URL` | leer | Webhook-URL für Discord, Slack, Gotify oder Generic | +| `NTFY_SERVER_URL` | `https://ntfy.sh` | Ntfy-Server | +| `NTFY_TOPIC` | leer | Ntfy-Topic | +| `NTFY_TOKEN` | leer | optionaler Access-Token | +| `NTFY_PRIORITY` | `4` | Ntfy-Priorität von 1 bis 5 | + +Ntfy-Beispiel: + +```bash +NOTIFY_ENABLED=true +NOTIFY_TYPE="ntfy" +NTFY_SERVER_URL="https://ntfy.sh" +NTFY_TOPIC="mein-adguard-shield" +NTFY_PRIORITY="4" +``` + +Discord-Beispiel: + +```bash +NOTIFY_ENABLED=true +NOTIFY_TYPE="discord" +NOTIFY_WEBHOOK_URL="https://discord.com/api/webhooks/..." +``` + +Details stehen in [Benachrichtigungen](benachrichtigungen.md). + +## E-Mail-Reports + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `REPORT_ENABLED` | `false` | Report-Funktion logisch aktivieren | +| `REPORT_INTERVAL` | `weekly` | `daily`, `weekly`, `biweekly` oder `monthly` | +| `REPORT_TIME` | `08:00` | Versandzeit im Format `HH:MM` | +| `REPORT_EMAIL_TO` | `admin@example.com` | Empfänger | +| `REPORT_EMAIL_FROM` | `adguard-shield@example.com` | Absender | +| `REPORT_FORMAT` | `html` | `html` oder `txt` | +| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm | +| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum in Tagen für "Aktivster Tag"; aktuell als Kompatibilitätsparameter vorhanden | + +Beispiel: + +```bash +REPORT_ENABLED=true +REPORT_INTERVAL="weekly" +REPORT_TIME="08:00" +REPORT_EMAIL_TO="admin@example.com" +REPORT_EMAIL_FROM="adguard-shield@example.com" +REPORT_FORMAT="html" +REPORT_MAIL_CMD="msmtp" +``` + +Cron installieren: + +```bash +sudo /opt/adguard-shield/adguard-shield report-install +``` + +Details stehen in [E-Mail Report](report.md). + +## Externe Whitelist + +| Parameter | Standard | Beschreibung | +|---|---|---| +| `EXTERNAL_WHITELIST_ENABLED` | `false` | externe Whitelist aktivieren | +| `EXTERNAL_WHITELIST_URLS` | leer | kommagetrennte URLs | +| `EXTERNAL_WHITELIST_INTERVAL` | `300` | Synchronisationsintervall in Sekunden | +| `EXTERNAL_WHITELIST_CACHE_DIR` | `/var/lib/adguard-shield/external-whitelist` | Cache-Verzeichnis | + +Beispiel: ```bash EXTERNAL_WHITELIST_ENABLED=true -EXTERNAL_WHITELIST_URLS="https://example.com/whitelist.txt" +EXTERNAL_WHITELIST_URLS="https://example.com/trusted.txt" EXTERNAL_WHITELIST_INTERVAL=300 ``` -3. Mehrere Listen können kommagetrennt angegeben werden: +Listenformat: -```bash -EXTERNAL_WHITELIST_URLS="https://example.com/trusted.txt,https://other.com/whitelist.txt" +```text +# Hostnamen werden regelmäßig aufgelöst +mein-router.dyndns.org +vpn.example.com + +# IPs und Netze +192.168.1.10 +10.0.0.0/24 +2001:db8::1 ``` -4. Service neustarten: +Mehrere Listen: ```bash -sudo systemctl restart adguard-shield +EXTERNAL_WHITELIST_URLS="https://example.com/a.txt,https://example.net/b.txt" ``` -> **Hinweis:** Da Domains bei jedem Prüfintervall neu aufgelöst werden, eignet sich diese Funktion besonders für DynDNS-Einträge. Ändert sich die IP eines DynDNS-Hostnamens, wird die neue IP beim nächsten Sync automatisch erkannt und in die Whitelist aufgenommen. +Verhalten: -> **Wichtig:** Wird eine bereits gesperrte IP durch eine Whitelist-Aktualisierung gewhitelistet, wird sie **automatisch entsperrt**. +- Hostnamen werden per DNS aufgelöst. +- Aufgelöste IPs landen in SQLite. +- Bereits aktive Sperren werden aufgehoben, wenn die IP später in der Whitelist auftaucht. +- Kommentare und Inline-Kommentare werden unterstützt. -### Externe Blocklist +Manuell synchronisieren: -Ermöglicht das Einbinden externer Blocklisten, die IPv4-Adressen, IPv6-Adressen und Hostnamen enthalten können. Der Worker läuft als Hintergrundprozess, prüft periodisch auf Änderungen und löst Hostnamen automatisch über den lokalen DNS-Resolver auf. +```bash +sudo /opt/adguard-shield/adguard-shield whitelist-sync +``` + +## Externe Blocklist | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `EXTERNAL_BLOCKLIST_ENABLED` | `false` | Aktiviert den externen Blocklist-Worker | -| `EXTERNAL_BLOCKLIST_URLS` | *(leer)* | URL(s) zu Blocklist-Textdateien (kommagetrennt). Unterstützt IPv4, IPv6, CIDR und Hostnamen | -| `EXTERNAL_BLOCKLIST_INTERVAL` | `300` | Prüfintervall in Sekunden (300 = 5 Min.) | -| `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_NOTIFY` | `false` | Webhook-Benachrichtigungen bei Blocklist-Sperren senden. Bei großen Listen unbedingt auf `false` lassen — beim ersten Sync kommen sonst hunderte Nachrichten auf einmal. | -| `EXTERNAL_BLOCKLIST_CACHE_DIR` | `/var/lib/adguard-shield/external-blocklist` | Lokaler Cache für heruntergeladene Listen | -### AbuseIPDB Reporting +|---|---|---| +| `EXTERNAL_BLOCKLIST_ENABLED` | `false` | externe Blocklist aktivieren | +| `EXTERNAL_BLOCKLIST_URLS` | leer | kommagetrennte URLs | +| `EXTERNAL_BLOCKLIST_INTERVAL` | `300` | Synchronisationsintervall in Sekunden | +| `EXTERNAL_BLOCKLIST_BAN_DURATION` | `0` | Sperrdauer in Sekunden, `0` = permanent | +| `EXTERNAL_BLOCKLIST_AUTO_UNBAN` | `true` | IPs freigeben, wenn sie nicht mehr in der Liste stehen | +| `EXTERNAL_BLOCKLIST_NOTIFY` | `false` | Benachrichtigungen für Blocklist-Sperren senden | +| `EXTERNAL_BLOCKLIST_CACHE_DIR` | `/var/lib/adguard-shield/external-blocklist` | Cache-Verzeichnis | -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. +Beispiel: -> **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. +```bash +EXTERNAL_BLOCKLIST_ENABLED=true +EXTERNAL_BLOCKLIST_URLS="https://example.com/blocklist.txt" +EXTERNAL_BLOCKLIST_INTERVAL=300 +EXTERNAL_BLOCKLIST_BAN_DURATION=0 +EXTERNAL_BLOCKLIST_AUTO_UNBAN=true +EXTERNAL_BLOCKLIST_NOTIFY=false +``` + +Listenformat: + +```text +# IPv4 +203.0.113.50 +198.51.100.0/24 + +# IPv6 +2001:db8::dead:beef +2001:db8::/32 + +# Hostnamen +bad-actor.example.com + +# Hosts-Datei-Format wird erkannt +0.0.0.0 malware.example.net +127.0.0.1 tracker.example.org +``` + +Unterstützt: + +| Format | Beispiel | +|---|---| +| IPv4 | `203.0.113.50` | +| IPv4-CIDR | `198.51.100.0/24` | +| IPv6 | `2001:db8::1` | +| IPv6-CIDR | `2001:db8::/32` | +| Hostname | `bad.example.com` | +| Hosts-Format | `0.0.0.0 bad.example.com` | +| Kommentar | `# Text` | +| Inline-Kommentar | `203.0.113.50 # Grund` | + +Nicht sinnvoll und wird übersprungen: + +- URLs wie `https://...` +- IP:Port wie `203.0.113.50:8443` +- Hostnamen ohne Punkt oder mit ungültigen Zeichen +- nicht auflösbare Hostnamen +- Blocking-Antworten wie `0.0.0.0` oder `::` + +Hinweise: + +- Große Listen können viele Sperren erzeugen. `EXTERNAL_BLOCKLIST_NOTIFY=false` ist deshalb der sichere Standard. +- Wenn ein Hostname mehrere IPs liefert, werden alle aufgelösten IPs verarbeitet. +- IPs aus der Whitelist werden nicht gesperrt. +- Bei `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` werden entfernte Einträge wieder freigegeben. + +Manuell synchronisieren: + +```bash +sudo /opt/adguard-shield/adguard-shield blocklist-sync +``` + +Dateiformat-Empfehlungen: + +- UTF-8 ohne BOM +- Unix-Zeilenenden `LF` +- IP-Listen und Hostname-Listen möglichst getrennt pflegen + +## AbuseIPDB | 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_API_KEY` | leer | API-Key | +| `ABUSEIPDB_CATEGORIES` | `4` | Kategorien, kommagetrennt möglich | -#### 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: +Beispiel: ```bash ABUSEIPDB_ENABLED=true -ABUSEIPDB_API_KEY="dein-api-key-hier" +ABUSEIPDB_API_KEY="dein-api-key" ABUSEIPDB_CATEGORIES="4" ``` -4. Service neustarten: +Gemeldet werden nur permanente Monitor-Sperren: -```bash -sudo systemctl restart adguard-shield -``` +- Watchlist-Treffer +- Progressive-Ban-Sperren auf Maximalstufe -#### Was wird gemeldet? +Nicht gemeldet werden: -Der Report an AbuseIPDB enthält (auf Englisch): +- temporäre Sperren +- GeoIP-Sperren +- externe Blocklist-Sperren +- manuelle Sperren -- **Bei Rate-Limit / DNS-Flood-Watchlist:** `DNS flooding on our DNS server: 100x microsoft.com in 60s. Banned by Adguard Shield 🔗 https://tnvs.de/as` -- **Bei Subdomain-Flood:** `DNS flooding on our DNS server: 85x *.microsoft.com in 60s (random subdomain attack). Banned by Adguard Shield 🔗 https://tnvs.de/as` - -Die Kategorie `4` (DDoS Attack) wird standardmäßig verwendet. Weitere Kategorien können kommagetrennt angegeben werden (z.B. `"4,15"`). - -### GeoIP-basierte Länderfilter - -Ermöglicht das Sperren oder Erlauben von DNS-Anfragen basierend auf dem Herkunftsland der Client-IP. Unterstützt zwei Modi: - -- **Blocklist-Modus:** Nur die gelisteten Länder werden gesperrt (alle anderen erlaubt) -- **Allowlist-Modus:** Nur die gelisteten Länder werden erlaubt (alle anderen gesperrt) +## GeoIP-Länderfilter | Parameter | Standard | Beschreibung | -|-----------|----------|--------------| +|---|---|---| | `GEOIP_ENABLED` | `false` | GeoIP-Filter aktivieren | -| `GEOIP_MODE` | `blocklist` | Modus: `blocklist` oder `allowlist` | -| `GEOIP_COUNTRIES` | *(leer)* | ISO 3166-1 Alpha-2 Ländercodes (kommagetrennt), z.B. `CN,RU,KP,IR` | -| `GEOIP_CHECK_INTERVAL` | `0` | Prüfintervall in Sekunden (`0` = nutzt `CHECK_INTERVAL`) | -| `GEOIP_NOTIFY` | `true` | Benachrichtigungen bei GeoIP-Sperren senden | -| `GEOIP_SKIP_PRIVATE` | `true` | Private/lokale IPs von der GeoIP-Prüfung ausnehmen | -| `GEOIP_LICENSE_KEY` | *(leer)* | MaxMind License-Key für automatischen DB-Download (kostenlos) | -| `GEOIP_MMDB_PATH` | *(leer)* | Manueller Pfad zur MaxMind GeoLite2 Datenbank (überschreibt Auto-Download) | +| `GEOIP_MODE` | `blocklist` | `blocklist` oder `allowlist` | +| `GEOIP_COUNTRIES` | leer | ISO-3166-1-Alpha-2-Ländercodes | +| `GEOIP_CHECK_INTERVAL` | `0` | Legacy-Parameter; die Go-Version nutzt den zentralen Query-Poller | +| `GEOIP_NOTIFY` | `true` | Benachrichtigungen bei GeoIP-Sperren | +| `GEOIP_SKIP_PRIVATE` | `true` | private/lokale IPs überspringen | +| `GEOIP_LICENSE_KEY` | leer | MaxMind-License-Key für Auto-Download | +| `GEOIP_MMDB_PATH` | leer | manueller Pfad zur MaxMind-MMDB | +| `GEOIP_CACHE_TTL` | `86400` | Cache-Zeit in Sekunden | -#### Voraussetzungen - -Es muss mindestens eines der folgenden GeoIP-Tools installiert sein: - -1. **Automatischer MaxMind-Download** (empfohlen): - ```bash - # Kostenlosen Account erstellen: https://www.maxmind.com/en/geolite2/signup - # License-Key generieren und in adguard-shield.conf eintragen: - GEOIP_LICENSE_KEY="dein_license_key_hier" - ``` - Die GeoLite2-Country-Datenbank wird automatisch heruntergeladen und alle 24 Stunden aktualisiert. - Es wird zusätzlich `mmdbinspect` oder `mmdblookup` benötigt: - ```bash - sudo apt install mmdb-bin # für mmdblookup - ``` - -2. **geoiplookup** (einfachster Einstieg, weniger genau): - ```bash - sudo apt install geoip-bin geoip-database - ``` - -3. **Manueller MaxMind-Pfad** (eigene Datenbank): - ```bash - # mmdbinspect oder mmdblookup installieren - # Datenbank manuell herunterladen: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data - GEOIP_MMDB_PATH="/usr/share/GeoIP/GeoLite2-Country.mmdb" - ``` - -> **Priorität:** `GEOIP_MMDB_PATH` (manuell) → Auto-Download-DB → `geoiplookup` (Legacy-Fallback) - -#### Beispiel: Bestimmte Länder sperren (Blocklist) +Blocklist-Modus: ```bash GEOIP_ENABLED=true GEOIP_MODE="blocklist" GEOIP_COUNTRIES="CN,RU,KP,IR" -GEOIP_LICENSE_KEY="dein_maxmind_license_key" # optional, für Auto-Download ``` -→ Alle Anfragen aus China, Russland, Nordkorea und Iran werden permanent gesperrt. +Damit werden öffentliche Clients aus diesen Ländern gesperrt. -#### Beispiel: Nur bestimmte Länder erlauben (Allowlist) +Allowlist-Modus: ```bash GEOIP_ENABLED=true @@ -351,164 +559,128 @@ GEOIP_MODE="allowlist" GEOIP_COUNTRIES="DE,AT,CH" ``` -→ Nur Anfragen aus Deutschland, Österreich und der Schweiz werden erlaubt. Alle anderen Länder werden gesperrt. +Damit werden nur diese Länder erlaubt. Andere öffentliche Länder werden gesperrt. -> **Hinweis:** Private IP-Adressen (10.x.x.x, 192.168.x.x, etc.) und Whitelist-IPs werden niemals durch GeoIP gesperrt. GeoIP-Sperren sind **immer permanent**. - -> **Auto-Unban:** Wird ein Land aus `GEOIP_COUNTRIES` entfernt oder der Modus (`GEOIP_MODE`) geändert, werden die nicht mehr zutreffenden Sperren beim nächsten Sync **automatisch aufgehoben**. Dasselbe gilt, wenn GeoIP komplett deaktiviert wird (`GEOIP_ENABLED=false`). - -> **Tipp:** GeoIP-Lookups werden für 24 Stunden gecacht. Mit `geoip-flush-cache` kann der Cache manuell geleert werden. - -> **Auto-Download:** Ist `GEOIP_LICENSE_KEY` gesetzt, wird die GeoLite2-Country-Datenbank automatisch nach `/geoip/` heruntergeladen und alle 24 Stunden aktualisiert. Bei einem Update wird der Download im Hintergrund durchgeführt — der Worker läuft während des Downloads normal weiter. Ein manuell gesetzter `GEOIP_MMDB_PATH` hat immer Vorrang vor der automatisch heruntergeladenen Datenbank. - -#### GeoIP-Befehle - -| Befehl | Beschreibung | -|--------|--------------| -| `adguard-shield.sh geoip-status` | Zeigt GeoIP-Status, aktive Sperren und verfügbare Tools | -| `adguard-shield.sh geoip-sync` | Einmalige GeoIP-Prüfung aller aktiven Clients | -| `adguard-shield.sh geoip-flush` | Alle GeoIP-Sperren aufheben | -| `adguard-shield.sh geoip-lookup ` | GeoIP-Lookup einer einzelnen IP-Adresse | - -#### Externe Blocklist einrichten - -1. Erstelle eine Textdatei auf einem Webserver. Pro Zeile ein Eintrag — IPv4, IPv6, CIDR oder Hostname: - -```text -# Kommentare werden ignoriert -# Inline-Kommentare ebenfalls: 1.2.3.4 # dieser Kommentar wird entfernt - -# IPv4 -192.168.100.50 -10.0.0.0/8 - -# IPv6 -2001:db8::dead:beef -2001:db8::/32 - -# Hostnamen (werden über den lokalen DNS-Resolver aufgelöst) -# Liefert ein Hostname mehrere IPs, werden alle gesperrt -bad-actor.example.com -malware.example.net - -# Hosts-Datei-Format wird ebenfalls erkannt (Routing-IP wird ignoriert, Hostname aufgelöst) -0.0.0.0 bad-actor.example.com -127.0.0.1 malware.example.net -``` - -> **Hinweis zur Hostname-Auflösung:** Da AdGuard Shield idealerweise auf demselben Host wie der DNS-Resolver läuft, verwendet der Worker automatisch den lokalen Resolver. Hostnamen die bereits von AdGuard geblockt werden (Antwort `0.0.0.0`) werden übersprungen und nicht importiert. - -#### Dateiformat der Blocklist - -Beim Erstellen eigener Blocklisten müssen zwei Dinge beachtet werden: - -- **Zeichenkodierung:** Datei in **UTF-8 ohne BOM** speichern. Dateien mit BOM (z.B. Standard-Einstellung in Notepad++) führen dazu, dass der erste Eintrag als ungültig erkannt wird. -- **Zeilenenden:** Datei mit **Unix-Zeilenenden (LF)** speichern, nicht Windows (CRLF). CRLF-Zeilenenden führen dazu, dass alle Einträge als ungültig abgelehnt werden. - -In **Notepad++:** Kodierung → „UTF-8 (ohne BOM)" und Bearbeiten → Zeilenende-Konvertierung → Unix (LF). -In **VS Code:** Unten rechts auf `CRLF` klicken → `LF` auswählen; Zeichenkodierung ebenfalls unten rechts prüfen. - -> **Empfehlung:** IP-Adressen und Hostnamen in **getrennten Listen** pflegen. Bei Hostname-Listen löst der Worker jeden Eintrag per DNS auf — das ist langsamer als direkte IP-Listen und liefert je nach DNS-Antwort mehrere IPs pro Eintrag. Getrennte Listen sind außerdem übersichtlicher und einfacher zu pflegen. - -#### Synchronisierungsverhalten - -Der Worker synchronisiert die Blocklisten: - -- **Beim Service-Start:** Der erste Sync läuft **sofort** beim Start — ohne Wartezeit. Danach beginnt erst das periodische Intervall (`EXTERNAL_BLOCKLIST_INTERVAL`). -- **Automatisch im Hintergrund:** Alle `EXTERNAL_BLOCKLIST_INTERVAL` Sekunden (Standard: 300s = 5 Min.) wird geprüft, ob sich die Liste geändert hat. Unveränderte Listen (HTTP 304 oder gleicher Inhalt) werden nicht erneut verarbeitet. -- **Manuell:** `sudo adguard-shield.sh blocklist-sync` erzwingt sofort einen Sync, unabhängig vom laufenden Worker. - -> **Nach einem Neustart** (Server oder Service) werden fehlende iptables-Regeln beim nächsten Sync automatisch nachgezogen. - -2. Aktiviere die Blocklist in der Konfiguration: +Private IPs: ```bash -EXTERNAL_BLOCKLIST_ENABLED=true -EXTERNAL_BLOCKLIST_URLS="https://example.com/blocklist.txt" -EXTERNAL_BLOCKLIST_INTERVAL=300 +GEOIP_SKIP_PRIVATE=true ``` -3. Mehrere Listen können kommagetrennt angegeben werden: +Damit werden unter anderem private Netze, Loopback, Link-Local und CGNAT übersprungen. + +### GeoIP-Datenquellen + +Priorität: + +1. `GEOIP_MMDB_PATH`, wenn gesetzt +2. automatisch geladene MaxMind-Datenbank, wenn `GEOIP_LICENSE_KEY` gesetzt ist +3. Legacy-Fallback über `geoiplookup` oder `geoiplookup6` + +Automatischer MaxMind-Download: ```bash -EXTERNAL_BLOCKLIST_URLS="https://example.com/list1.txt,https://other.com/list2.txt" +GEOIP_LICENSE_KEY="dein_maxmind_license_key" ``` -4. Service neustarten: +Die Datenbank wird unter `/opt/adguard-shield/geoip/` gespeichert und nach 24 Stunden erneuert. + +Manueller Pfad: ```bash -sudo systemctl restart adguard-shield +GEOIP_MMDB_PATH="/usr/share/GeoIP/GeoLite2-Country.mmdb" ``` -#### Unterstützte Eintragsformate +Nützliche Befehle: -| Format | Beispiel | Verhalten | -|--------|----------|----------| -| IPv4 | `1.2.3.4` | direkt gesperrt | -| IPv4-CIDR | `10.0.0.0/8` | direkt gesperrt | -| IPv6 | `2001:db8::1` | direkt gesperrt | -| IPv6-CIDR | `2001:db8::/32` | direkt gesperrt | -| Hostname | `bad.example.com` | per lokalem DNS aufgelöst, alle IPs (IPv4 + IPv6) gesperrt | -| Hosts-Format | `0.0.0.0 bad.example.com` | Hostname extrahiert und aufgelöst | -| Kommentar | `# Text` | übersprungen | -| Inline-Kommentar | `1.2.3.4 # Text` | Kommentar entfernt, IP gesperrt | - -Folgende Einträge werden mit einer Warnung im Log übersprungen: - -- URLs (`https://...`, `http://...`) -- IP:Port-Kombinationen (`1.2.3.4:8080`) -- Hostnamen mit ungültigen Zeichen oder ohne Punkt -- Einträge mit nicht auflösbarem Hostnamen -- `0.0.0.0` und `::` (AdGuard-Blocking-Antwort) -## Gesperrte Ports im Detail - -Bei einem Rate-Limit-Verstoß werden **alle** DNS-Protokoll-Ports für den Client gesperrt (IPv4 via `iptables` und IPv6 via `ip6tables`): - -| Port | Protokoll | Beschreibung | -|------|-----------|-------------| -| 53 | UDP/TCP | Standard DNS | -| 443 | TCP | DNS-over-HTTPS (DoH) | -| 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: - -- `127.0.0.1` und `::1` (Localhost) -- Die IP deines Routers / Gateways -- Deine eigenen Management-IPs -- Andere vertrauenswürdige DNS-Clients - -Beispiel: - -``` -WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10,fd00::1" +```bash +sudo /opt/adguard-shield/adguard-shield geoip-status +sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8 +sudo /opt/adguard-shield/adguard-shield geoip-sync +sudo /opt/adguard-shield/adguard-shield geoip-flush-cache ``` -### Externe Whitelist für dynamische IPs +## Protokollerkennung -Für Clients mit wechselnden IP-Adressen (z.B. DynDNS) kann eine **externe Whitelist** genutzt werden. Der Whitelist-Worker löst Domains regelmäßig per DNS auf und fügt die aktuellen IPs automatisch zur Whitelist hinzu. Siehe [Externe Whitelist](#externe-whitelist) für die Konfiguration. +AdGuard Shield liest das Feld `client_proto` aus der AdGuard-Home-API. + +| API-Wert | Anzeige | Bedeutung | +|---|---|---| +| leer oder `dns` | `DNS` | klassisches DNS | +| `doh` | `DoH` | DNS-over-HTTPS | +| `dot` | `DoT` | DNS-over-TLS | +| `doq` | `DoQ` | DNS-over-QUIC | +| `dnscrypt` | `DNSCrypt` | DNSCrypt | + +Die Sperre blockiert die konfigurierten Ports unabhängig davon, welches Protokoll den Verstoß ausgelöst hat. + +## Beispielkonfiguration für ein Heimnetz + +```bash +ADGUARD_URL="http://127.0.0.1:3000" +ADGUARD_USER="admin" +ADGUARD_PASS="geheim" + +RATE_LIMIT_MAX_REQUESTS=30 +RATE_LIMIT_WINDOW=60 +CHECK_INTERVAL=10 +API_QUERY_LIMIT=500 + +SUBDOMAIN_FLOOD_ENABLED=true +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 +SUBDOMAIN_FLOOD_WINDOW=60 + +BAN_DURATION=3600 +PROGRESSIVE_BAN_ENABLED=true +PROGRESSIVE_BAN_MAX_LEVEL=5 + +WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10" + +NOTIFY_ENABLED=true +NOTIFY_TYPE="ntfy" +NTFY_TOPIC="adguard-shield-home" + +REPORT_ENABLED=false +GEOIP_ENABLED=false +EXTERNAL_BLOCKLIST_ENABLED=false +EXTERNAL_WHITELIST_ENABLED=false +``` + +## Beispielkonfiguration für einen öffentlichen Resolver + +```bash +ADGUARD_URL="https://dns.example.com" +ADGUARD_USER="admin" +ADGUARD_PASS="geheim" + +RATE_LIMIT_MAX_REQUESTS=60 +RATE_LIMIT_WINDOW=60 +CHECK_INTERVAL=5 +API_QUERY_LIMIT=2000 + +SUBDOMAIN_FLOOD_ENABLED=true +SUBDOMAIN_FLOOD_MAX_UNIQUE=75 +SUBDOMAIN_FLOOD_WINDOW=60 + +DNS_FLOOD_WATCHLIST_ENABLED=true +DNS_FLOOD_WATCHLIST="microsoft.com,google.com,apple.com" + +BAN_DURATION=3600 +PROGRESSIVE_BAN_ENABLED=true +PROGRESSIVE_BAN_MULTIPLIER=2 +PROGRESSIVE_BAN_MAX_LEVEL=5 + +ABUSEIPDB_ENABLED=true +ABUSEIPDB_API_KEY="..." + +NOTIFY_ENABLED=true +NOTIFY_TYPE="ntfy" +NTFY_TOPIC="adguard-shield-prod" +``` + +Vor produktiver Aktivierung: + +```bash +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield dry-run +``` diff --git a/docs/report.md b/docs/report.md index 502e225..681f063 100644 --- a/docs/report.md +++ b/docs/report.md @@ -1,19 +1,58 @@ # E-Mail Report -AdGuard Shield kann regelmäßig einen Statistik-Report per E-Mail versenden. Der Report enthält eine Übersicht über alle Sperren, die auffälligsten IPs, meistbetroffene Domains und weitere Statistiken. +AdGuard Shield kann Statistik-Reports direkt aus der SQLite-Datenbank erzeugen und per E-Mail versenden. Es gibt in der Go-Version keinen separaten `report-generator.sh` mehr. -## Voraussetzungen +## Was der Report enthält -Der Server muss E-Mails versenden können. Empfohlen wird **msmtp** als leichtgewichtiger SMTP-Client. +Der Report basiert auf: -**Anleitung zur Einrichtung von msmtp:** -👉 [Linux: Einfach E-Mails versenden mit msmtp](https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/) +```text +/var/lib/adguard-shield/adguard-shield.db +``` -Alternativ funktioniert auch `sendmail`, `mail` oder ein anderer Befehl, der E-Mails über stdin entgegennimmt. +Ausgewertet werden vor allem: -## Aktivierung +- `ban_history` +- `active_bans` -In der Konfiguration (`adguard-shield.conf`): +Inhalte: + +- Zeitraum des Reports +- Anzahl Sperren im Zeitraum +- Anzahl Freigaben im Zeitraum +- aktuell aktive Sperren +- Top-Clients +- Gründe der Sperren +- Quellen aktiver Sperren +- letzte Ereignisse aus der History + +## Konfiguration + +```bash +REPORT_ENABLED=false +REPORT_INTERVAL="weekly" +REPORT_TIME="08:00" +REPORT_EMAIL_TO="admin@example.com" +REPORT_EMAIL_FROM="adguard-shield@example.com" +REPORT_FORMAT="html" +REPORT_MAIL_CMD="msmtp" +REPORT_BUSIEST_DAY_RANGE=30 +``` + +Parameter: + +| Parameter | Bedeutung | +|---|---| +| `REPORT_ENABLED` | dokumentiert, ob Reports gewünscht sind; der Cron-Job wird über `report-install` angelegt | +| `REPORT_INTERVAL` | `daily`, `weekly`, `biweekly` oder `monthly` | +| `REPORT_TIME` | Uhrzeit im Format `HH:MM` | +| `REPORT_EMAIL_TO` | Empfängeradresse | +| `REPORT_EMAIL_FROM` | Absenderadresse | +| `REPORT_FORMAT` | `html` oder `txt` | +| `REPORT_MAIL_CMD` | Mailprogramm, z.B. `msmtp` | +| `REPORT_BUSIEST_DAY_RANGE` | Kompatibilitätsparameter für den Zeitraum "Aktivster Tag" | + +Beispiel: ```bash REPORT_ENABLED=true @@ -25,252 +64,182 @@ REPORT_FORMAT="html" REPORT_MAIL_CMD="msmtp" ``` -Anschließend den Cron-Job einrichten: - -```bash -sudo /opt/adguard-shield/report-generator.sh install -``` - -## Konfigurationsparameter - -| Parameter | Standard | Beschreibung | -|-----------|----------|--------------| -| `REPORT_ENABLED` | `false` | Report-Funktion aktivieren | -| `REPORT_INTERVAL` | `weekly` | Versandintervall (siehe unten) | -| `REPORT_TIME` | `08:00` | Uhrzeit für den Versand (HH:MM, 24h) | -| `REPORT_EMAIL_TO` | *(leer)* | E-Mail-Empfänger | -| `REPORT_EMAIL_FROM` | `adguard-shield@hostname` | E-Mail-Absender | -| `REPORT_FORMAT` | `html` | Format: `html` oder `txt` | -| `REPORT_MAIL_CMD` | `msmtp` | Mail-Befehl (`msmtp`, `sendmail`, `mail`) | -| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum in Tagen für „Aktivster Tag“ (0 = Berichtszeitraum) | - -### Versandintervalle - -| Wert | Beschreibung | -|------|-------------| -| `daily` | Täglich zur konfigurierten Uhrzeit | -| `weekly` | Wöchentlich am Montag | -| `biweekly` | Alle zwei Wochen am Montag | -| `monthly` | Monatlich am 1. des Monats | - -## Report-Inhalte - -Der Report enthält folgende Statistiken: - -### Zeitraum-Schnellübersicht *(immer ganz oben)* - -Eine Vergleichstabelle mit Live-Zahlen für vier feste Zeitfenster – unabhängig vom konfigurierten `REPORT_INTERVAL`: - -| Zeitraum | Sperren | Entsperrungen | Eindeutige IPs | Permanent gebannt | -|----------|---------|---------------|----------------|-------------------| -| Heute *(nur nach 20:00 Uhr)* | … | … | … | … | -| Gestern | … | … | … | … | -| Letzte 7 Tage | … | … | … | … | -| Letzte 14 Tage | … | … | … | … | -| Letzte 30 Tage | … | … | … | … | - -Im HTML-Format wird **Gestern** grün hervorgehoben, **Heute** blau (erscheint nur ab 20:00 Uhr). -- **Gestern** umfasst exakt 00:00:00 – 23:59:59 des gestrigen Tages. -- **Heute** umfasst den laufenden Tag von 00:00:00 bis zum Zeitpunkt der Reportgenerierung und wird nur eingeblendet, wenn der Report nach 20:00 Uhr erstellt wird. -Die übrigen Zeiträume laufen vom Starttag 00:00 Uhr bis zum Zeitpunkt der Reportgenerierung. - -> **Hinweis:** Die AbuseIPDB-Meldungen werden in der Schnellübersicht nicht mehr separat ausgewiesen, da sie immer mit einer Permanentsperre korrelieren – der Wert „Permanent gebannt" ist daher ausreichend. Die Gesamtanzahl der AbuseIPDB-Reports im Berichtszeitraum ist weiterhin in der allgemeinen Übersicht sichtbar. - -### Übersicht (Berichtszeitraum) -- Gesamtzahl der Sperren und Entsperrungen -- Anzahl eindeutiger gesperrter IPs -- Permanente Sperren -- Aktuell aktive Sperren -- AbuseIPDB-Reports - -### Angriffsarten -- Rate-Limit Sperren -- Subdomain-Flood Sperren -- Externe Blocklist Sperren -- Aktivster Tag – wird über einen konfigurierbaren Zeitraum ermittelt (Standard: letzte 30 Tage, `REPORT_BUSIEST_DAY_RANGE`). Zeigt zusätzlich die Anzahl der Sperren an diesem Tag. Bei `REPORT_BUSIEST_DAY_RANGE=0` wird nur der Berichtszeitraum betrachtet. - -### Top 10 Listen -- **Auffälligste IPs** — Die 10 IPs mit den meisten Sperren (mit Balkendiagramm im HTML-Format) -- **Meistbetroffene Domains** — Die 10 am häufigsten betroffenen Domains - -### Weitere Details -- **Protokoll-Verteilung** — Aufschlüsselung nach DNS, DoH, DoT, DoQ -- **Letzte 10 Sperren** — Die aktuellsten Sperrereignisse mit Zeitstempel, IP, Domain und Grund - ## Befehle ```bash -# Report sofort generieren und versenden -sudo /opt/adguard-shield/report-generator.sh send +# Konfiguration und Cron-Status anzeigen +sudo /opt/adguard-shield/adguard-shield report-status -# Test-E-Mail senden (prüft alle Voraussetzungen + Mailversand) -sudo /opt/adguard-shield/report-generator.sh test +# HTML-Report in Datei schreiben +sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html -# Report als Datei generieren (auf stdout ausgeben) -sudo /opt/adguard-shield/report-generator.sh generate +# Text-Report auf stdout ausgeben +sudo /opt/adguard-shield/adguard-shield report-generate txt -# Report im spezifischen Format generieren -sudo /opt/adguard-shield/report-generator.sh generate html > report.html -sudo /opt/adguard-shield/report-generator.sh generate txt > report.txt +# Testmail senden +sudo /opt/adguard-shield/adguard-shield report-test -# Cron-Job für automatischen Versand einrichten -sudo /opt/adguard-shield/report-generator.sh install +# aktuellen Report erzeugen und versenden +sudo /opt/adguard-shield/adguard-shield report-send + +# Cron-Job installieren +sudo /opt/adguard-shield/adguard-shield report-install # Cron-Job entfernen -sudo /opt/adguard-shield/report-generator.sh remove - -# Report-Konfiguration und Cron-Status anzeigen -sudo /opt/adguard-shield/report-generator.sh status +sudo /opt/adguard-shield/adguard-shield report-remove ``` -## Report-Intervall ändern +## Mailversand -Um das Intervall, die Uhrzeit oder andere Einstellungen zu ändern: +AdGuard Shield übergibt die fertige Mail an ein lokales Mailprogramm. Der Standard ist: ```bash -# 1. Konfiguration bearbeiten -sudo nano /opt/adguard-shield/adguard-shield.conf -# → z.B. REPORT_INTERVAL="weekly" auf "daily" ändern -# → z.B. REPORT_TIME="09:00" - -# 2. Cron-Job neu einrichten (überschreibt den alten automatisch) -sudo /opt/adguard-shield/report-generator.sh install +REPORT_MAIL_CMD="msmtp" ``` -> **Hinweis:** Der `install`-Befehl überschreibt den bestehenden Cron-Job mit den aktuellen Werten aus der Konfiguration. Ein vorheriges `remove` ist nicht nötig, schadet aber auch nicht. - -Alternativ in zwei Schritten: +Minimaler Ablauf mit `msmtp`: ```bash -# Alten Cron-Job erst entfernen, dann neu anlegen -sudo /opt/adguard-shield/report-generator.sh remove -sudo nano /opt/adguard-shield/adguard-shield.conf -sudo /opt/adguard-shield/report-generator.sh install -``` - -## Templates - -Die Report-Templates liegen unter: - -``` -/opt/adguard-shield/templates/report.html # HTML-Template -/opt/adguard-shield/templates/report.txt # TXT-Template -``` - -Die Templates verwenden Platzhalter (z.B. `{{TOTAL_BANS}}`, `{{TOP10_IPS_TABLE}}`), die beim Generieren durch die tatsächlichen Werte ersetzt werden. Die Templates können nach Bedarf angepasst werden. - -### Verfügbare Platzhalter - -| Platzhalter | Beschreibung | -|-------------|-------------| -| `{{REPORT_PERIOD}}` | Berichtszeitraum mit Label | -| `{{REPORT_DATE}}` | Erstellungsdatum des Reports | -| `{{HOSTNAME}}` | Server-Hostname | -| `{{VERSION}}` | AdGuard Shield Version | -| `{{TOTAL_BANS}}` | Gesamtzahl Sperren | -| `{{TOTAL_UNBANS}}` | Gesamtzahl Entsperrungen | -| `{{UNIQUE_IPS}}` | Anzahl eindeutiger IPs | -| `{{PERMANENT_BANS}}` | Permanente Sperren | -| `{{ACTIVE_BANS}}` | Aktuell aktive Sperren | -| `{{ABUSEIPDB_REPORTS}}` | Anzahl AbuseIPDB-Reports | -| `{{RATELIMIT_BANS}}` | Rate-Limit Sperren | -| `{{SUBDOMAIN_FLOOD_BANS}}` | Subdomain-Flood Sperren | -| `{{EXTERNAL_BLOCKLIST_BANS}}` | Externe Blocklist Sperren | -| `{{BUSIEST_DAY}}` | Aktivster Tag (Datum + Anzahl Sperren) | -| `{{BUSIEST_DAY_LABEL}}` | Dynamisches Label für den aktivsten Tag (z.B. „Aktivster Tag (30 Tage)“) | -| `{{TOP10_IPS_TABLE}}` | Top 10 IPs (HTML-Tabelle) | -| `{{TOP10_IPS_TEXT}}` | Top 10 IPs (Text-Tabelle) | -| `{{TOP10_DOMAINS_TABLE}}` | Top 10 Domains (HTML-Tabelle) | -| `{{TOP10_DOMAINS_TEXT}}` | Top 10 Domains (Text-Tabelle) | -| `{{PROTOCOL_TABLE}}` | Protokoll-Verteilung (HTML) | -| `{{PROTOCOL_TEXT}}` | Protokoll-Verteilung (Text) | -| `{{RECENT_BANS_TABLE}}` | Letzte Sperren (HTML) | -| `{{RECENT_BANS_TEXT}}` | Letzte Sperren (Text) | - -## Beispiel: Schnellstart - -```bash -# 1. msmtp installieren und konfigurieren sudo apt install msmtp msmtp-mta -# Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/ - -# 2. Report-Konfiguration anpassen -sudo nano /opt/adguard-shield/adguard-shield.conf -# → REPORT_ENABLED=true -# → REPORT_EMAIL_TO="deine@email.de" - -# 3. Test-Mail senden (prüft alle Voraussetzungen) -sudo /opt/adguard-shield/report-generator.sh test - -# 4. Wenn die Test-Mail angekommen ist: echten Report testen -sudo /opt/adguard-shield/report-generator.sh send - -# 5. Automatischen Versand einrichten -sudo /opt/adguard-shield/report-generator.sh install - -# 6. Status prüfen -sudo /opt/adguard-shield/report-generator.sh status +sudo /opt/adguard-shield/adguard-shield report-test ``` -## Test-Mail +`report-test` sendet eine einfache Testmail. Erst wenn diese funktioniert, lohnt sich die Fehlersuche am eigentlichen Report. -Bevor du den automatischen Versand einrichtest, kannst du mit dem `test`-Befehl prüfen, ob alles funktioniert: +Wenn dein Mailprogramm zusätzliche Argumente braucht, können sie in `REPORT_MAIL_CMD` stehen. AdGuard Shield hängt intern `-t` an, damit Empfänger und Header aus der generierten Mail gelesen werden. + +Beispiel: ```bash -sudo /opt/adguard-shield/report-generator.sh test +REPORT_MAIL_CMD="msmtp --account=default" ``` -Der Test prüft Schritt für Schritt: +## Automatischer Versand -1. **E-Mail-Empfänger** — Ist `REPORT_EMAIL_TO` konfiguriert? -2. **E-Mail-Absender** — Zeigt den konfigurierten Absender an -3. **Mail-Befehl** — Ist `msmtp` (oder der konfigurierte Befehl) installiert? -4. **Report-Template** — Existiert das HTML/TXT-Template? -5. **Ban-History** — Gibt es vorhandene Daten? -6. **Test-Versand** — Sendet eine Test-E-Mail und prüft den Exit-Code +Cron installieren: -Die Test-Mail enthält eine Übersicht der aktuellen Konfiguration und bestätigt, dass der Mailversand funktioniert. +```bash +sudo /opt/adguard-shield/adguard-shield report-install +``` -## Troubleshooting +Dadurch wird diese Datei geschrieben: -### E-Mail wird nicht versendet +```text +/etc/cron.d/adguard-shield-report +``` -1. Prüfe ob der Mail-Befehl installiert ist: - ```bash - which msmtp - ``` +Der Cron-Eintrag ruft das installierte Binary mit der installierten Konfiguration auf: -2. Teste den Mailversand manuell: - ```bash - echo "Test" | msmtp -t deine@email.de - ``` +```text +/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf report-send +``` -3. Prüfe die msmtp-Konfiguration: - ```bash - cat ~/.msmtprc - # oder - cat /etc/msmtprc - ``` +Zeitplan nach `REPORT_INTERVAL`: -4. Prüfe die Report-Konfiguration: - ```bash - sudo /opt/adguard-shield/report-generator.sh status - ``` +| Intervall | Cron-Verhalten | +|---|---| +| `daily` | täglich zur Uhrzeit aus `REPORT_TIME` | +| `weekly` | montags zur Uhrzeit aus `REPORT_TIME` | +| `biweekly` | am 1. und 15. des Monats | +| `monthly` | am 1. des Monats | -### Report enthält keine Daten +Cron entfernen: -Der Report basiert auf der Ban-History in der SQLite-Datenbank (`/var/lib/adguard-shield/adguard-shield.db`). Wenn keine Sperren im Berichtszeitraum vorhanden sind, zeigt der Report „Keine Daten" an. +```bash +sudo /opt/adguard-shield/adguard-shield report-remove +``` -### Cron-Job wird nicht ausgeführt +## Manuelle Prüfung -1. Prüfe ob der Cron-Job angelegt wurde: - ```bash - cat /etc/cron.d/adguard-shield-report - ``` +Status: -2. Prüfe die Cron-Logs: - ```bash - grep adguard-shield /var/log/syslog - # oder - journalctl -u cron - ``` +```bash +sudo /opt/adguard-shield/adguard-shield report-status +``` + +Report lokal erzeugen: + +```bash +sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html +sudo /opt/adguard-shield/adguard-shield report-generate txt +``` + +Versand testen: + +```bash +sudo /opt/adguard-shield/adguard-shield report-test +sudo /opt/adguard-shield/adguard-shield report-send +``` + +Logs: + +```bash +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 +sudo journalctl -u cron --no-pager -n 100 +``` + +Je nach Distribution heißt der Cron-Service auch `cron`, `crond` oder wird über das allgemeine Syslog protokolliert. + +## Häufige Probleme + +### `REPORT_EMAIL_TO ist leer` + +Setze einen Empfänger: + +```bash +REPORT_EMAIL_TO="admin@example.com" +``` + +### Mailprogramm nicht gefunden + +Prüfen: + +```bash +which msmtp +``` + +Installieren: + +```bash +sudo apt install msmtp msmtp-mta +``` + +Oder `REPORT_MAIL_CMD` auf dein vorhandenes Mailprogramm setzen. + +### Cron läuft, aber keine Mail kommt an + +Prüfen: + +```bash +sudo /opt/adguard-shield/adguard-shield report-send +sudo cat /etc/cron.d/adguard-shield-report +``` + +Achte darauf, dass: + +- `REPORT_EMAIL_TO` stimmt +- `REPORT_MAIL_CMD` im Cron-PATH verfügbar ist +- der lokale Mailer für root konfiguriert ist +- Spam-Ordner geprüft wurde +- ausgehende SMTP-Verbindungen erlaubt sind + +## HTML und TXT + +HTML ist für normale E-Mail-Clients angenehmer zu lesen: + +```bash +REPORT_FORMAT="html" +``` + +TXT ist robuster für sehr einfache Mail-Setups oder Log-Ablage: + +```bash +REPORT_FORMAT="txt" +``` + +Du kannst das Format beim manuellen Generieren überschreiben: + +```bash +sudo /opt/adguard-shield/adguard-shield report-generate txt +sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/report.html +``` diff --git a/docs/tipps-und-troubleshooting.md b/docs/tipps-und-troubleshooting.md index 9813669..340f0b3 100644 --- a/docs/tipps-und-troubleshooting.md +++ b/docs/tipps-und-troubleshooting.md @@ -1,287 +1,424 @@ # Tipps & Troubleshooting -## Best Practices +Dieses Dokument hilft beim Eingrenzen typischer Probleme im Betrieb. Die Reihenfolge ist bewusst praktisch: erst prüfen, ob der Dienst läuft, dann API, Firewall, Sperren, Listen und optionale Module. -- **Erst immer im Dry-Run testen**, bevor der scharfe Modus aktiviert wird - ```bash - sudo /opt/adguard-shield/adguard-shield.sh dry-run - ``` -- **Whitelist großzügig pflegen**: Eigene IPs, Router, wichtige Server nicht vergessen -- **Sperrdauer anpassen**: Für DDoS-artige Muster ggf. länger sperren -- **Logs regelmäßig prüfen**: Falsche Positive erkennen und Whitelist anpassen -- **Ban-History nutzen**: `history`-Befehl zeigt alle vergangenen Sperren — hilfreich um Muster zu erkennen -- **Log-Level auf DEBUG** setzen wenn etwas nicht funktioniert +## Erste Diagnose -## Häufige Probleme - -### API-Verbindung schlägt fehl +Diese Befehle liefern meistens schon genug Hinweise: ```bash -sudo /opt/adguard-shield/adguard-shield.sh test +sudo systemctl status adguard-shield +sudo journalctl -u adguard-shield --no-pager -n 100 +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 ``` -**Mögliche Ursachen:** -- Falsche URL in `ADGUARD_URL` (Port prüfen!) -- 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) +Wenn du aktuelle Queries sehen willst: -#### Schritt-für-Schritt Diagnose - -**1. Base-URL Erreichbarkeit prüfen (ohne Auth):** ```bash -# 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 +sudo /opt/adguard-shield/adguard-shield live ``` -> `-i` zeigt HTTP-Response-Header, `-k` ignoriert SSL-Fehler, `-v` zeigt Verbindungsdetails (DNS, TLS-Handshake, etc.) +## Service startet nicht + +Prüfen: -**2. DNS-Auflösung testen:** ```bash -# Hostname auflösen -dig +short dns1.domain.com - -# Oder mit nslookup -nslookup dns1.domain.com +sudo systemctl status adguard-shield +sudo journalctl -u adguard-shield --no-pager -n 100 ``` -**3. Port-Erreichbarkeit testen:** -```bash -# TCP-Verbindung zum Port prüfen (z.B. Port 3000) -nc -zv 127.0.0.1 3000 +Typische Ursachen: -# Oder mit curl -curl -v telnet://127.0.0.1:3000 -``` +- Konfigurationsdatei fehlt oder hat falsche Rechte +- Binary fehlt oder ist nicht ausführbar +- `iptables`, `ip6tables` oder `ipset` fehlen +- AdGuard-Home-API ist nicht erreichbar +- alte Shell-Artefakte verursachen Konflikte +- systemd-Unit wurde manuell geändert, aber `daemon-reload` fehlt -**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" - -Das Script muss als **root** laufen, da iptables Root-Rechte benötigt. +Nützliche Prüfungen: ```bash -sudo /opt/adguard-shield/adguard-shield.sh start -``` - -### Client wird fälschlich gesperrt - -1. Client sofort entsperren: - ```bash - sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100 - ``` -2. In der Ban-History prüfen, warum gesperrt wurde: - ```bash - sudo /opt/adguard-shield/adguard-shield.sh history | grep 192.168.1.100 - ``` -3. Offense-Zähler für die IP zurücksetzen (damit die progressive Sperre wieder bei Stufe 1 beginnt): - ```bash - sudo /opt/adguard-shield/adguard-shield.sh reset-offenses 192.168.1.100 - ``` -4. IP zur Whitelist hinzufügen in `adguard-shield.conf` -5. Service neustarten: - ```bash - sudo systemctl restart adguard-shield - ``` - -### Client wurde permanent gesperrt (Progressive Sperren) - -Wenn eine IP die maximale Stufe der progressiven Sperren erreicht hat, wird sie permanent gesperrt und nicht automatisch aufgehoben. - -1. IP entsperren: - ```bash - sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100 - ``` -2. Offense-Zähler zurücksetzen: - ```bash - sudo /opt/adguard-shield/adguard-shield.sh reset-offenses 192.168.1.100 - ``` -3. Prüfen ob die IP auf die Whitelist gehört, oder die Progressive-Ban-Einstellungen anpassen (`PROGRESSIVE_BAN_MAX_LEVEL` erhöhen oder auf `0` setzen für keine permanenten Sperren) - -### Sperren überleben Reboot nicht - -Das ist normal — iptables-Regeln sind flüchtig. Der **Service** erstellt die Chain beim Start automatisch neu. Aktive Sperren aus der SQLite-Datenbank werden aber nicht automatisch als iptables-Regeln wiederhergestellt. - -**Optionen:** -- `iptables-persistent` installieren (`apt install iptables-persistent`) -- Oder den State beim Boot wiederherstellen lassen (Feature-Idee) - -### Zu viele false positives - -- `RATE_LIMIT_MAX_REQUESTS` erhöhen (z.B. 50 oder 100) -- `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 -# Altes PID-File entfernen -sudo rm -f /var/run/adguard-shield.pid -sudo systemctl start adguard-shield -``` - -### Service ist ausgefallen und startet nicht mehr - -Wenn systemd das Restart-Limit erreicht hat (z.B. `"Start request repeated too quickly"`), hilft der **Watchdog** — er prüft alle 5 Minuten ob der Service läuft und startet ihn automatisch neu. - -**Watchdog-Status prüfen:** -```bash -# Timer-Status anzeigen -sudo systemctl status adguard-shield-watchdog.timer - -# Letzte Watchdog-Ausführungen anzeigen -sudo systemctl list-timers adguard-shield-watchdog.timer - -# Watchdog-Logs prüfen -sudo journalctl -u adguard-shield-watchdog.service --no-pager -n 20 -``` - -**Manuelles Recovery (sofort):** -```bash -# systemd-Fehlerzähler zurücksetzen und Service starten -sudo systemctl reset-failed adguard-shield.service -sudo systemctl start adguard-shield.service -``` - -**Watchdog nachträglich aktivieren:** -```bash -sudo systemctl enable adguard-shield-watchdog.timer -sudo systemctl start adguard-shield-watchdog.timer -``` - -> **Hinweis:** Der Watchdog sendet automatisch eine Benachrichtigung (falls `NOTIFY_ENABLED=true`), wenn er den Service wiederbeleben muss oder die Recovery fehlschlägt. - -## Update durchführen - -```bash -# Repository aktualisieren -cd /tmp/adguard-shield -git pull - -# Update ausführen (Konfig wird automatisch migriert, Service neu gestartet) -sudo bash install.sh update -``` - -**Was passiert beim Update:** -- Alle Scripts werden aktualisiert -- Konfiguration wird als `adguard-shield.conf.old` gesichert -- Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig ergänzt -- Bestehende Einstellungen bleiben erhalten -- Bestehende Flat-File-Daten werden einmalig in die SQLite-Datenbank migriert (mit Fortschrittsanzeige) -- Service wird per `daemon-reload` neu geladen und automatisch neu gestartet - -## Deinstallation - -Ab Version 0.6 gibt es einen eigenständigen Uninstaller im Installationsverzeichnis. Die Deinstallation kann daher jederzeit durchgeführt werden, **ohne die originalen Installationsdateien (install.sh) behalten zu müssen**: - -```bash -# Empfohlen: direkt aus dem Installationsverzeichnis ausführen -sudo bash /opt/adguard-shield/uninstall.sh - -# Alternativ: über den Installer (sofern noch vorhanden) -sudo bash install.sh uninstall -``` - -Beide Wege sind gleichwertig — `install.sh uninstall` delegiert intern an `/opt/adguard-shield/uninstall.sh`. - -Oder manuell: -```bash -sudo systemctl stop adguard-shield -sudo systemctl disable adguard-shield -sudo systemctl stop adguard-shield-watchdog.timer -sudo systemctl disable adguard-shield-watchdog.timer -sudo /opt/adguard-shield/iptables-helper.sh remove -sudo rm -rf /opt/adguard-shield -sudo rm -f /etc/systemd/system/adguard-shield.service -sudo rm -f /etc/systemd/system/adguard-shield-watchdog.service -sudo rm -f /etc/systemd/system/adguard-shield-watchdog.timer +ls -l /opt/adguard-shield/adguard-shield +ls -l /opt/adguard-shield/adguard-shield.conf +which iptables ip6tables ipset systemctl sudo systemctl daemon-reload ``` -## Voraussetzungen +## Verbindung zu AdGuard Home schlägt fehl -Folgende Pakete werden für den Betrieb benötigt und bei der Installation automatisch installiert: +Test: -| Paket | Zweck | -|-------|-------| -| `curl` | API-Kommunikation mit AdGuard Home | -| `jq` | JSON-Verarbeitung der API-Antworten | -| `iptables` | Firewall-Regeln (IPv4 + IPv6) | -| `gawk` | Textverarbeitung in Scripts | -| `systemd` | Service-Management und Autostart | -| `sqlite3` | Datenbank für State-Management, Ban-History und Offense-Tracking | +```bash +sudo /opt/adguard-shield/adguard-shield test +``` -Diese werden bei `sudo bash install.sh install` automatisch geprüft und bei Bedarf über den Paketmanager (`apt`, `dnf`, `yum`, `pacman`) nachinstalliert. +Prüfe in `/opt/adguard-shield/adguard-shield.conf`: + +```bash +ADGUARD_URL="http://127.0.0.1:3000" +ADGUARD_USER="admin" +ADGUARD_PASS="..." +``` + +Häufige Fehler: + +| Symptom | Mögliche Ursache | +|---|---| +| HTTP 401/403 | Benutzername oder Passwort falsch | +| HTTP 404 | falsche URL oder AdGuard Home nicht hinter dieser URL | +| Timeout | Firewall, DNS, falsche IP, Dienst nicht erreichbar | +| connection refused | AdGuard Home läuft nicht oder anderer Port | +| keine Querylog-Einträge | Querylog in AdGuard Home deaktiviert oder leer | + +Direkt testen: + +```bash +curl -k -u "admin:passwort" "http://127.0.0.1:3000/control/querylog?limit=1&response_status=all" +``` + +Passe URL und Zugangsdaten entsprechend an. + +## Keine Sperren trotz vieler Anfragen + +Prüfen: + +```bash +sudo /opt/adguard-shield/adguard-shield live --once +sudo /opt/adguard-shield/adguard-shield history 50 +sudo /opt/adguard-shield/adguard-shield logs --level debug --limit 100 +``` + +Mögliche Ursachen: + +- `RATE_LIMIT_MAX_REQUESTS` ist zu hoch +- `RATE_LIMIT_WINDOW` ist zu kurz +- `API_QUERY_LIMIT` ist zu niedrig und verpasst Spitzen +- Client steht in `WHITELIST` +- externe Whitelist enthält die IP +- AdGuard Home sieht nicht die echte Client-IP, sondern nur einen Proxy/Forwarder +- Querylog enthält die Anfragen nicht +- `DRY_RUN=true` ist gesetzt + +Wichtig bei Proxies und Forwardern: Wenn AdGuard Home nur eine einzige interne IP sieht, zählt AdGuard Shield auch nur diese IP. In solchen Setups muss die Architektur geprüft oder der Forwarder gewhitelistet werden. + +## Zu viele Sperren + +Erst Übersicht: + +```bash +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield history 100 +``` + +Dann Ursachen einordnen: + +| Ursache | Gegenmaßnahme | +|---|---| +| legitimer Client fragt häufig dieselbe Domain | Client whitelisten oder Limit erhöhen | +| Router/Resolver bündelt viele Clients | Router/Resolver whitelisten | +| CDN/App erzeugt viele Subdomains | `SUBDOMAIN_FLOOD_MAX_UNIQUE` erhöhen | +| externe Blocklist ist sehr groß | `blocklist-status` prüfen und Benachrichtigungen deaktiviert lassen | +| GeoIP Allowlist zu eng | Länder prüfen oder `GEOIP_MODE` ändern | + +Falsch gesperrte IP freigeben: + +```bash +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 +sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100 +``` + +Dauerhaft ausnehmen: + +```bash +WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.100" +``` + +Danach: + +```bash +sudo systemctl restart adguard-shield +``` + +## Firewall prüfen + +Status: + +```bash +sudo /opt/adguard-shield/adguard-shield firewall-status +``` + +Direkt prüfen: + +```bash +sudo ipset list adguard_shield_v4 +sudo ipset list adguard_shield_v6 +sudo iptables -n -L ADGUARD_SHIELD --line-numbers -v +sudo ip6tables -n -L ADGUARD_SHIELD --line-numbers -v +``` + +Firewall neu aufbauen: + +```bash +sudo /opt/adguard-shield/adguard-shield firewall-remove +sudo /opt/adguard-shield/adguard-shield firewall-create +sudo systemctl restart adguard-shield +``` + +Nach dem Neustart werden aktive Sperren aus SQLite wieder in die ipsets geschrieben. + +## Sperren bleiben nach Ablauf aktiv + +Prüfen: + +```bash +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield history 100 +``` + +Temporäre Sperren werden beim Start und während des Pollings auf Ablauf geprüft. Wenn eine Sperre permanent ist, wird sie nicht automatisch freigegeben. + +Permanent sind typischerweise: + +- DNS-Flood-Watchlist-Treffer +- Progressive-Ban-Maximalstufe +- manuelle `ban`-Sperren +- GeoIP-Sperren +- externe Blocklist mit `EXTERNAL_BLOCKLIST_BAN_DURATION=0` + +Manuell freigeben: + +```bash +sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100 +``` + +## Dry-Run verwenden + +Dry-Run ist ideal für neue Regeln: + +```bash +sudo /opt/adguard-shield/adguard-shield dry-run +``` + +Währenddessen: + +```bash +sudo /opt/adguard-shield/adguard-shield history 50 +``` + +Im Dry-Run werden mögliche Sperren als `DRY` protokolliert. Es entstehen keine aktiven Sperren und keine Firewall-Änderungen. + +## Externe Whitelist + +Status: + +```bash +sudo /opt/adguard-shield/adguard-shield whitelist-status +``` + +Manuell synchronisieren: + +```bash +sudo /opt/adguard-shield/adguard-shield whitelist-sync +``` + +Typische Probleme: + +- URL nicht erreichbar +- Datei enthält Windows-Zeilenenden oder BOM +- Hostname ist nicht auflösbar +- Einträge enthalten Ports oder URLs statt IP/Hostname +- DNS-Auflösung liefert `0.0.0.0`, weil AdGuard den Host blockiert + +Format prüfen: + +```text +192.168.1.100 +10.0.0.0/24 +trusted.example.com +# Kommentare sind erlaubt +``` + +## Externe Blocklist + +Status: + +```bash +sudo /opt/adguard-shield/adguard-shield blocklist-status +``` + +Manuell synchronisieren: + +```bash +sudo /opt/adguard-shield/adguard-shield blocklist-sync +``` + +Alle externen Blocklist-Sperren freigeben: + +```bash +sudo /opt/adguard-shield/adguard-shield blocklist-flush +``` + +Wenn zu viele IPs gesperrt werden: + +1. `EXTERNAL_BLOCKLIST_URLS` prüfen. +2. Liste manuell ansehen. +3. Whitelist für eigene IPs ergänzen. +4. `EXTERNAL_BLOCKLIST_NOTIFY=false` lassen. +5. Bei Bedarf `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` setzen. + +## GeoIP + +Status: + +```bash +sudo /opt/adguard-shield/adguard-shield geoip-status +``` + +Einzelne IP prüfen: + +```bash +sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8 +``` + +Cache leeren: + +```bash +sudo /opt/adguard-shield/adguard-shield geoip-flush-cache +``` + +Alle GeoIP-Sperren freigeben: + +```bash +sudo /opt/adguard-shield/adguard-shield geoip-flush +``` + +Typische Ursachen: + +| Problem | Lösung | +|---|---| +| keine Länder erkannt | MaxMind-Key, MMDB-Pfad oder `geoiplookup` prüfen | +| private IPs werden nicht geprüft | `GEOIP_SKIP_PRIVATE=true` ist aktiv, das ist Standard | +| zu viele Länder gesperrt | `GEOIP_MODE` und `GEOIP_COUNTRIES` prüfen | +| Allowlist sperrt fast alles | im Allowlist-Modus sind nur genannte Länder erlaubt | + +## Reports + +Status: + +```bash +sudo /opt/adguard-shield/adguard-shield report-status +``` + +Test: + +```bash +sudo /opt/adguard-shield/adguard-shield report-test +sudo /opt/adguard-shield/adguard-shield report-generate txt +``` + +Wenn keine Mail ankommt: + +- `REPORT_EMAIL_TO` gesetzt? +- `REPORT_MAIL_CMD` vorhanden? +- Mailer für root konfiguriert? +- Cron installiert? +- Spam-Ordner geprüft? + +Cron prüfen: + +```bash +sudo cat /etc/cron.d/adguard-shield-report +sudo /opt/adguard-shield/adguard-shield report-send +``` + +## Benachrichtigungen + +Prüfen: + +```bash +sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100 +``` + +Häufige Ursachen: + +- `NOTIFY_ENABLED=false` +- falscher `NOTIFY_TYPE` +- Webhook-URL leer +- Ntfy-Topic leer +- Token ungültig +- ausgehende HTTPS-Verbindung blockiert +- externe Blocklist meldet nichts, weil `EXTERNAL_BLOCKLIST_NOTIFY=false` +- GeoIP meldet nichts, weil `GEOIP_NOTIFY=false` + +## SQLite direkt auswerten + +Für tiefergehende Analysen: + +```bash +sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \ + "select source, reason, count(*) from active_bans group by source, reason order by count(*) desc;" +``` + +Letzte History: + +```bash +sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \ + "select timestamp_text, action, client_ip, domain, reason from ban_history order by id desc limit 20;" +``` + +Offense-Zähler: + +```bash +sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \ + "select client_ip, offense_level, last_offense from offense_tracking order by offense_level desc;" +``` + +## Alte Shell-Artefakte entfernen + +Wenn der Installer alte Dateien meldet, zuerst sauber migrieren. Typische alte Dateien: + +```text +adguard-shield.sh +iptables-helper.sh +external-blocklist-worker.sh +external-whitelist-worker.sh +geoip-worker.sh +offense-cleanup-worker.sh +report-generator.sh +unban-expired.sh +adguard-shield-watchdog.sh +``` + +Die Go-Version ersetzt diese Funktionen durch das eine Binary. Alte Worker sollten nicht parallel laufen. + +## Service hart zurücksetzen + +Wenn der Zustand unklar ist: + +```bash +sudo systemctl stop adguard-shield +sudo /opt/adguard-shield/adguard-shield firewall-remove +sudo systemctl start adguard-shield +sudo /opt/adguard-shield/adguard-shield status +``` + +Das entfernt die Firewall-Struktur und lässt den Daemon sie beim Start wieder aus SQLite aufbauen. + +## Deinstallation + +Konfiguration behalten: + +```bash +sudo /opt/adguard-shield/adguard-shield uninstall --keep-config +``` + +Alles entfernen: + +```bash +sudo /opt/adguard-shield/adguard-shield uninstall +``` + +Ohne `--keep-config` werden Installationsverzeichnis, State-Verzeichnis und Logdatei entfernt. diff --git a/docs/update.md b/docs/update.md index aa84a6a..78fa110 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,78 +1,238 @@ # Update-Anleitung -## Voraussetzungen +AdGuard Shield wird in der Go-Version über das Binary selbst installiert und aktualisiert. Es gibt kein `install.sh` und kein `update`-Shellskript mehr. -- AdGuard Shield ist bereits installiert (`/opt/adguard-shield/`) -- Git ist installiert (`sudo apt install git`) -- Zugriff auf den Server per SSH mit Root-Rechten - -## Update durchführen - -### 1. Git-Repository aktualisieren - -Wechsle in das Verzeichnis, in dem du das Repository geklont hast, und hole die neueste Version: +## Kurzfassung ```bash -cd /pfad/zum/adguard-shield -git pull +# neues Linux-Binary bereitstellen +chmod +x ./adguard-shield + +# Update durchführen +sudo ./adguard-shield update ``` -> **Hinweis:** Falls du das Repository z.B. nach `/opt/adguard-shield-repo` geklont hast: -> ```bash -> cd /opt/adguard-shield-repo -> git pull -> ``` +Am Ende fragt der Updater, ob AdGuard Shield direkt neu gestartet werden soll. -### 2. Update-Script ausführen +Danach prüfen: ```bash -sudo bash install.sh update +sudo /opt/adguard-shield/adguard-shield install-status +sudo /opt/adguard-shield/adguard-shield status +sudo journalctl -u adguard-shield --no-pager -n 50 ``` -Das Update-Script macht automatisch folgendes: +## Woher kommt das neue Binary? -1. **Abhängigkeiten prüfen** — Fehlende Pakete (inkl. `sqlite3`) werden nachinstalliert -2. **Scripts aktualisieren** — Alle `.sh`-Dateien werden nach `/opt/adguard-shield/` kopiert -3. **Konfigurations-Migration** — Neue Parameter werden automatisch zur bestehenden Konfiguration hinzugefügt, bestehende Einstellungen bleiben **unverändert** -4. **Backup erstellen** — Die alte Konfiguration wird als `adguard-shield.conf.old` gesichert -5. **Datenbank-Migration (in der v1.0.0)** — Bestehende Flat-File-Daten (`.ban`, `.offenses`, Ban-History-Log) werden einmalig in die SQLite-Datenbank migriert. Die alten Dateien werden als Backup gesichert. Der Fortschritt und das Ergebnis werden im Terminal angezeigt. -6. **Service aktualisieren** — Die systemd Service-Datei und Watchdog-Dateien werden aktualisiert und `daemon-reload` ausgeführt -7. **Watchdog aktivieren** — Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv) -8. **Service neustarten** — Der Service wird automatisch neu gestartet (falls er vorher lief) +Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder aus einem lokalen Build kommen. -### 3. Neue Parameter prüfen (optional) - -Nach dem Update empfiehlt es sich, eventuell neu hinzugefügte Konfigurationsparameter zu prüfen: +Release-Binary für v1.0.0 herunterladen: ```bash -sudo nano /opt/adguard-shield/adguard-shield.conf +curl -fL -o adguard-shield-linux-amd64.tar.gz \ + https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.0.0/adguard-shield-linux-amd64.tar.gz +tar -xzf adguard-shield-linux-amd64.tar.gz +chmod +x ./adguard-shield ``` -Falls etwas nicht stimmt, kann das Backup wiederhergestellt werden: +Build mit lokal installiertem Go: + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-shieldd +``` + +Build ohne lokale Go-Installation mit Docker: + +```bash +docker run --rm -v "$PWD":/src -w /src \ + -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=0 \ + golang:1.22 go build -o adguard-shield ./cmd/adguard-shieldd +``` + +Auf dem Zielserver muss Go nicht installiert sein, wenn dort nur das fertige Binary ausgeführt wird. + +## Was `update` macht + +Der Update-Befehl nutzt intern dieselbe Routine wie die Installation: + +1. Linux- und root-Rechte prüfen. +2. Auf alte Shell-Artefakte prüfen. +3. Systemabhängigkeiten prüfen, sofern nicht `--skip-deps` gesetzt ist. +4. Installationsverzeichnis sicherstellen. +5. neues Binary nach `/opt/adguard-shield/adguard-shield` kopieren. +6. Konfiguration migrieren. +7. systemd-Service neu schreiben. +8. `systemctl daemon-reload` ausführen. +9. Autostart aktivieren, sofern nicht `--no-enable` gesetzt ist. +10. fragen, ob der Service direkt neu gestartet werden soll. + +## Konfigurationsmigration + +Vorhandene Werte bleiben erhalten. Neue Parameter werden ergänzt. + +Wenn eine Migration nötig ist: + +```text +/opt/adguard-shield/adguard-shield.conf # aktualisierte Konfiguration +/opt/adguard-shield/adguard-shield.conf.old # Backup der vorherigen Datei +``` + +Nach dem Update solltest du prüfen: + +```bash +sudo diff -u /opt/adguard-shield/adguard-shield.conf.old /opt/adguard-shield/adguard-shield.conf +``` + +Falls `diff` keine Datei findet, war keine Konfigurationsmigration nötig. + +## Update mit Service-Neustart + +Wenn der Service nach dem Update direkt laufen soll, bestätige die Nachfrage am Ende mit `j`. + +Wenn du vorher manuell prüfen möchtest: + +```bash +sudo ./adguard-shield update +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield dry-run +sudo systemctl restart adguard-shield +``` + +## Update ohne Paketprüfung + +```bash +sudo ./adguard-shield update --skip-deps +``` + +Das ist sinnvoll, wenn du sicher weißt, dass `iptables`, `ip6tables`, `ipset` und `systemctl` vorhanden sind oder wenn Paketinstallation auf deinem System nicht über `apt-get` laufen soll. + +## Update in anderem Installationsverzeichnis + +```bash +sudo ./adguard-shield update --install-dir /opt/adguard-shield-test +``` + +Beachte: Die systemd-Unit heißt weiterhin `adguard-shield.service`. Mehrere parallele produktive Installationen über dieselbe Unit sind nicht vorgesehen. + +## Migration von der alten Shell-Version + +Die Go-Version erkennt alte Shell-Artefakte und bricht ab, wenn sie noch vorhanden sind. + +Typische Funde: + +```text +/opt/adguard-shield/adguard-shield.sh +/opt/adguard-shield/iptables-helper.sh +/opt/adguard-shield/external-blocklist-worker.sh +/opt/adguard-shield/external-whitelist-worker.sh +/opt/adguard-shield/geoip-worker.sh +/opt/adguard-shield/offense-cleanup-worker.sh +/opt/adguard-shield/report-generator.sh +/opt/adguard-shield/unban-expired.sh +/etc/systemd/system/adguard-shield-watchdog.service +/etc/systemd/system/adguard-shield-watchdog.timer +``` + +Warum Abbruch? + +Die alte und die neue Version würden sonst dieselbe Firewall, dieselbe Konfiguration und dieselben Sperren verwalten. Das kann zu schwer nachvollziehbaren Zuständen führen. + +Empfohlener Migrationsablauf: + +```bash +# Konfiguration sichern +sudo cp /opt/adguard-shield/adguard-shield.conf /root/adguard-shield.conf.backup + +# alte Shell-Version mit deren Uninstaller entfernen +# dabei Konfiguration behalten, falls der alte Uninstaller diese Option anbietet + +# neues Go-Binary installieren und alte Konfiguration als Quelle nutzen +sudo ./adguard-shield install --config-source /root/adguard-shield.conf.backup + +# prüfen +sudo /opt/adguard-shield/adguard-shield test +sudo /opt/adguard-shield/adguard-shield dry-run +``` + +Wenn der Go-Installer Legacy-Dateien meldet, entferne nur die gemeldeten alten Artefakte der Shell-Version. Keine fremden Firewall-Regeln oder unrelated Dateien löschen. + +## Nach dem Update prüfen + +Installation: + +```bash +sudo /opt/adguard-shield/adguard-shield install-status +``` + +Service: + +```bash +sudo systemctl status adguard-shield +sudo journalctl -u adguard-shield --no-pager -n 100 +``` + +API: + +```bash +sudo /opt/adguard-shield/adguard-shield test +``` + +Runtime: + +```bash +sudo /opt/adguard-shield/adguard-shield status +sudo /opt/adguard-shield/adguard-shield live --once +``` + +Firewall: + +```bash +sudo /opt/adguard-shield/adguard-shield firewall-status +``` + +## Rollback + +Ein Rollback besteht aus zwei Teilen: altes Binary wieder bereitstellen und passende Konfiguration verwenden. + +Vorgehen: + +1. Service stoppen. +2. altes Binary nach `/opt/adguard-shield/adguard-shield` kopieren. +3. optional `adguard-shield.conf.old` zurückkopieren. +4. Service starten. + +Beispiel: + +```bash +sudo systemctl stop adguard-shield +sudo cp ./adguard-shield-alte-version /opt/adguard-shield/adguard-shield +sudo chmod +x /opt/adguard-shield/adguard-shield +sudo systemctl start adguard-shield +``` + +Wenn die Konfiguration zurückgesetzt werden soll: ```bash sudo cp /opt/adguard-shield/adguard-shield.conf.old /opt/adguard-shield/adguard-shield.conf sudo systemctl restart adguard-shield ``` -## Kurzfassung (Copy & Paste) +Hinweis: SQLite-Schema-Migrationen sind aktuell sehr konservativ. Trotzdem solltest du vor größeren Updates ein Backup von `/var/lib/adguard-shield/adguard-shield.db` erstellen, wenn dir History und aktive Sperren wichtig sind. + +## Backup vor größeren Updates ```bash -cd /pfad/zum/adguard-shield -git pull -sudo bash install.sh update +sudo systemctl stop adguard-shield +sudo cp /opt/adguard-shield/adguard-shield.conf /root/adguard-shield.conf.$(date +%F) +sudo cp /var/lib/adguard-shield/adguard-shield.db /root/adguard-shield.db.$(date +%F) +sudo systemctl start adguard-shield ``` -## Versionsprüfung +Bei laufendem SQLite mit WAL können zusätzliche Dateien existieren: -Installierte Version anzeigen: - -```bash -sudo /opt/adguard-shield/adguard-shield.sh status +```text +adguard-shield.db-wal +adguard-shield.db-shm ``` -Oder über den Installer: - -```bash -sudo bash install.sh status -``` +Am saubersten ist ein kurzer Service-Stop während des Backups. diff --git a/external-blocklist-worker.sh b/external-blocklist-worker.sh deleted file mode 100644 index ebc3d53..0000000 --- a/external-blocklist-worker.sh +++ /dev/null @@ -1,805 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Externer Blocklist-Worker -# Lädt externe IP-Blocklisten herunter und sperrt/entsperrt IPs automatisch. -# Wird als Hintergrundprozess vom Hauptscript gestartet. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Datum: 2026-03-03 -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" - -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Worker PID-File ────────────────────────────────────────────────────────── -WORKER_PID_FILE="/var/run/adguard-blocklist-worker.pid" - -# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ─────────────────────── -declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3) - -log() { - local level="$1" - shift - local message="$*" - local configured_level="${LOG_LEVEL:-INFO}" - - if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [$level] [BLOCKLIST-WORKER] $message" - echo "$log_entry" | tee -a "$LOG_FILE" >&2 - fi -} - -# ─── Ban-History ───────────────────────────────────────────────────────────── -log_ban_history() { - local action="$1" - local client_ip="$2" - local reason="${3:-external-blocklist}" - - local duration="permanent" - [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] && duration="${EXTERNAL_BLOCKLIST_BAN_DURATION}s" - - db_history_add "$action" "$client_ip" "-" "-" "$reason" "$duration" "-" -} - -# ─── Verzeichnisse erstellen ────────────────────────────────────────────────── -init_directories() { - mkdir -p "$EXTERNAL_BLOCKLIST_CACHE_DIR" - mkdir -p "$STATE_DIR" - mkdir -p "$(dirname "$LOG_FILE")" - db_init -} - -# ─── Whitelist Prüfung ─────────────────────────────────────────────────────── -is_whitelisted() { - local ip="$1" - IFS=',' read -ra wl_entries <<< "$WHITELIST" - for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) - if [[ "$ip" == "$entry" ]]; then - return 0 - fi - done - - if db_whitelist_contains "$ip"; then - return 0 - fi - - return 1 -} - -# ─── iptables Chain Setup ──────────────────────────────────────────────────── -setup_iptables_chain() { - # IPv4 Chain erstellen falls nicht vorhanden - if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - log "INFO" "Erstelle iptables Chain: $IPTABLES_CHAIN (IPv4)" - iptables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi - - # IPv6 Chain erstellen falls nicht vorhanden - if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - log "INFO" "Erstelle ip6tables Chain: $IPTABLES_CHAIN (IPv6)" - ip6tables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi -} - -# ─── IP sperren ────────────────────────────────────────────────────────────── -ban_ip() { - local ip="$1" - - # Bereits gesperrt? - if db_ban_exists "$ip"; then - # iptables-Regel pruefen und ggf. nachziehen - if [[ "$ip" == *:* ]]; then - if ! ip6tables -C "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null; then - ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - fi - else - if ! iptables -C "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null; then - iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - fi - fi - log "DEBUG" "IP $ip bereits gesperrt" - return 0 - fi - - if [[ "$DRY_RUN" == "true" ]]; then - log "WARN" "[DRY-RUN] WÜRDE sperren (externe Blocklist): $ip" - log_ban_history "DRY" "$ip" "external-blocklist-dry-run" - return 0 - fi - - log "WARN" "SPERRE IP (externe Blocklist): $ip" - - if [[ "$ip" == *:* ]]; then - ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - else - iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - fi - - local ban_until_epoch="0" - local is_permanent=1 - if [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]]; then - ban_until_epoch=$(date -d "+${EXTERNAL_BLOCKLIST_BAN_DURATION} seconds" '+%s' 2>/dev/null \ - || date -v "+${EXTERNAL_BLOCKLIST_BAN_DURATION}S" '+%s') - is_permanent=0 - fi - - db_ban_insert "$ip" "-" "0" "$(date '+%Y-%m-%d %H:%M:%S')" "$ban_until_epoch" "${EXTERNAL_BLOCKLIST_BAN_DURATION:-0}" "0" "$is_permanent" "external-blocklist" "-" "external-blocklist" - - log_ban_history "BAN" "$ip" "external-blocklist" - - if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then - send_notification "ban" "$ip" - fi -} - -# ─── IP entsperren ─────────────────────────────────────────────────────────── -unban_ip() { - local ip="$1" - local reason="${2:-external-blocklist-removed}" - - db_ban_exists "$ip" || return 0 - - log "INFO" "ENTSPERRE IP (externe Blocklist entfernt): $ip" - - if [[ "$ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true - fi - - db_ban_delete "$ip" - log_ban_history "UNBAN" "$ip" "$reason" - - if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then - send_notification "unban" "$ip" - fi -} - -# ─── Hostname-Auflösung ────────────────────────────────────────────────────── -# Versucht den Hostnamen einer IP per Reverse-DNS aufzulösen -resolve_hostname() { - local ip="$1" - local hostname="" - - if command -v dig &>/dev/null; then - hostname=$(dig +short -x "$ip" 2>/dev/null | head -1 | sed 's/\.$//') - fi - - if [[ -z "$hostname" ]] && command -v host &>/dev/null; then - hostname=$(host "$ip" 2>/dev/null | awk '/domain name pointer/ {print $NF}' | sed 's/\.$//' | head -1) - fi - - if [[ -z "$hostname" ]] && command -v getent &>/dev/null; then - hostname=$(getent hosts "$ip" 2>/dev/null | awk '{print $2}' | head -1) - fi - - echo "${hostname:-(unbekannt)}" -} - -# ─── Benachrichtigung ──────────────────────────────────────────────────────── -send_notification() { - local action="$1" - local ip="$2" - - # ntfy benötigt keine NOTIFY_WEBHOOK_URL, alle anderen schon - if [[ "${NOTIFY_TYPE:-generic}" != "ntfy" && -z "${NOTIFY_WEBHOOK_URL:-}" ]]; then - return - fi - - local title - local message - local my_hostname - my_hostname=$(hostname) - local client_hostname - client_hostname=$(resolve_hostname "$ip") - - if [[ "$action" == "ban" ]]; then - title="🚨 🛡️ AdGuard Shield" - message="🚫 AdGuard Shield Ban auf ${my_hostname} (Externe Blocklist) ---- -IP: ${ip} -Hostname: ${client_hostname} - -Whois: https://www.whois.com/whois/${ip} -AbuseIPDB: https://www.abuseipdb.com/check/${ip}" - else - title="✅ AdGuard Shield" - message="✅ AdGuard Shield Freigabe auf ${my_hostname} (Externe Blocklist) ---- -IP: ${ip} -Hostname: ${client_hostname} - -AbuseIPDB: https://www.abuseipdb.com/check/${ip}" - fi - - case "${NOTIFY_TYPE:-generic}" in - discord) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{content: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - slack) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{text: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - gotify) - curl -s -X POST "$NOTIFY_WEBHOOK_URL" \ - -F "title=${title}" \ - -F "message=${message}" \ - -F "priority=5" &>/dev/null & - ;; - ntfy) - local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}" - local tags="rotating_light,blocklist" - [[ "$action" != "ban" ]] && tags="white_check_mark,blocklist" - # Ntfy fügt Emojis über Tags hinzu → Titel ohne führende Emojis setzen - local ntfy_title - case "$action" in - ban) ntfy_title="🛡️ AdGuard Shield" ;; - *) ntfy_title="AdGuard Shield" ;; - esac - local -a curl_args=( - -s -X POST "${ntfy_url}/${NTFY_TOPIC}" - -H "Title: ${ntfy_title}" - -H "Priority: ${NTFY_PRIORITY:-3}" - -H "Tags: ${tags}" - -d "${message}" - ) - [[ -n "${NTFY_TOKEN:-}" ]] && curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}") - curl "${curl_args[@]}" &>/dev/null & - ;; - generic) - local json_payload - json_payload=$(jq -nc --arg msg "$message" --arg act "$action" --arg cl "$ip" \ - '{message: $msg, action: $act, client: $cl, source: "external-blocklist"}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - esac -} - -# ─── Externe Blocklist herunterladen ───────────────────────────────────────── -download_blocklist() { - local url="$1" - local index="$2" - local cache_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.txt" - local etag_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.etag" - local tmp_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.tmp" - - log "DEBUG" "Prüfe externe Blocklist: $url" - - # HTTP-Header für bedingte Anfrage vorbereiten - local -a curl_args=( - -s - -L - --connect-timeout 10 - --max-time 30 - -o "$tmp_file" - -w "%{http_code}" - ) - - # ETag für If-None-Match Header nutzen falls vorhanden - if [[ -f "$etag_file" ]]; then - local stored_etag - stored_etag=$(cat "$etag_file") - curl_args+=(-H "If-None-Match: ${stored_etag}") - fi - - # Download-Header separat abfragen für ETag - local http_code - http_code=$(curl "${curl_args[@]}" -D "${tmp_file}.headers" "$url" 2>/dev/null) || { - log "WARN" "Fehler beim Download der Blocklist: $url" - rm -f "$tmp_file" "${tmp_file}.headers" - return 1 - } - - # 304 Not Modified - keine Änderung - if [[ "$http_code" == "304" ]]; then - log "DEBUG" "Blocklist nicht geändert (HTTP 304): $url" - rm -f "$tmp_file" "${tmp_file}.headers" - return 1 - fi - - # Fehlerhafte HTTP-Codes - if [[ "$http_code" != "200" ]]; then - log "WARN" "Blocklist Download fehlgeschlagen (HTTP $http_code): $url" - rm -f "$tmp_file" "${tmp_file}.headers" - return 1 - fi - - # Neuen ETag speichern falls vorhanden - if [[ -f "${tmp_file}.headers" ]]; then - local new_etag - new_etag=$(grep -i '^etag:' "${tmp_file}.headers" | head -1 | sed 's/^[^:]*: *//;s/\r$//') - if [[ -n "$new_etag" ]]; then - echo "$new_etag" > "$etag_file" - fi - fi - rm -f "${tmp_file}.headers" - - # Prüfen ob sich der Inhalt tatsächlich geändert hat (Fallback für Server ohne ETag) - if [[ -f "$cache_file" ]]; then - if diff -q "$tmp_file" "$cache_file" &>/dev/null; then - log "DEBUG" "Blocklist Inhalt unverändert: $url" - rm -f "$tmp_file" - return 1 - fi - fi - - # Neue Datei übernehmen - mv "$tmp_file" "$cache_file" - log "INFO" "Blocklist aktualisiert: $url" - return 0 -} - -# ─── Eintrag-Validierung ───────────────────────────────────────────────────── - -# Prüft IPv4-Adresse mit optionalem CIDR (z.B. 1.2.3.4 oder 1.2.3.0/24) -_is_valid_ipv4() { - local ip="$1" addr="$1" prefix="" - if [[ "$ip" == */* ]]; then - addr="${ip%/*}" - prefix="${ip#*/}" - { [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 32 ]]; } || return 1 - fi - local IFS='.' - read -ra _octets <<< "$addr" - [[ ${#_octets[@]} -eq 4 ]] || return 1 - local o - for o in "${_octets[@]}"; do - [[ "$o" =~ ^[0-9]+$ ]] || return 1 - [[ "$o" -le 255 ]] || return 1 - done - return 0 -} - -# Prüft IPv6-Adresse mit optionalem CIDR (z.B. ::1 oder 2001:db8::/32) -# Fängt auch IPv4:Port-Kombinationen ab (z.B. 1.2.3.4:8080) -_is_valid_ipv6() { - local ip="$1" addr="$1" - if [[ "$ip" == */* ]]; then - addr="${ip%/*}" - local prefix="${ip#*/}" - { [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 128 ]]; } || return 1 - fi - # IPv4:Port abfangen — enthält Punkt(e) vor dem ersten Doppelpunkt - [[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9] ]] && return 1 - # Muss mindestens einen Doppelpunkt haben und nur gültige Zeichen (Hex, Doppelpunkt, Punkt für IPv4-mapped) - [[ "$addr" == *:* ]] || return 1 - [[ "$addr" =~ ^[0-9a-fA-F:\.]+$ ]] || return 1 - return 0 -} - -# Prüft ob ein Hostname syntaktisch plausibel ist -# Akzeptiert: example.com, sub.example.com, example.com. (trailing dot) -# Lehnt ab: einzelne Wörter ohne Punkt, Sonderzeichen, überlange Einträge -_is_valid_hostname() { - local host="$1" - host="${host%.}" # trailing dot (FQDN) entfernen - [[ -z "$host" ]] && return 1 - [[ ${#host} -gt 253 ]] && return 1 - [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1 - [[ "$host" =~ ^[.\-] ]] && return 1 # darf nicht mit . oder - beginnen - [[ "$host" == *.* ]] || return 1 # muss mindestens einen Punkt enthalten - return 0 -} - -# ─── IPs aus Blocklist-Datei parsen ────────────────────────────────────────── -# Unterstützt IPv4, IPv6, CIDR-Notation und Hostnamen (werden aufgelöst). -# Unterstützt außerdem das Hosts-Datei-Format: "0.0.0.0 hostname" oder "127.0.0.1 hostname". -# Ungültige Einträge (URLs, IP:Port, fehlerhafte IPs, einzelne Wörter usw.) werden -# mit WARN geloggt und übersprungen. -# 0.0.0.0 / :: wird nie importiert (AdGuard-typische Blocking-Antwort). -parse_blocklist_ips() { - local cache_file="$1" - - [[ -f "$cache_file" ]] || return - - while IFS= read -r line; do - line="${line%$'\r'}" # Windows-Zeilenenden (CRLF) entfernen - line="${line#$'\xef\xbb\xbf'}" # UTF-8 BOM entfernen (erste Zeile) - - # Leerzeilen und Kommentarzeilen überspringen - [[ -z "$line" ]] && continue - [[ "$line" =~ ^[[:space:]]*# ]] && continue - - # Whitespace trimmen, dann Inline-Kommentare entfernen (# oder ;) - line=$(echo "$line" | xargs) - line=$(echo "$line" | sed 's/[[:space:]]*[#;].*$//' | xargs) - [[ -z "$line" ]] && continue - - # ── URLs ablehnen (http://, https://, ftp:// …) ────────────────────── - if [[ "$line" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then - log "WARN" "Eintrag übersprungen (URL nicht erlaubt): $line" - continue - fi - - # ── Hosts-Datei-Format erkennen: " " ─────────────── - # z.B. "0.0.0.0 bad.com" oder "127.0.0.1 malware.net" - if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+[^[:space:]] ]]; then - local _first="${line%% *}" - local _rest="${line#* }" - local _second="${_rest%% *}" - if [[ "$_first" == "0.0.0.0" || "$_first" =~ ^127\. || - "$_first" == "::1" || "$_first" == "::0" || - "$_first" == "::" ]]; then - log "DEBUG" "Hosts-Format erkannt, extrahiere Ziel: $_second" - line="$_second" - else - log "WARN" "Eintrag übersprungen (Leerzeichen im Eintrag, unbekanntes Format): $line" - continue - fi - fi - - # ── Klassifizieren und validieren ───────────────────────────────────── - if [[ "$line" == *:* ]]; then - # ── IPv6 ────────────────────────────────────────────────────────── - if _is_valid_ipv6 "$line"; then - echo "$line" - else - log "WARN" "Eintrag übersprungen (ungültige IPv6-Adresse oder IP:Port): $line" - fi - - elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then - # ── IPv4 (nur Ziffern, Punkte und optionaler CIDR-Suffix) ──────── - [[ "$line" == "0.0.0.0"* ]] && continue - if _is_valid_ipv4 "$line"; then - echo "$line" - else - log "WARN" "Eintrag übersprungen (ungültige IPv4-Adresse oder ungültiges CIDR): $line" - fi - - else - # ── Hostname → DNS-Auflösung ────────────────────────────────────── - if ! _is_valid_hostname "$line"; then - log "WARN" "Eintrag übersprungen (kein gültiger Hostname): $line" - continue - fi - local resolved - resolved=$(getent ahosts "$line" 2>/dev/null | awk '{print $1}' | sort -u) || resolved="" - if [[ -z "$resolved" ]]; then - log "WARN" "Hostname konnte nicht aufgelöst werden: $line" - continue - fi - local resolved_count=0 - while IFS= read -r resolved_ip; do - [[ -z "$resolved_ip" ]] && continue - [[ "$resolved_ip" == "0.0.0.0" ]] && continue # AdGuard-Blocking-Antwort - [[ "$resolved_ip" == "::" ]] && continue # IPv6 unspecified - [[ "$resolved_ip" == "::0" ]] && continue - echo "$resolved_ip" - resolved_count=$((resolved_count + 1)) - done <<< "$resolved" - if [[ $resolved_count -gt 0 ]]; then - log "DEBUG" "Hostname aufgelöst: $line → $resolved_count IP(s)" - else - log "WARN" "Hostname lieferte nur ungültige Adressen (z.B. 0.0.0.0): $line – wird übersprungen" - fi - fi - done < "$cache_file" -} - -# ─── Aktuelle externe Sperren ermitteln ────────────────────────────────────── -get_currently_banned_external_ips() { - db_ban_get_by_source "external-blocklist" -} - -# ─── Abgelaufene externe Sperren prüfen ───────────────────────────────────── -check_expired_external_bans() { - [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] || return - - local expired_ips - expired_ips=$(db_ban_get_expired_by_source "external-blocklist") - [[ -z "$expired_ips" ]] && return - - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - unban_ip "$client_ip" "external-blocklist-expired" - done <<< "$expired_ips" -} - -# ─── Blocklisten synchronisieren ───────────────────────────────────────────── -sync_blocklists() { - local any_updated=false - - # Alle URLs holen - IFS=',' read -ra urls <<< "$EXTERNAL_BLOCKLIST_URLS" - local index=0 - - for url in "${urls[@]}"; do - url=$(echo "$url" | xargs) # trim - [[ -z "$url" ]] && continue - - if download_blocklist "$url" "$index"; then - any_updated=true - fi - index=$((index + 1)) - done - - # Alle gewünschten IPs zusammenführen (aus allen Cache-Dateien) - local all_desired_ips_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/.all_ips.tmp" - > "$all_desired_ips_file" - - for cache_file in "${EXTERNAL_BLOCKLIST_CACHE_DIR}"/blocklist_*.txt; do - [[ -f "$cache_file" ]] || continue - parse_blocklist_ips "$cache_file" >> "$all_desired_ips_file" - done - - # Duplikate entfernen und sortieren - local unique_ips_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/.all_ips_unique.tmp" - sort -u "$all_desired_ips_file" > "$unique_ips_file" - - local desired_count - desired_count=$(wc -l < "$unique_ips_file" | xargs) - log "DEBUG" "Externe Blockliste enthält $desired_count eindeutige IPs" - - # ─── Neue IPs sperren ──────────────────────────────────────────────────── - local new_bans=0 - while IFS= read -r ip; do - [[ -z "$ip" ]] && continue - - # Whitelist prüfen - if is_whitelisted "$ip"; then - log "DEBUG" "IP $ip ist auf der Whitelist - überspringe (externe Blocklist)" - continue - fi - - local _was_new=false - db_ban_exists "$ip" || _was_new=true - - ban_ip "$ip" - [[ "$_was_new" == "true" ]] && new_bans=$((new_bans + 1)) - done < "$unique_ips_file" - - # ─── Entfernte IPs entsperren ──────────────────────────────────────────── - if [[ "$EXTERNAL_BLOCKLIST_AUTO_UNBAN" == "true" ]]; then - local removed_count=0 - while IFS= read -r banned_ip; do - [[ -z "$banned_ip" ]] && continue - # Prüfen ob die IP noch in der gewünschten Liste ist - if ! grep -qxF "$banned_ip" "$unique_ips_file" 2>/dev/null; then - unban_ip "$banned_ip" "external-blocklist-removed" - removed_count=$((removed_count + 1)) - fi - done < <(get_currently_banned_external_ips) - - if [[ $removed_count -gt 0 ]]; then - log "INFO" "$removed_count IPs aus externer Blocklist entfernt und entsperrt" - fi - fi - - # Abgelaufene Sperren prüfen (nur bei zeitlich begrenzten Sperren) - check_expired_external_bans - - # Aufräumen - rm -f "$all_desired_ips_file" "$unique_ips_file" - - if [[ "$new_bans" -gt 0 ]]; then - log "INFO" "$new_bans neue IPs aus externer Blocklist gesperrt" - fi -} - -# ─── PID-Management ────────────────────────────────────────────────────────── -write_pid() { - echo $$ > "$WORKER_PID_FILE" -} - -cleanup() { - log "INFO" "Externer Blocklist-Worker wird beendet..." - rm -f "$WORKER_PID_FILE" - exit 0 -} - -check_already_running() { - if [[ -f "$WORKER_PID_FILE" ]]; then - local old_pid - old_pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$old_pid" 2>/dev/null; then - log "DEBUG" "Blocklist-Worker läuft bereits (PID: $old_pid)" - return 1 - else - rm -f "$WORKER_PID_FILE" - fi - fi - return 0 -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " Externer Blocklist-Worker - Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - if [[ "$EXTERNAL_BLOCKLIST_ENABLED" != "true" ]]; then - echo " ⚠️ Externer Blocklist-Worker ist deaktiviert" - echo " Aktivieren: EXTERNAL_BLOCKLIST_ENABLED=true in $CONFIG_FILE" - echo "" - return - fi - - # Worker-Prozess Status - if [[ -f "$WORKER_PID_FILE" ]]; then - local pid - pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - echo " ✅ Worker läuft (PID: $pid)" - else - echo " ❌ Worker nicht aktiv (veraltete PID-Datei)" - fi - else - echo " ❌ Worker nicht aktiv" - fi - - echo "" - - # Konfigurierte URLs - echo " Konfigurierte Blocklisten:" - IFS=',' read -ra urls <<< "$EXTERNAL_BLOCKLIST_URLS" - local index=0 - for url in "${urls[@]}"; do - url=$(echo "$url" | xargs) - [[ -z "$url" ]] && continue - local cache_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.txt" - local ip_count=0 - if [[ -f "$cache_file" ]]; then - ip_count=$(grep -cv '^\s*#\|^\s*$' "$cache_file" 2>/dev/null || echo "0") - local last_modified - last_modified=$(date -r "$cache_file" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt") - echo " [$index] $url" - echo " IPs: $ip_count | Zuletzt aktualisiert: $last_modified" - else - echo " [$index] $url (noch nicht heruntergeladen)" - fi - index=$((index + 1)) - done - - echo "" - - # Aktive externe Sperren - local ext_ban_count - ext_ban_count=$(db_ban_count_by_source "external-blocklist") - echo " Aktive Sperren (externe Blocklist): ${ext_ban_count:-0}" - - echo "" - echo " Prüfintervall: ${EXTERNAL_BLOCKLIST_INTERVAL}s" - echo " Auto-Unban: ${EXTERNAL_BLOCKLIST_AUTO_UNBAN}" - if [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]]; then - echo " Sperrdauer: ${EXTERNAL_BLOCKLIST_BAN_DURATION}s" - else - echo " Sperrdauer: permanent (bis aus Liste entfernt)" - fi - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Einmalig synchronisieren ──────────────────────────────────────────────── -run_once() { - init_directories - setup_iptables_chain - - if [[ -z "${EXTERNAL_BLOCKLIST_URLS:-}" ]]; then - log "ERROR" "Keine externen Blocklist-URLs konfiguriert (EXTERNAL_BLOCKLIST_URLS)" - exit 1 - fi - - log "INFO" "Einmalige Blocklist-Synchronisation..." - sync_blocklists - log "INFO" "Synchronisation abgeschlossen" -} - -# ─── Hauptschleife ────────────────────────────────────────────────────────── -main_loop() { - init_directories - setup_iptables_chain - - if [[ -z "${EXTERNAL_BLOCKLIST_URLS:-}" ]]; then - log "ERROR" "Keine externen Blocklist-URLs konfiguriert (EXTERNAL_BLOCKLIST_URLS)" - exit 1 - fi - - log "INFO" "═══════════════════════════════════════════════════════════" - log "INFO" "Externer Blocklist-Worker gestartet" - log "INFO" " URLs: ${EXTERNAL_BLOCKLIST_URLS}" - log "INFO" " Prüfintervall: ${EXTERNAL_BLOCKLIST_INTERVAL}s" - log "INFO" " Auto-Unban: ${EXTERNAL_BLOCKLIST_AUTO_UNBAN}" - log "INFO" "═══════════════════════════════════════════════════════════" - - while true; do - sync_blocklists - sleep "$EXTERNAL_BLOCKLIST_INTERVAL" - done -} - -# ─── Signal-Handler ────────────────────────────────────────────────────────── -trap cleanup SIGTERM SIGINT SIGHUP - -# ─── Kommandozeilen-Argumente ──────────────────────────────────────────────── -case "${1:-start}" in - start) - if ! check_already_running; then - exit 0 - fi - write_pid - main_loop - ;; - stop) - if [[ -f "$WORKER_PID_FILE" ]]; then - kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true - rm -f "$WORKER_PID_FILE" - echo "Blocklist-Worker gestoppt" - else - echo "Blocklist-Worker läuft nicht" - fi - ;; - sync) - run_once - ;; - status) - init_directories - show_status - ;; - flush) - init_directories - echo "Entferne alle externen Blocklist-Sperren..." - local flush_ips - flush_ips=$(db_ban_get_by_source "external-blocklist") - if [[ -n "$flush_ips" ]]; then - while IFS= read -r _ip; do - [[ -z "$_ip" ]] && continue - unban_ip "$_ip" "manual-flush" - done <<< "$flush_ips" - fi - echo "Alle externen Blocklist-Sperren aufgehoben" - ;; - *) - cat << USAGE -AdGuard Shield - Externer Blocklist-Worker - -Nutzung: $0 {start|stop|sync|status|flush} - -Befehle: - start Startet den Worker (Dauerbetrieb) - stop Stoppt den Worker - sync Einmalige Synchronisation - status Zeigt Status und konfigurierte Listen - flush Entfernt alle externen Blocklist-Sperren - -Konfiguration: $CONFIG_FILE - -USAGE - exit 0 - ;; -esac diff --git a/external-whitelist-worker.sh b/external-whitelist-worker.sh deleted file mode 100644 index 101f832..0000000 --- a/external-whitelist-worker.sh +++ /dev/null @@ -1,523 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Externer Whitelist-Worker -# Lädt externe Whitelist-Dateien herunter, löst Domains zu IPs auf und -# stellt diese dem Hauptscript als dynamische Whitelist zur Verfügung. -# Ideal für DynDNS-Domains mit wechselnden IP-Adressen. -# Wird als Hintergrundprozess vom Hauptscript gestartet. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Datum: 2026-04-04 -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" - -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Standardwerte ──────────────────────────────────────────────────────────── -EXTERNAL_WHITELIST_CACHE_DIR="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}" - -# ─── Worker PID-File ────────────────────────────────────────────────────────── -WORKER_PID_FILE="/var/run/adguard-whitelist-worker.pid" - -# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ─────────────────────── -declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3) - -log() { - local level="$1" - shift - local message="$*" - local configured_level="${LOG_LEVEL:-INFO}" - - if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [$level] [WHITELIST-WORKER] $message" - echo "$log_entry" | tee -a "$LOG_FILE" >&2 - fi -} - -# ─── Verzeichnisse erstellen ────────────────────────────────────────────────── -init_directories() { - mkdir -p "$EXTERNAL_WHITELIST_CACHE_DIR" - mkdir -p "$(dirname "$LOG_FILE")" - db_init -} - -# ─── Eintrag-Validierung ───────────────────────────────────────────────────── - -# Prüft IPv4-Adresse mit optionalem CIDR -_is_valid_ipv4() { - local ip="$1" addr="$1" prefix="" - if [[ "$ip" == */* ]]; then - addr="${ip%/*}" - prefix="${ip#*/}" - { [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 32 ]]; } || return 1 - fi - local IFS='.' - read -ra _octets <<< "$addr" - [[ ${#_octets[@]} -eq 4 ]] || return 1 - local o - for o in "${_octets[@]}"; do - [[ "$o" =~ ^[0-9]+$ ]] || return 1 - [[ "$o" -le 255 ]] || return 1 - done - return 0 -} - -# Prüft IPv6-Adresse mit optionalem CIDR -_is_valid_ipv6() { - local ip="$1" addr="$1" - if [[ "$ip" == */* ]]; then - addr="${ip%/*}" - local prefix="${ip#*/}" - { [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 128 ]]; } || return 1 - fi - [[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9] ]] && return 1 - [[ "$addr" == *:* ]] || return 1 - [[ "$addr" =~ ^[0-9a-fA-F:\.]+$ ]] || return 1 - return 0 -} - -# Prüft ob ein Hostname syntaktisch plausibel ist -_is_valid_hostname() { - local host="$1" - host="${host%.}" # trailing dot entfernen - [[ -z "$host" ]] && return 1 - [[ ${#host} -gt 253 ]] && return 1 - [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1 - [[ "$host" =~ ^[.\-] ]] && return 1 - [[ "$host" == *.* ]] || return 1 - return 0 -} - -# ─── Externe Whitelist herunterladen ───────────────────────────────────────── -download_whitelist() { - local url="$1" - local index="$2" - local cache_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.txt" - local etag_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.etag" - local tmp_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.tmp" - - log "DEBUG" "Prüfe externe Whitelist: $url" - - local -a curl_args=( - -s - -L - --connect-timeout 10 - --max-time 30 - -o "$tmp_file" - -w "%{http_code}" - ) - - if [[ -f "$etag_file" ]]; then - local stored_etag - stored_etag=$(cat "$etag_file") - curl_args+=(-H "If-None-Match: ${stored_etag}") - fi - - local http_code - http_code=$(curl "${curl_args[@]}" -D "${tmp_file}.headers" "$url" 2>/dev/null) || { - log "WARN" "Fehler beim Download der Whitelist: $url" - rm -f "$tmp_file" "${tmp_file}.headers" - return 1 - } - - if [[ "$http_code" == "304" ]]; then - log "DEBUG" "Whitelist nicht geändert (HTTP 304): $url" - rm -f "$tmp_file" "${tmp_file}.headers" - # Auch bei 304 müssen wir DNS neu auflösen (dynamische IPs!) - return 0 - fi - - if [[ "$http_code" != "200" ]]; then - log "WARN" "Whitelist Download fehlgeschlagen (HTTP $http_code): $url" - rm -f "$tmp_file" "${tmp_file}.headers" - return 1 - fi - - if [[ -f "${tmp_file}.headers" ]]; then - local new_etag - new_etag=$(grep -i '^etag:' "${tmp_file}.headers" | head -1 | sed 's/^[^:]*: *//;s/\r$//') - if [[ -n "$new_etag" ]]; then - echo "$new_etag" > "$etag_file" - fi - fi - rm -f "${tmp_file}.headers" - - if [[ -f "$cache_file" ]]; then - if diff -q "$tmp_file" "$cache_file" &>/dev/null; then - log "DEBUG" "Whitelist Inhalt unverändert: $url" - rm -f "$tmp_file" - return 0 - fi - fi - - mv "$tmp_file" "$cache_file" - log "INFO" "Whitelist aktualisiert: $url" - return 0 -} - -# ─── Einträge aus Whitelist-Datei parsen und IPs auflösen ─────────────────── -# Gibt pro Zeile eine IP-Adresse aus (aufgelöste Domains + direkte IPs) -parse_whitelist_entries() { - local cache_file="$1" - - [[ -f "$cache_file" ]] || return - - while IFS= read -r line; do - line="${line%$'\r'}" - line="${line#$'\xef\xbb\xbf'}" - - [[ -z "$line" ]] && continue - [[ "$line" =~ ^[[:space:]]*# ]] && continue - - line=$(echo "$line" | xargs) - line=$(echo "$line" | sed 's/[[:space:]]*[#;].*$//' | xargs) - [[ -z "$line" ]] && continue - - # URLs ablehnen - if [[ "$line" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then - log "WARN" "Whitelist-Eintrag übersprungen (URL nicht erlaubt): $line" - continue - fi - - # Hosts-Datei-Format erkennen - if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+[^[:space:]] ]]; then - local _first="${line%% *}" - local _rest="${line#* }" - local _second="${_rest%% *}" - if [[ "$_first" == "0.0.0.0" || "$_first" =~ ^127\. || - "$_first" == "::1" || "$_first" == "::0" || - "$_first" == "::" ]]; then - log "DEBUG" "Whitelist Hosts-Format erkannt, extrahiere: $_second" - line="$_second" - else - log "WARN" "Whitelist-Eintrag übersprungen (unbekanntes Format): $line" - continue - fi - fi - - # Klassifizieren und validieren - if [[ "$line" == *:* ]]; then - # IPv6 - if _is_valid_ipv6 "$line"; then - echo "$line" - else - log "WARN" "Whitelist-Eintrag übersprungen (ungültige IPv6): $line" - fi - - elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then - # IPv4 (nur Ziffern, Punkte und optionaler CIDR-Suffix) - [[ "$line" == "0.0.0.0"* ]] && continue - if _is_valid_ipv4 "$line"; then - echo "$line" - else - log "WARN" "Whitelist-Eintrag übersprungen (ungültige IPv4): $line" - fi - - else - # Hostname → DNS-Auflösung (wird bei jedem Durchlauf neu aufgelöst!) - if ! _is_valid_hostname "$line"; then - log "WARN" "Whitelist-Eintrag übersprungen (kein gültiger Hostname): $line" - continue - fi - local resolved - resolved=$(getent ahosts "$line" 2>/dev/null | awk '{print $1}' | sort -u) || resolved="" - if [[ -z "$resolved" ]]; then - log "WARN" "Whitelist-Hostname konnte nicht aufgelöst werden: $line" - continue - fi - local resolved_count=0 - while IFS= read -r resolved_ip; do - [[ -z "$resolved_ip" ]] && continue - [[ "$resolved_ip" == "0.0.0.0" ]] && continue - [[ "$resolved_ip" == "::" ]] && continue - [[ "$resolved_ip" == "::0" ]] && continue - echo "$resolved_ip" - resolved_count=$((resolved_count + 1)) - done <<< "$resolved" - if [[ $resolved_count -gt 0 ]]; then - log "DEBUG" "Whitelist-Hostname aufgelöst: $line → $resolved_count IP(s)" - else - log "WARN" "Whitelist-Hostname lieferte nur ungültige Adressen: $line" - fi - fi - done < "$cache_file" -} - -# ─── Whitelisten synchronisieren ───────────────────────────────────────────── -sync_whitelists() { - # Alle URLs herunterladen - IFS=',' read -ra urls <<< "$EXTERNAL_WHITELIST_URLS" - local index=0 - - for url in "${urls[@]}"; do - url=$(echo "$url" | xargs) - [[ -z "$url" ]] && continue - - download_whitelist "$url" "$index" || true - index=$((index + 1)) - done - - # Alle Eintraege aus Cache-Dateien parsen und IPs aufloesen - local all_ips_file="${EXTERNAL_WHITELIST_CACHE_DIR}/.all_ips.tmp" - > "$all_ips_file" - - for cache_file in "${EXTERNAL_WHITELIST_CACHE_DIR}"/whitelist_*.txt; do - [[ -f "$cache_file" ]] || continue - parse_whitelist_entries "$cache_file" >> "$all_ips_file" - done - - # Duplikate entfernen und in SQLite-Whitelist schreiben (atomar) - local unique_file="${EXTERNAL_WHITELIST_CACHE_DIR}/.all_ips_unique.tmp" - sort -u "$all_ips_file" > "$unique_file" - local unique_count - unique_count=$(wc -l < "$unique_file" | xargs) - - db_whitelist_sync "external" < "$unique_file" - - rm -f "$all_ips_file" "$unique_file" - - log "DEBUG" "Externe Whitelist: $unique_count eindeutige IPs aufgelöst" - - # Pruefen ob gesperrte IPs jetzt auf der Whitelist stehen - check_banned_whitelist_ips -} - -# ─── Gesperrte IPs prüfen die jetzt gewhitelistet sind ────────────────────── -check_banned_whitelist_ips() { - # Alle gesperrten IPs pruefen, ob sie jetzt auf der Whitelist stehen - local banned_ips - banned_ips=$(db_query "SELECT a.client_ip FROM active_bans a INNER JOIN whitelist_cache w ON a.client_ip = w.ip_address;") - [[ -z "$banned_ips" ]] && return - - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - log "INFO" "Gesperrte IP $client_ip ist jetzt auf externer Whitelist – entsperre automatisch" - - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - db_ban_delete "$client_ip" - db_history_add "UNBAN" "$client_ip" "-" "-" "external-whitelist" "-" "-" - done <<< "$banned_ips" -} - -# ─── PID-Management ────────────────────────────────────────────────────────── -write_pid() { - echo $$ > "$WORKER_PID_FILE" -} - -cleanup() { - log "INFO" "Externer Whitelist-Worker wird beendet..." - rm -f "$WORKER_PID_FILE" - exit 0 -} - -check_already_running() { - if [[ -f "$WORKER_PID_FILE" ]]; then - local old_pid - old_pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$old_pid" 2>/dev/null; then - log "DEBUG" "Whitelist-Worker läuft bereits (PID: $old_pid)" - return 1 - else - rm -f "$WORKER_PID_FILE" - fi - fi - return 0 -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " Externer Whitelist-Worker - Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - if [[ "$EXTERNAL_WHITELIST_ENABLED" != "true" ]]; then - echo " ⚠️ Externer Whitelist-Worker ist deaktiviert" - echo " Aktivieren: EXTERNAL_WHITELIST_ENABLED=true in $CONFIG_FILE" - echo "" - return - fi - - # Worker-Prozess Status - if [[ -f "$WORKER_PID_FILE" ]]; then - local pid - pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - echo " ✅ Worker läuft (PID: $pid)" - else - echo " ❌ Worker nicht aktiv (veraltete PID-Datei)" - fi - else - echo " ❌ Worker nicht aktiv" - fi - - echo "" - - # Konfigurierte URLs - echo " Konfigurierte Whitelisten:" - IFS=',' read -ra urls <<< "$EXTERNAL_WHITELIST_URLS" - local index=0 - for url in "${urls[@]}"; do - url=$(echo "$url" | xargs) - [[ -z "$url" ]] && continue - local cache_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.txt" - if [[ -f "$cache_file" ]]; then - local entry_count - entry_count=$(grep -cv '^\s*#\|^\s*$' "$cache_file" 2>/dev/null || echo "0") - local last_modified - last_modified=$(date -r "$cache_file" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt") - echo " [$index] $url" - echo " Einträge: $entry_count | Zuletzt aktualisiert: $last_modified" - else - echo " [$index] $url (noch nicht heruntergeladen)" - fi - index=$((index + 1)) - done - - echo "" - - # Aufgelöste IPs aus Datenbank - local resolved_count - resolved_count=$(db_whitelist_count) - - if [[ "${resolved_count:-0}" -gt 0 ]]; then - echo " Aufgelöste IPs: $resolved_count" - - if [[ "$resolved_count" -le 20 ]]; then - echo "" - echo " Aktuelle IPs:" - local all_wl_ips - all_wl_ips=$(db_whitelist_get_all) - while IFS= read -r ip; do - echo " ✅ $ip" - done <<< "$all_wl_ips" - else - echo "" - echo " Erste 20 IPs:" - local first_wl_ips - first_wl_ips=$(db_query "SELECT ip_address FROM whitelist_cache LIMIT 20;") - while IFS= read -r ip; do - echo " ✅ $ip" - done <<< "$first_wl_ips" - echo " ... ($((resolved_count - 20)) weitere)" - fi - else - echo " Aufgelöste IPs: 0 (noch keine Synchronisation durchgeführt)" - fi - - echo "" - echo " Prüfintervall: ${EXTERNAL_WHITELIST_INTERVAL}s" - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Einmalig synchronisieren ──────────────────────────────────────────────── -run_once() { - init_directories - - if [[ -z "${EXTERNAL_WHITELIST_URLS:-}" ]]; then - log "ERROR" "Keine externen Whitelist-URLs konfiguriert (EXTERNAL_WHITELIST_URLS)" - exit 1 - fi - - log "INFO" "Einmalige Whitelist-Synchronisation..." - sync_whitelists - log "INFO" "Whitelist-Synchronisation abgeschlossen" -} - -# ─── Hauptschleife ────────────────────────────────────────────────────────── -main_loop() { - init_directories - - if [[ -z "${EXTERNAL_WHITELIST_URLS:-}" ]]; then - log "ERROR" "Keine externen Whitelist-URLs konfiguriert (EXTERNAL_WHITELIST_URLS)" - exit 1 - fi - - log "INFO" "═══════════════════════════════════════════════════════════" - log "INFO" "Externer Whitelist-Worker gestartet" - log "INFO" " URLs: ${EXTERNAL_WHITELIST_URLS}" - log "INFO" " Prüfintervall: ${EXTERNAL_WHITELIST_INTERVAL}s" - log "INFO" "═══════════════════════════════════════════════════════════" - - while true; do - sync_whitelists - sleep "$EXTERNAL_WHITELIST_INTERVAL" - done -} - -# ─── Signal-Handler ────────────────────────────────────────────────────────── -trap cleanup SIGTERM SIGINT SIGHUP - -# ─── Kommandozeilen-Argumente ──────────────────────────────────────────────── -case "${1:-start}" in - start) - if ! check_already_running; then - exit 0 - fi - write_pid - main_loop - ;; - stop) - if [[ -f "$WORKER_PID_FILE" ]]; then - kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true - rm -f "$WORKER_PID_FILE" - echo "Whitelist-Worker gestoppt" - else - echo "Whitelist-Worker läuft nicht" - fi - ;; - sync) - run_once - ;; - status) - init_directories - show_status - ;; - flush) - init_directories - echo "Entferne aufgelöste externe Whitelist-IPs..." - db_whitelist_clear - echo "Externe Whitelist-IPs entfernt" - ;; - *) - cat << USAGE -AdGuard Shield - Externer Whitelist-Worker - -Nutzung: $0 {start|stop|sync|status|flush} - -Befehle: - start Startet den Worker (Dauerbetrieb) - stop Stoppt den Worker - sync Einmalige Synchronisation (DNS-Auflösung) - status Zeigt Status und aufgelöste IPs - flush Entfernt alle aufgelösten Whitelist-IPs - -Konfiguration: $CONFIG_FILE - -USAGE - exit 0 - ;; -esac diff --git a/geoip-worker.sh b/geoip-worker.sh deleted file mode 100644 index 62c92bb..0000000 --- a/geoip-worker.sh +++ /dev/null @@ -1,892 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - GeoIP Worker -# Prüft Client-IPs auf Herkunftsland und sperrt/erlaubt basierend auf Konfig. -# Wird als Hintergrundprozess vom Hauptscript gestartet. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" - -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Worker PID-File ────────────────────────────────────────────────────────── -WORKER_PID_FILE="/var/run/adguard-geoip-worker.pid" - -# ─── GeoIP Cache ────────────────────────────────────────────────────────────── -GEOIP_CACHE_DIR="${STATE_DIR}/geoip-cache" - -# ─── MaxMind Auto-Download Verzeichnis ──────────────────────────────────────── -GEOIP_DB_DIR="${SCRIPT_DIR}/geoip" -GEOIP_AUTO_DB="${GEOIP_DB_DIR}/GeoLite2-Country.mmdb" -GEOIP_DB_UPDATE_INTERVAL=86400 # 24 Stunden (fest) - -# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ─────────────────────── -declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3) - -log() { - local level="$1" - shift - local message="$*" - local configured_level="${LOG_LEVEL:-INFO}" - - if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [$level] [GEOIP-WORKER] $message" - echo "$log_entry" | tee -a "$LOG_FILE" >&2 - fi -} - -# ─── Ban-History ───────────────────────────────────────────────────────────── -log_ban_history() { - local action="$1" - local client_ip="$2" - local country="${3:-}" - local reason="${4:-geoip}" - - db_history_add "$action" "$client_ip" "Land: ${country:-?}" "-" "$reason" "permanent" "-" -} - -# ─── Verzeichnisse erstellen ────────────────────────────────────────────────── -init_directories() { - mkdir -p "$GEOIP_CACHE_DIR" - mkdir -p "$GEOIP_DB_DIR" - mkdir -p "$STATE_DIR" - mkdir -p "$(dirname "$LOG_FILE")" - db_init -} - -# ─── Private IP-Adressen erkennen ──────────────────────────────────────────── -is_private_ip() { - local ip="$1" - - # IPv6 Loopback und Link-Local - if [[ "$ip" == "::1" || "$ip" == fe80:* || "$ip" == fc00:* || "$ip" == fd00:* ]]; then - return 0 - fi - - # IPv4 private Bereiche - if [[ "$ip" =~ ^10\. || "$ip" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. || "$ip" =~ ^192\.168\. || "$ip" =~ ^127\. || "$ip" == "0.0.0.0" ]]; then - return 0 - fi - - # IPv4 CGNAT - if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\. ]]; then - return 0 - fi - - return 1 -} - -# ─── Whitelist Prüfung ─────────────────────────────────────────────────────── -is_whitelisted() { - local ip="$1" - IFS=',' read -ra wl_entries <<< "$WHITELIST" - for entry in "${wl_entries[@]}"; do - entry=$(echo "$entry" | xargs) - if [[ "$ip" == "$entry" ]]; then - return 0 - fi - done - - if db_whitelist_contains "$ip"; then - return 0 - fi - - return 1 -} - -# ─── MaxMind GeoLite2 Auto-Download & Update ───────────────────────────────── -# Lädt die GeoLite2-Country.mmdb herunter, wenn GEOIP_LICENSE_KEY gesetzt ist -# und kein eigener GEOIP_MMDB_PATH angegeben wurde. -# Aktualisiert automatisch alle 24 Stunden. -update_maxmind_db() { - local license_key="${GEOIP_LICENSE_KEY:-}" - - # Kein License-Key → nichts zu tun - if [[ -z "$license_key" ]]; then - return 0 - fi - - # User hat eigenen Pfad gesetzt → kein Auto-Download - if [[ -n "${GEOIP_MMDB_PATH:-}" ]]; then - return 0 - fi - - # Prüfen ob Update nötig (alle 24h) - if [[ -f "$GEOIP_AUTO_DB" ]]; then - local db_age - db_age=$(( $(date '+%s') - $(stat -c '%Y' "$GEOIP_AUTO_DB" 2>/dev/null || stat -f '%m' "$GEOIP_AUTO_DB" 2>/dev/null || echo "0") )) - if [[ "$db_age" -lt "$GEOIP_DB_UPDATE_INTERVAL" ]]; then - log "DEBUG" "MaxMind DB ist aktuell (Alter: $((db_age / 3600))h, nächstes Update in $(( (GEOIP_DB_UPDATE_INTERVAL - db_age) / 3600 ))h)" - return 0 - fi - log "INFO" "MaxMind DB ist älter als 24h – starte Update..." - else - log "INFO" "MaxMind DB nicht vorhanden – starte Erstdownload..." - fi - - # Download-URL zusammenbauen (MaxMind Permalink) - local download_url="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${license_key}&suffix=tar.gz" - local tmp_file="${GEOIP_DB_DIR}/GeoLite2-Country.tar.gz" - local tmp_extract="${GEOIP_DB_DIR}/extract_tmp" - - # Herunterladen - local http_code - http_code=$(curl -s -o "$tmp_file" -w "%{http_code}" \ - --connect-timeout 10 \ - --max-time 60 \ - "$download_url" 2>/dev/null) || true - - if [[ "$http_code" != "200" ]]; then - rm -f "$tmp_file" - case "$http_code" in - 401) log "ERROR" "MaxMind Download fehlgeschlagen: Ungültiger License-Key (HTTP 401)" ;; - 403) log "ERROR" "MaxMind Download fehlgeschlagen: Zugriff verweigert (HTTP 403) – License-Key prüfen" ;; - *) log "ERROR" "MaxMind Download fehlgeschlagen (HTTP ${http_code:-timeout})" ;; - esac - return 1 - fi - - # Entpacken - rm -rf "$tmp_extract" - mkdir -p "$tmp_extract" - - if ! tar -xzf "$tmp_file" -C "$tmp_extract" 2>/dev/null; then - log "ERROR" "MaxMind DB: tar-Archiv konnte nicht entpackt werden" - rm -f "$tmp_file" - rm -rf "$tmp_extract" - return 1 - fi - - # .mmdb Datei finden und verschieben - local mmdb_file - mmdb_file=$(find "$tmp_extract" -name 'GeoLite2-Country.mmdb' -type f 2>/dev/null | head -1) - - if [[ -z "$mmdb_file" || ! -f "$mmdb_file" ]]; then - log "ERROR" "MaxMind DB: GeoLite2-Country.mmdb nicht im Archiv gefunden" - rm -f "$tmp_file" - rm -rf "$tmp_extract" - return 1 - fi - - mv "$mmdb_file" "$GEOIP_AUTO_DB" - rm -f "$tmp_file" - rm -rf "$tmp_extract" - - log "INFO" "MaxMind GeoLite2-Country DB erfolgreich aktualisiert: $GEOIP_AUTO_DB" - return 0 -} - -# ─── Effektiven MMDB-Pfad ermitteln ────────────────────────────────────────── -# Priorität: GEOIP_MMDB_PATH (User) > Auto-Download > leer (Fallback auf geoiplookup) -resolve_mmdb_path() { - # User hat eigenen Pfad gesetzt - if [[ -n "${GEOIP_MMDB_PATH:-}" && -f "${GEOIP_MMDB_PATH:-}" ]]; then - echo "$GEOIP_MMDB_PATH" - return 0 - fi - - # Auto-Download DB vorhanden - if [[ -f "$GEOIP_AUTO_DB" ]]; then - echo "$GEOIP_AUTO_DB" - return 0 - fi - - # Kein MMDB verfügbar - echo "" - return 1 -} - -# ─── GeoIP Lookup ──────────────────────────────────────────────────────────── -# Gibt den ISO 3166-1 Alpha-2 Ländercode zurück (z.B. "DE", "US", "CN") -# Nutzt Cache um wiederholte Lookups zu vermeiden -geoip_lookup() { - local ip="$1" - local cache_file="${GEOIP_CACHE_DIR}/${ip//[:\/]/_}.country" - - # Cache prüfen (max 24 Stunden alt) - if [[ -f "$cache_file" ]]; then - local cache_age - cache_age=$(( $(date '+%s') - $(stat -c '%Y' "$cache_file" 2>/dev/null || stat -f '%m' "$cache_file" 2>/dev/null || echo "0") )) - if [[ "$cache_age" -lt 86400 ]]; then - cat "$cache_file" - return 0 - fi - fi - - local country_code="" - - # Effektiven MMDB-Pfad ermitteln (User-Pfad oder Auto-Download) - local effective_mmdb - effective_mmdb=$(resolve_mmdb_path 2>/dev/null) || true - - # Methode 1: MaxMind mmdbinspect (bevorzugt, genauer) - if [[ -n "$effective_mmdb" && -f "$effective_mmdb" ]] && command -v mmdbinspect &>/dev/null; then - country_code=$(mmdbinspect -db "$effective_mmdb" -ip "$ip" 2>/dev/null \ - | jq -r '.[0].Records[0].Record.country.iso_code // empty' 2>/dev/null || true) - fi - - # Methode 2: geoiplookup (GeoIP Legacy) - if [[ -z "$country_code" ]] && command -v geoiplookup &>/dev/null; then - if [[ "$ip" == *:* ]]; then - # IPv6 - if command -v geoiplookup6 &>/dev/null; then - country_code=$(geoiplookup6 "$ip" 2>/dev/null \ - | grep -oP '(?<=: )[A-Z]{2}(?=,)' | head -1 || true) - fi - else - # IPv4 - country_code=$(geoiplookup "$ip" 2>/dev/null \ - | grep -oP '(?<=: )[A-Z]{2}(?=,)' | head -1 || true) - fi - fi - - # Methode 3: mmdblookup (libmaxminddb) - if [[ -z "$country_code" && -n "$effective_mmdb" && -f "$effective_mmdb" ]] && command -v mmdblookup &>/dev/null; then - country_code=$(mmdblookup --file "$effective_mmdb" --ip "$ip" country iso_code 2>/dev/null \ - | grep -oP '"[A-Z]{2}"' | tr -d '"' | head -1 || true) - fi - - if [[ -n "$country_code" ]]; then - echo "$country_code" > "$cache_file" - echo "$country_code" - return 0 - fi - - # Unbekannt – nicht cachen (könnte temporärer Fehler sein) - echo "" - return 1 -} - -# ─── GeoIP Prüfung: Soll eine IP gesperrt werden? ──────────────────────────── -# Return 0 = sperren, Return 1 = erlauben -should_block_by_geoip() { - local country_code="$1" - local mode="${GEOIP_MODE:-blocklist}" - local countries="${GEOIP_COUNTRIES:-}" - - [[ -z "$country_code" || -z "$countries" ]] && return 1 - - # Länder-Liste in Array umwandeln - IFS=',' read -ra country_list <<< "$countries" - - local found=false - for c in "${country_list[@]}"; do - c=$(echo "$c" | xargs | tr '[:lower:]' '[:upper:]') # trim + uppercase - if [[ "$country_code" == "$c" ]]; then - found=true - break - fi - done - - if [[ "$mode" == "blocklist" ]]; then - # Blocklist-Modus: Sperren wenn Land in der Liste - [[ "$found" == "true" ]] && return 0 || return 1 - elif [[ "$mode" == "allowlist" ]]; then - # Allowlist-Modus: Sperren wenn Land NICHT in der Liste - [[ "$found" == "true" ]] && return 1 || return 0 - fi - - return 1 -} - -# ─── IP via iptables sperren ───────────────────────────────────────────────── -ban_ip_geoip() { - local client_ip="$1" - local country_code="$2" - local mode="${GEOIP_MODE:-blocklist}" - - # Prüfen ob bereits gesperrt - if db_ban_exists "$client_ip"; then - log "DEBUG" "GeoIP: $client_ip ist bereits gesperrt" - return 0 - fi - - # GeoIP-Sperren sind immer permanent - local ban_until=0 - local ban_until_display="PERMANENT" - - local reason_text - if [[ "$mode" == "blocklist" ]]; then - reason_text="geoip-blocklist (Land: $country_code)" - else - reason_text="geoip-allowlist (Land: $country_code)" - fi - - log "WARN" "GeoIP SPERRE: $client_ip (Land: $country_code, Modus: $mode) PERMANENT" - - # iptables Regel setzen - if [[ "$client_ip" == *:* ]]; then - ip6tables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - # State in Datenbank speichern - db_ban_insert "$client_ip" "GeoIP:${country_code}" "0" "$(date '+%Y-%m-%d %H:%M:%S')" "0" "0" "0" "1" "geoip" "-" "geoip" "$country_code" "$mode" - - # Ban-History - log_ban_history "BAN" "$client_ip" "$country_code" "$reason_text" - - # Benachrichtigung senden - if [[ "${GEOIP_NOTIFY:-true}" == "true" && "${NOTIFY_ENABLED:-false}" == "true" ]]; then - send_geoip_notification "ban" "$client_ip" "$country_code" "PERMANENT" "$mode" - fi -} - -# ─── GeoIP Benachrichtigung ────────────────────────────────────────────────── -send_geoip_notification() { - local action="$1" - local client_ip="$2" - local country_code="$3" - local duration="${4:-PERMANENT}" - local mode="${5:-blocklist}" - local my_hostname - my_hostname=$(hostname) - - local title="🌍 🛡️ AdGuard Shield" - local mode_label - [[ "$mode" == "blocklist" ]] && mode_label="Blocklist" || mode_label="Allowlist" - - local message="🌍 AdGuard Shield GeoIP-Sperre auf ${my_hostname} ---- -IP: ${client_ip} -Land: ${country_code} -Modus: ${mode_label} -Dauer: ${duration} - -Whois: https://www.whois.com/whois/${client_ip} -AbuseIPDB: https://www.abuseipdb.com/check/${client_ip}" - - case "${NOTIFY_TYPE:-}" in - discord) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{content: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - slack) - local json_payload - json_payload=$(jq -nc --arg msg "$message" '{text: $msg}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - gotify) - local clean_message - clean_message=$(echo "$message" | sed 's/\*\*//g') - curl -s -X POST "$NOTIFY_WEBHOOK_URL" \ - -F "title=${title}" \ - -F "message=${clean_message}" \ - -F "priority=5" &>/dev/null & - ;; - ntfy) - local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}" - local -a curl_args=( - -s -X POST - "${ntfy_url}/${NTFY_TOPIC}" - -H "Title: 🛡️ AdGuard Shield GeoIP" - -H "Priority: ${NTFY_PRIORITY:-4}" - -H "Tags: globe_with_meridians,ban" - -d "$message" - ) - [[ -n "${NTFY_TOKEN:-}" ]] && curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}") - curl "${curl_args[@]}" &>/dev/null & - ;; - generic) - local json_payload - json_payload=$(jq -nc --arg msg "$message" --arg cl "$client_ip" --arg cc "$country_code" \ - '{message: $msg, action: "geoip-ban", client: $cl, country: $cc}') - curl -s -H "Content-Type: application/json" \ - -d "$json_payload" \ - "$NOTIFY_WEBHOOK_URL" &>/dev/null & - ;; - esac -} - -# ─── iptables Chain Setup ──────────────────────────────────────────────────── -setup_iptables_chain() { - if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - iptables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi - if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - ip6tables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - fi -} - -# ─── GeoIP-Tools Verfügbarkeit prüfen ──────────────────────────────────────── -check_geoip_tools() { - # Effektiven MMDB-Pfad ermitteln - local effective_mmdb - effective_mmdb=$(resolve_mmdb_path 2>/dev/null) || true - - # mmdbinspect + MMDB - if [[ -n "$effective_mmdb" && -f "$effective_mmdb" ]]; then - if command -v mmdbinspect &>/dev/null; then - echo "mmdbinspect" - return 0 - elif command -v mmdblookup &>/dev/null; then - echo "mmdblookup" - return 0 - fi - fi - - # geoiplookup (Legacy GeoIP) - if command -v geoiplookup &>/dev/null; then - echo "geoiplookup" - return 0 - fi - - echo "none" - return 1 -} - -# ─── Client-IPs aus AdGuard API extrahieren ────────────────────────────────── -get_active_clients() { - local response - response=$(curl -s -u "${ADGUARD_USER}:${ADGUARD_PASS}" \ - --connect-timeout 5 \ - --max-time 10 \ - -k "${ADGUARD_URL}/control/querylog?limit=${API_QUERY_LIMIT:-500}&response_status=all" 2>/dev/null) - - if [[ -z "$response" || "$response" == "null" ]]; then - log "ERROR" "Keine Antwort von AdGuard Home API" - return 1 - fi - - # Eindeutige Client-IPs extrahieren - echo "$response" | jq -r '.data // [] | [.[].client // .[].client_info.ip] | unique | .[]' 2>/dev/null | sort -u -} - -# ─── Auto-Unban: GeoIP-Sperren aufheben bei Konfigurationsänderung ──────────── -# Prüft alle bestehenden GeoIP-Sperren und hebt sie auf, wenn: -# - Das Land nicht mehr in GEOIP_COUNTRIES steht -# - Der Modus gewechselt wurde (blocklist ↔ allowlist) -# - GeoIP deaktiviert wurde -auto_unban_geoip() { - local unban_count=0 - local geoip_bans - geoip_bans=$(db_ban_get_by_reason "geoip") - [[ -z "$geoip_bans" ]] && return - - while IFS='|' read -r client_ip domain count ban_time ban_until_epoch ban_duration offense_level is_permanent reason protocol source geoip_country geoip_mode; do - [[ -z "$client_ip" ]] && continue - - local should_unban=false - - if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then - should_unban=true - elif [[ -n "$geoip_mode" && "$geoip_mode" != "${GEOIP_MODE:-blocklist}" ]]; then - should_unban=true - elif [[ -n "$geoip_country" ]] && ! should_block_by_geoip "$geoip_country"; then - should_unban=true - fi - - if [[ "$should_unban" == "true" ]]; then - log "INFO" "GeoIP Auto-Unban: $client_ip (Land: ${geoip_country:-?}, war: ${geoip_mode:-?})" - - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - db_ban_delete "$client_ip" - log_ban_history "UNBAN" "$client_ip" "$geoip_country" "geoip-auto-unban" - unban_count=$((unban_count + 1)) - fi - done <<< "$geoip_bans" - - if [[ $unban_count -gt 0 ]]; then - log "INFO" "GeoIP Auto-Unban: $unban_count Sperren aufgehoben (Länderliste/Modus geändert)" - fi -} - -# ─── Einmaliger GeoIP-Sync ────────────────────────────────────────────────── -sync_geoip() { - # Auto-Unban zuerst: bestehende Sperren prüfen, die nicht mehr zur Config passen - auto_unban_geoip - - if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then - log "INFO" "GeoIP ist deaktiviert" - return 0 - fi - - # MaxMind DB automatisch herunterladen/aktualisieren (falls License-Key gesetzt) - update_maxmind_db || true - - local countries="${GEOIP_COUNTRIES:-}" - if [[ -z "$countries" ]]; then - log "WARN" "GeoIP: Keine Länder konfiguriert (GEOIP_COUNTRIES ist leer)" - return 0 - fi - - local tool - tool=$(check_geoip_tools) || { - log "ERROR" "GeoIP: Kein GeoIP-Tool verfügbar. Installiere geoip-bin oder mmdbinspect." - return 1 - } - log "INFO" "GeoIP-Sync gestartet (Tool: $tool, Modus: ${GEOIP_MODE:-blocklist}, Länder: $countries)" - - # Client-IPs aus der API holen - local clients - clients=$(get_active_clients) || { - log "ERROR" "GeoIP: Konnte aktive Clients nicht ermitteln" - return 1 - } - - local checked=0 - local blocked=0 - local skipped=0 - - while IFS= read -r client_ip; do - [[ -z "$client_ip" || "$client_ip" == "null" ]] && continue - - # Private IPs überspringen - if [[ "${GEOIP_SKIP_PRIVATE:-true}" == "true" ]] && is_private_ip "$client_ip"; then - log "DEBUG" "GeoIP: Private IP übersprungen: $client_ip" - skipped=$((skipped + 1)) - continue - fi - - # Whitelist prüfen - if is_whitelisted "$client_ip"; then - log "DEBUG" "GeoIP: Whitelisted IP übersprungen: $client_ip" - skipped=$((skipped + 1)) - continue - fi - - # Bereits gesperrt? - if db_ban_exists "$client_ip"; then - skipped=$((skipped + 1)) - continue - fi - - checked=$((checked + 1)) - - # GeoIP Lookup - local country_code - country_code=$(geoip_lookup "$client_ip") || true - - if [[ -z "$country_code" ]]; then - log "DEBUG" "GeoIP: Kein Ergebnis für $client_ip" - continue - fi - - log "DEBUG" "GeoIP: $client_ip → $country_code" - - # Prüfen ob gesperrt werden soll - if should_block_by_geoip "$country_code"; then - ban_ip_geoip "$client_ip" "$country_code" - blocked=$((blocked + 1)) - fi - done <<< "$clients" - - log "INFO" "GeoIP-Sync abgeschlossen: $checked geprüft, $blocked gesperrt, $skipped übersprungen" -} - -# ─── Worker-Hauptschleife ──────────────────────────────────────────────────── -start_worker() { - if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then - log "DEBUG" "GeoIP-Worker ist deaktiviert" - return 0 - fi - - # PID schreiben - echo $$ > "$WORKER_PID_FILE" - trap 'rm -f "$WORKER_PID_FILE"; exit 0' SIGTERM SIGINT SIGHUP - - local interval="${GEOIP_CHECK_INTERVAL:-0}" - [[ "$interval" -le 0 ]] && interval="${CHECK_INTERVAL:-10}" - - log "INFO" "GeoIP-Worker gestartet (PID: $$, Intervall: ${interval}s)" - - # Beim Start: MaxMind DB herunterladen/aktualisieren (falls License-Key gesetzt) - update_maxmind_db || true - - while true; do - sync_geoip - sleep "$interval" - done -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " AdGuard Shield - GeoIP Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then - echo " ℹ️ GeoIP ist deaktiviert" - echo "" - return - fi - - echo " Modus: ${GEOIP_MODE:-blocklist}" - echo " Länder: ${GEOIP_COUNTRIES:-}" - echo " Sperrdauer: PERMANENT (Auto-Unban bei Änderung der Länderliste)" - echo " Private IPs überspringen: ${GEOIP_SKIP_PRIVATE:-true}" - echo "" - - # MaxMind DB Info - local eff_mmdb - eff_mmdb=$(resolve_mmdb_path) - if [[ -n "${GEOIP_MMDB_PATH:-}" ]]; then - echo " MMDB-Pfad: ${GEOIP_MMDB_PATH} (manuell konfiguriert)" - elif [[ -n "${GEOIP_LICENSE_KEY:-}" ]]; then - echo " MMDB-Pfad: ${GEOIP_AUTO_DB} (Auto-Download)" - if [[ -f "${GEOIP_AUTO_DB}" ]]; then - local db_age db_age_h - db_age=$(( $(date +%s) - $(stat -c %Y "${GEOIP_AUTO_DB}" 2>/dev/null || echo 0) )) - db_age_h=$(( db_age / 3600 )) - echo " DB-Alter: ${db_age_h}h (Update alle 24h)" - else - echo " DB-Status: ⚠️ Noch nicht heruntergeladen" - fi - elif [[ -n "$eff_mmdb" ]]; then - echo " MMDB-Pfad: ${eff_mmdb}" - else - echo " MMDB-Pfad: (Fallback auf geoiplookup)" - fi - echo " License-Key: $(if [[ -n "${GEOIP_LICENSE_KEY:-}" ]]; then echo "✅ konfiguriert"; else echo "❌ nicht gesetzt (kein Auto-Download)"; fi)" - echo "" - - # GeoIP Tools prüfen - echo " GeoIP Tools:" - local tool - tool=$(check_geoip_tools 2>/dev/null) || tool="none" - case "$tool" in - mmdbinspect) echo " ✅ mmdbinspect mit MaxMind DB" ;; - mmdblookup) echo " ✅ mmdblookup mit MaxMind DB" ;; - geoiplookup) echo " ✅ geoiplookup (Legacy GeoIP)" ;; - none) echo " ❌ Kein GeoIP-Tool gefunden!" ;; - esac - echo "" - - # Worker-Status - if [[ -f "$WORKER_PID_FILE" ]]; then - local wpid - wpid=$(cat "$WORKER_PID_FILE") - if kill -0 "$wpid" 2>/dev/null; then - echo " Worker: Läuft (PID: $wpid)" - else - echo " Worker: Abgestürzt (PID: $wpid existiert nicht mehr)" - fi - else - echo " Worker: Nicht gestartet" - fi - echo "" - - # GeoIP-Sperren anzeigen - local geoip_bans_data - geoip_bans_data=$(db_ban_get_by_reason "geoip") - local geoip_bans=0 - - if [[ -n "$geoip_bans_data" ]]; then - while IFS='|' read -r s_ip s_domain _ _ s_ban_until_epoch _ _ s_perm_int _ _ _ s_country _; do - [[ -z "$s_ip" ]] && continue - geoip_bans=$((geoip_bans + 1)) - local s_until_display="PERMANENT" - if [[ "$s_ban_until_epoch" != "0" && "$s_perm_int" != "1" ]]; then - s_until_display=$(date -d "@$s_ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "?") - fi - echo " 🌍 $s_ip → Land: ${s_country:-?} (bis: $s_until_display)" - done <<< "$geoip_bans_data" - fi - - if [[ $geoip_bans -eq 0 ]]; then - echo " Keine aktiven GeoIP-Sperren" - else - echo "" - echo " Gesamt: $geoip_bans aktive GeoIP-Sperren" - fi - - # Cache-Statistik - if [[ -d "$GEOIP_CACHE_DIR" ]]; then - local cache_count - cache_count=$(find "$GEOIP_CACHE_DIR" -name '*.country' -type f 2>/dev/null | wc -l) - echo "" - echo " Cache: $cache_count IP-Lookups zwischengespeichert" - fi - - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Einzelne IP nachschlagen ──────────────────────────────────────────────── -lookup_ip() { - local ip="$1" - - local eff_mmdb - eff_mmdb=$(resolve_mmdb_path) - - local tool - tool=$(check_geoip_tools 2>/dev/null) || tool="none" - - if [[ "$tool" == "none" ]]; then - echo "❌ Kein GeoIP-Tool verfügbar." - echo " Installiere geoip-bin: sudo apt install geoip-bin geoip-database" - echo " Oder mmdbinspect mit MaxMind GeoLite2 DB" - return 1 - fi - - local country_code - country_code=$(geoip_lookup "$ip") || true - - if [[ -z "$country_code" ]]; then - echo "IP: $ip → Land: unbekannt (kein GeoIP-Ergebnis)" - return 1 - fi - - echo "IP: $ip → Land: $country_code (Tool: $tool)" - [[ -n "$eff_mmdb" ]] && echo " MMDB: $eff_mmdb" - - # Prüfen ob diese IP gesperrt werden würde - if [[ "${GEOIP_ENABLED:-false}" == "true" && -n "${GEOIP_COUNTRIES:-}" ]]; then - if should_block_by_geoip "$country_code"; then - echo "→ Würde GESPERRT werden (Modus: ${GEOIP_MODE:-blocklist}, Länder: ${GEOIP_COUNTRIES})" - else - echo "→ Würde ERLAUBT werden (Modus: ${GEOIP_MODE:-blocklist}, Länder: ${GEOIP_COUNTRIES})" - fi - fi -} - -# ─── Cache leeren ──────────────────────────────────────────────────────────── -flush_cache() { - if [[ -d "$GEOIP_CACHE_DIR" ]]; then - local count - count=$(find "$GEOIP_CACHE_DIR" -name '*.country' -type f 2>/dev/null | wc -l) - rm -f "${GEOIP_CACHE_DIR}"/*.country 2>/dev/null || true - echo "✅ GeoIP-Cache geleert ($count Einträge entfernt)" - log "INFO" "GeoIP-Cache geleert ($count Einträge)" - else - echo "ℹ️ GeoIP-Cache-Verzeichnis existiert nicht" - fi -} - -# ─── GeoIP-Sperren aufheben ───────────────────────────────────────────────── -flush_geoip_bans() { - local count=0 - local geoip_ips - geoip_ips=$(db_query "SELECT client_ip FROM active_bans WHERE reason='geoip';") - - if [[ -n "$geoip_ips" ]]; then - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - - # iptables Regel entfernen - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - db_ban_delete "$client_ip" - log_ban_history "UNBAN" "$client_ip" "" "geoip-flush" - count=$((count + 1)) - done <<< "$geoip_ips" - fi - - echo "✅ $count GeoIP-Sperren aufgehoben" - log "INFO" "$count GeoIP-Sperren aufgehoben (flush)" -} - -# ─── Hauptprogramm ────────────────────────────────────────────────────────── -case "${1:-help}" in - start) - init_directories - setup_iptables_chain - start_worker - ;; - sync) - init_directories - setup_iptables_chain - sync_geoip - ;; - status) - init_directories - show_status - ;; - lookup) - if [[ -z "${2:-}" ]]; then - echo "Nutzung: $0 lookup " >&2 - exit 1 - fi - init_directories - lookup_ip "$2" - ;; - flush) - init_directories - flush_geoip_bans - ;; - flush-cache) - init_directories - flush_cache - ;; - stop) - if [[ -f "$WORKER_PID_FILE" ]]; then - local wpid - wpid=$(cat "$WORKER_PID_FILE") - if kill -0 "$wpid" 2>/dev/null; then - kill "$wpid" 2>/dev/null || true - rm -f "$WORKER_PID_FILE" - echo "GeoIP-Worker gestoppt" - else - rm -f "$WORKER_PID_FILE" - echo "GeoIP-Worker war nicht aktiv" - fi - else - echo "GeoIP-Worker läuft nicht" - fi - ;; - *) - cat << USAGE -AdGuard Shield - GeoIP Worker - -Nutzung: $0 {start|stop|sync|status|lookup|flush|flush-cache} - -Befehle: - start Startet den GeoIP-Worker (Hintergrundprozess) - stop Stoppt den GeoIP-Worker - sync Einmalige GeoIP-Prüfung aller aktiven Clients - status Zeigt GeoIP-Status und aktive Sperren - lookup GeoIP-Lookup für eine einzelne IP - flush Alle GeoIP-Sperren aufheben - flush-cache GeoIP-Lookup-Cache leeren - -Konfiguration in: $CONFIG_FILE - GEOIP_ENABLED=true/false - GEOIP_MODE=blocklist/allowlist - GEOIP_COUNTRIES="CN,RU,..." - -USAGE - exit 0 - ;; -esac diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c60fb1 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module adguard-shield + +go 1.22 + +require ( + github.com/oschwald/maxminddb-golang v1.12.0 + modernc.org/sqlite v1.29.10 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.19.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.49.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f6a1a68 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= +github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk= +modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA= +modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg= +modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= +modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/install.sh b/install.sh deleted file mode 100644 index b7c63ba..0000000 --- a/install.sh +++ /dev/null @@ -1,966 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Installer / Updater / Uninstaller -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -VERSION="v1.0.0" - -set -euo pipefail - -INSTALL_DIR="/opt/adguard-shield" -SERVICE_FILE="/etc/systemd/system/adguard-shield.service" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Farben -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -BOLD='\033[1m' -NC='\033[0m' - -print_header() { - echo "" - echo -e "${BLUE}" - echo " ▄▄▄ ▓█████▄ ▄████ █ ██ ▄▄▄ ██▀███ ▓█████▄ ██████ ██░ ██ ██▓▓█████ ██▓ ▓█████▄ " - echo "▒████▄ ▒██▀ ██▌ ██▒ ▀█▒ ██ ▓██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒ ▓██░ ██▒▓██▒▓█ ▀ ▓██▒ ▒██▀ ██▌" - echo "▒██ ▀█▄ ░██ █▌▒██░▄▄▄░▓██ ▒██░▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░ ▓██▄ ▒██▀▀██░▒██▒▒███ ▒██░ ░██ █▌" - echo "░██▄▄▄▄██ ░▓█▄ ▌░▓█ ██▓▓▓█ ░██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌ ▒ ██▒░▓█ ░██ ░██░▒▓█ ▄ ▒██░ ░▓█▄ ▌" - echo " ▓█ ▓██▒░▒████▓ ░▒▓███▀▒▒▒█████▓ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▒██████▒▒░▓█▒░██▓░██░░▒████▒░██████▒░▒████▓ " - echo " ▒▒ ▓▒█░ ▒▒▓ ▒ ░▒ ▒ ░▒▓▒ ▒ ▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░ ▒░ ░░ ▒░▓ ░ ▒▒▓ ▒ " - echo " ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░▒░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒ ░ ░ ▒ ▒ " - echo " ░ ▒ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░ ░ " - echo " ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ " - echo " ░ ░ ░ " - echo -e "${NC}" - echo -e "${GREEN} Version: ${VERSION}${NC}" - echo -e "${BLUE} Autor: Patrick Asmus${NC}" - echo -e - echo -e "${BLUE} E-Mail: support@techniverse.net${NC}" - echo -e "${BLUE} Web: https://www.patrick-asmus.de${NC}" - echo "" - echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}" - echo "" - echo -e "${BLUE} Repo: https://git.techniverse.net/scriptos/adguard-shield${NC}" - echo "" - echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" - echo "" -} - -# ─── Hilfe-Menü ────────────────────────────────────────────────────────────── -print_help() { - echo -e "${BOLD}Nutzung:${NC} sudo bash $0 [BEFEHL]" - echo "" - echo -e "${BOLD}Verfügbare Befehle:${NC}" - echo "" - echo -e " ${GREEN}install${NC} Vollständige Neuinstallation durchführen" - echo -e " Installiert alle Dateien, fragt die Konfiguration ab," - echo -e " richtet den systemd Service ein und aktiviert Autostart." - echo "" - echo -e " ${GREEN}update${NC} Update auf die neueste Version" - echo -e " Aktualisiert alle Scripts, führt eine automatische" - echo -e " Konfigurations-Migration durch (neue Parameter werden" - echo -e " hinzugefügt, bestehende Einstellungen bleiben erhalten)," - echo -e " migriert bestehende Daten nach SQLite (einmalig)" - echo -e " und startet den Service automatisch neu." - echo "" - echo -e " ${GREEN}uninstall${NC} Vollständige Deinstallation" - echo -e " Stoppt den Service, entfernt iptables-Regeln und" - echo -e " löscht alle Dateien (optional Konfiguration behalten)." - echo -e " Delegiert automatisch an den im Installationsverzeichnis" - echo -e " liegenden Uninstaller — kein Behalten der Installationsdateien nötig." - echo -e " Direkt ausführbar: ${CYAN}sudo bash $INSTALL_DIR/uninstall.sh${NC}" - echo "" - echo -e " ${GREEN}status${NC} Installationsstatus anzeigen" - echo -e " Zeigt ob AdGuard Shield installiert ist, welche Version" - echo -e " läuft und ob der Service aktiv ist." - echo "" - echo -e " ${GREEN}--help, -h${NC} Diese Hilfe anzeigen" - echo "" - echo -e "${BOLD}Beispiele:${NC}" - echo -e " ${CYAN}sudo bash install.sh install${NC} # Neuinstallation" - echo -e " ${CYAN}sudo bash install.sh update${NC} # Update durchführen" - 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 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" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh unban IP${NC} # Einzelne IP entsperren" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh flush${NC} # Alle Sperren aufheben" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh test${NC} # API-Verbindung testen" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh dry-run${NC} # Testmodus (nur loggen)" - echo "" - echo -e "${BOLD}Externe Whitelist-Befehle:${NC}" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-status${NC} # Status der externen Whitelisten" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-sync${NC} # Einmalige Synchronisation" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-flush${NC} # Aufgelöste IPs entfernen" - echo "" - echo -e "${BOLD}iptables-Befehle:${NC}" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh status${NC} # Firewall-Regeln anzeigen" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh ban IP${NC} # IP manuell sperren" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh unban IP${NC} # IP entsperren" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh flush${NC} # Alle Regeln leeren" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh create${NC} # Chain erstellen" - echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh remove${NC} # Chain komplett entfernen" - 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}Report-Befehle:${NC}" - echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh status${NC} # Report-Konfiguration anzeigen" - echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh send${NC} # Report sofort senden" - echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh generate${NC} # Report als Datei generieren" - echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh install${NC} # Cron-Job einrichten" - echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh remove${NC} # Cron-Job entfernen" - echo "" - echo -e "${BOLD}Watchdog-Befehle:${NC}" - echo -e " ${CYAN}sudo systemctl status adguard-shield-watchdog.timer${NC} # Watchdog-Status" - echo -e " ${CYAN}sudo systemctl list-timers adguard-shield-watchdog.timer${NC} # Nächste Ausführung" - echo -e " ${CYAN}sudo systemctl enable adguard-shield-watchdog.timer${NC} # Watchdog aktivieren" - echo -e " ${CYAN}sudo systemctl disable adguard-shield-watchdog.timer${NC} # Watchdog deaktivieren" - echo -e " ${CYAN}sudo journalctl -u adguard-shield-watchdog.service${NC} # Watchdog-Logs" - echo "" - echo -e "${BOLD}GeoIP-Befehle:${NC}" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-status${NC} # GeoIP-Status anzeigen" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-sync${NC} # Einmalige GeoIP-Prüfung" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-flush${NC} # Alle GeoIP-Sperren aufheben" - echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-lookup IP${NC} # GeoIP-Lookup einer IP" - echo "" - echo -e "${BOLD}Voraussetzungen:${NC}" - echo " - Linux Server (Debian/Ubuntu empfohlen)" - echo " - Root-Zugriff (sudo)" - echo " - AdGuard Home installiert und erreichbar" - echo " - Pakete: curl, jq, iptables, gawk, sqlite3 (werden bei Installation automatisch installiert)" - echo " - GeoIP (optional): geoip-bin + geoip-database oder MaxMind GeoLite2 DB" - echo "" - echo -e "${BOLD}Dokumentation:${NC}" - echo " https://git.techniverse.net/scriptos/adguard-shield" - echo "" -} - -# ─── Interaktives Menü ─────────────────────────────────────────────────────── -show_menu() { - echo -e "${BOLD}Was möchtest du tun?${NC}" - echo "" - echo -e " ${CYAN}1)${NC} Installation — AdGuard Shield neu installieren" - echo -e " ${CYAN}2)${NC} Update — Auf die neueste Version aktualisieren" - echo -e " ${CYAN}3)${NC} Deinstallation — AdGuard Shield vollständig entfernen" - echo -e " ${CYAN}4)${NC} Status — Installationsstatus anzeigen" - echo -e " ${CYAN}5)${NC} Hilfe — Hilfe & Befehlsübersicht anzeigen" - echo -e " ${CYAN}0)${NC} Beenden" - echo "" - read -rep " Auswahl [0-5]: " choice - echo "" - - case "$choice" in - 1) do_install ;; - 2) do_update ;; - 3) do_uninstall ;; - 4) do_status ;; - 5) print_help ;; - 0) echo -e "${GREEN}Auf Wiedersehen!${NC}"; exit 0 ;; - *) echo -e "${RED}Ungültige Auswahl.${NC}"; exit 1 ;; - esac -} - -check_root() { - if [[ $EUID -ne 0 ]]; then - echo -e "${RED}Dieses Script muss als root ausgeführt werden!${NC}" >&2 - echo "Bitte mit 'sudo $0' ausführen." - exit 1 - fi -} - -# ─── Abhängigkeiten prüfen und installieren ────────────────────────────────── -check_dependencies() { - echo -e "${YELLOW}Prüfe Abhängigkeiten...${NC}" - local missing_cmds=() - local missing_pkgs=() - - # Befehl → Paketname Zuordnung - declare -A cmd_to_pkg=( - [curl]="curl" - [jq]="jq" - [iptables]="iptables" - [ip6tables]="iptables" - [gawk]="gawk" - [systemctl]="systemd" - [sqlite3]="sqlite3" - ) - - for cmd in curl jq iptables ip6tables gawk systemctl sqlite3; do - if command -v "$cmd" &>/dev/null; then - echo -e " ✅ $cmd" - else - echo -e " ❌ $cmd" - missing_cmds+=("$cmd") - local pkg="${cmd_to_pkg[$cmd]}" - # Duplikate vermeiden - if [[ ! " ${missing_pkgs[*]:-} " =~ " ${pkg} " ]]; then - missing_pkgs+=("$pkg") - fi - fi - done - - if [[ ${#missing_cmds[@]} -gt 0 ]]; then - echo "" - echo -e "${YELLOW}Installiere fehlende Pakete: ${missing_pkgs[*]}${NC}" - - if command -v apt &>/dev/null; then - apt update -qq - apt install -y -qq "${missing_pkgs[@]}" - elif command -v dnf &>/dev/null; then - dnf install -y "${missing_pkgs[@]}" - elif command -v yum &>/dev/null; then - yum install -y "${missing_pkgs[@]}" - elif command -v pacman &>/dev/null; then - pacman -S --noconfirm "${missing_pkgs[@]}" - else - echo -e "${RED}Konnte Paketmanager nicht erkennen. Bitte installiere manuell: ${missing_pkgs[*]}${NC}" - exit 1 - fi - - echo "" - echo -e "${YELLOW}Prüfe erneut...${NC}" - for cmd in "${missing_cmds[@]}"; do - if command -v "$cmd" &>/dev/null; then - echo -e " ✅ $cmd (installiert)" - else - echo -e " ❌ $cmd (Installation fehlgeschlagen!)" - echo -e "${RED}FEHLER: $cmd konnte nicht installiert werden. Bitte manuell nachinstallieren.${NC}" - exit 1 - fi - done - fi - - echo -e " ${GREEN}Alle Abhängigkeiten erfüllt.${NC}" - echo "" -} - -install_files() { - echo -e "${YELLOW}Installiere Dateien nach $INSTALL_DIR ...${NC}" - - mkdir -p "$INSTALL_DIR" - mkdir -p /var/lib/adguard-shield - mkdir -p /var/log - - # Scripts kopieren - cp "$SCRIPT_DIR/adguard-shield.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/iptables-helper.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/unban-expired.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/external-blocklist-worker.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/external-whitelist-worker.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/report-generator.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/adguard-shield-watchdog.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/geoip-worker.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/offense-cleanup-worker.sh" "$INSTALL_DIR/" - cp "$SCRIPT_DIR/db.sh" "$INSTALL_DIR/" - - # Templates kopieren - mkdir -p "$INSTALL_DIR/templates" - cp "$SCRIPT_DIR/templates/report.html" "$INSTALL_DIR/templates/" - cp "$SCRIPT_DIR/templates/report.txt" "$INSTALL_DIR/templates/" - - # Ausführbar machen - chmod +x "$INSTALL_DIR/adguard-shield.sh" - chmod +x "$INSTALL_DIR/iptables-helper.sh" - chmod +x "$INSTALL_DIR/unban-expired.sh" - chmod +x "$INSTALL_DIR/external-blocklist-worker.sh" - chmod +x "$INSTALL_DIR/external-whitelist-worker.sh" - chmod +x "$INSTALL_DIR/report-generator.sh" - chmod +x "$INSTALL_DIR/adguard-shield-watchdog.sh" - chmod +x "$INSTALL_DIR/uninstall.sh" - chmod +x "$INSTALL_DIR/geoip-worker.sh" - chmod +x "$INSTALL_DIR/offense-cleanup-worker.sh" - chmod +x "$INSTALL_DIR/db.sh" - - echo -e " ✅ Dateien installiert" - echo "" -} - -# ─── Konfigurations-Migration ──────────────────────────────────────────────── -# Vergleicht die bestehende Konfiguration mit der neuen Version. -# - Bestehende Einstellungen des Benutzers bleiben IMMER erhalten -# - Neue Parameter (die in der alten Konfig fehlen) werden automatisch ergänzt -# - Die alte Konfiguration wird als .conf.old gesichert -migrate_config() { - local existing_conf="$INSTALL_DIR/adguard-shield.conf" - local new_conf="$SCRIPT_DIR/adguard-shield.conf" - local backup_conf="$INSTALL_DIR/adguard-shield.conf.old" - - if [[ ! -f "$existing_conf" ]]; then - # Keine bestehende Konfig → einfach kopieren - cp "$new_conf" "$existing_conf" - chmod 600 "$existing_conf" - echo -e " ✅ Konfiguration kopiert (Neuinstallation)" - return 0 - fi - - echo -e "${YELLOW}Führe Konfigurations-Migration durch...${NC}" - - # Backup der aktuellen Konfiguration erstellen - cp "$existing_conf" "$backup_conf" - echo -e " 📦 Backup erstellt: adguard-shield.conf.old" - - # Alle Schlüssel aus der bestehenden Konfig extrahieren (nur KEY=... Zeilen) - local existing_keys=() - while IFS= read -r line; do - # Zeilen mit KEY=VALUE extrahieren (keine Kommentare, keine leeren Zeilen) - if [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then - local key="${line%%=*}" - existing_keys+=("$key") - fi - done < "$existing_conf" - - # Neue Schlüssel finden die in der bestehenden Konfig fehlen - local new_keys_added=0 - local current_comment_block="" - - while IFS= read -r line; do - # Kommentarblock sammeln (für Kontext bei neuen Keys) - if [[ "$line" =~ ^#.* ]] || [[ -z "$line" ]]; then - current_comment_block+="$line"$'\n' - continue - fi - - # KEY=VALUE Zeile prüfen - if [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then - local key="${line%%=*}" - local found=false - for existing_key in "${existing_keys[@]}"; do - if [[ "$key" == "$existing_key" ]]; then - found=true - break - fi - done - - if [[ "$found" == "false" ]]; then - # Neuer Parameter gefunden → mit Kommentarblock an bestehende Konfig anhängen - if [[ $new_keys_added -eq 0 ]]; then - echo "" >> "$existing_conf" - echo "# ─── Neue Parameter (automatisch bei Update hinzugefügt) ───" >> "$existing_conf" - fi - 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 + 1)) - fi - fi - - current_comment_block="" - done < "$new_conf" - - chmod 600 "$existing_conf" - - if [[ $new_keys_added -eq 0 ]]; then - echo -e " ✅ Konfiguration ist aktuell — keine neuen Parameter" - else - echo -e " ✅ ${new_keys_added} neue Parameter zur Konfiguration hinzugefügt" - echo -e " ${YELLOW}ℹ️ Backup der alten Konfig: $backup_conf${NC}" - echo -e " ${YELLOW}ℹ️ Bitte prüfe die neuen Parameter in: $existing_conf${NC}" - fi - echo "" -} - -install_service() { - echo -e "${YELLOW}Installiere systemd Service...${NC}" - - cp "$SCRIPT_DIR/adguard-shield.service" "$SERVICE_FILE" - cp "$SCRIPT_DIR/adguard-shield-watchdog.service" /etc/systemd/system/adguard-shield-watchdog.service - cp "$SCRIPT_DIR/adguard-shield-watchdog.timer" /etc/systemd/system/adguard-shield-watchdog.timer - systemctl daemon-reload - - echo -e " ✅ Service-Dateien installiert (inkl. Watchdog)" - echo "" - - # Interaktiv: Autostart beim Booten? - read -rep " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart - if [[ "${autostart,,}" != "n" ]]; then - systemctl enable adguard-shield.service - systemctl enable adguard-shield-watchdog.timer - echo -e " ✅ Autostart aktiviert (inkl. Watchdog-Timer)" - else - systemctl disable adguard-shield.service 2>/dev/null || true - systemctl disable adguard-shield-watchdog.timer 2>/dev/null || true - echo -e " ℹ️ Autostart nicht aktiviert" - echo -e " ${YELLOW}Später aktivieren mit: sudo systemctl enable adguard-shield${NC}" - fi - echo "" -} - -configure() { - echo -e "${YELLOW}Konfiguration:${NC}" - echo "" - - local conf="$INSTALL_DIR/adguard-shield.conf" - - # AdGuard URL - read -rep " AdGuard Home URL [http://127.0.0.1:3000]: " adguard_url - adguard_url="${adguard_url:-http://127.0.0.1:3000}" - sed -i "s|^ADGUARD_URL=.*|ADGUARD_URL=\"$adguard_url\"|" "$conf" - - # Benutzername - read -rep " AdGuard Home Benutzername [admin]: " adguard_user - adguard_user="${adguard_user:-admin}" - sed -i "s|^ADGUARD_USER=.*|ADGUARD_USER=\"$adguard_user\"|" "$conf" - - # Passwort - read -resp " AdGuard Home Passwort: " adguard_pass - echo "" - if [[ -n "$adguard_pass" ]]; then - # Einfache Quotes damit $-Zeichen im Passwort nicht expandiert werden - sed -i "s|^ADGUARD_PASS=.*|ADGUARD_PASS='$adguard_pass'|" "$conf" - fi - - # Rate Limit - read -rep " Max. Anfragen pro Domain/Client pro Minute [30]: " rate_limit - rate_limit="${rate_limit:-30}" - sed -i "s|^RATE_LIMIT_MAX_REQUESTS=.*|RATE_LIMIT_MAX_REQUESTS=$rate_limit|" "$conf" - - # Sperrdauer - read -rep " Sperrdauer in Sekunden [3600]: " ban_duration - ban_duration="${ban_duration:-3600}" - sed -i "s|^BAN_DURATION=.*|BAN_DURATION=$ban_duration|" "$conf" - - # Whitelist - read -rep " Whitelist IPs (kommagetrennt) [127.0.0.1,::1]: " whitelist - whitelist="${whitelist:-127.0.0.1,::1}" - sed -i "s|^WHITELIST=.*|WHITELIST=\"$whitelist\"|" "$conf" - - echo "" - echo -e " ✅ Konfiguration gespeichert" - echo "" -} - -test_connection() { - echo -e "${YELLOW}Teste Verbindung zur AdGuard Home API...${NC}" - - source "$INSTALL_DIR/adguard-shield.conf" - - # ── Schritt 1: Base-URL Erreichbarkeit prüfen ──────────────────────── - echo -e " ${CYAN}1)${NC} Prüfe Erreichbarkeit von ${BOLD}${ADGUARD_URL}${NC} ..." - - 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 " ❌ API-Verbindung fehlgeschlagen (HTTP $api_response)" - echo -e " ${YELLOW}→ Bitte prüfe URL und Zugangsdaten in: $INSTALL_DIR/adguard-shield.conf${NC}" - fi - echo "" -} - -print_summary() { - # Service-Status dynamisch ermitteln - local svc_status="gestoppt" - local autostart_status="deaktiviert" - if systemctl is-active adguard-shield &>/dev/null 2>&1; then - svc_status="läuft ✅" - fi - if systemctl is-enabled adguard-shield &>/dev/null 2>&1; then - autostart_status="aktiviert ✅" - fi - - echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" - echo -e "${GREEN} AdGuard Shield - Installation abgeschlossen!${NC}" - echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" - echo "" - echo " Installationspfad: $INSTALL_DIR" - echo " Konfiguration: $INSTALL_DIR/adguard-shield.conf" - echo " Service: adguard-shield.service ($svc_status)" - echo " Autostart: $autostart_status" - - # Watchdog-Status - local watchdog_status="deaktiviert" - if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then - watchdog_status="aktiv ✅" - elif systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then - watchdog_status="aktiviert (Timer nicht gestartet)" - fi - echo " Watchdog: $watchdog_status" - echo " Log-Datei: /var/log/adguard-shield.log" - echo "" - echo " Nützliche Befehle:" - echo " ──────────────────" - echo " Konfiguration bearbeiten:" - echo " sudo nano $INSTALL_DIR/adguard-shield.conf" - echo "" - echo " Dry-Run testen (nur loggen, nichts sperren):" - echo " sudo $INSTALL_DIR/adguard-shield.sh dry-run" - echo "" - echo " Service steuern:" - echo " sudo systemctl start|stop|restart adguard-shield" - echo " sudo systemctl status adguard-shield" - echo "" - echo " Logs verfolgen:" - echo " sudo journalctl -u adguard-shield -f" - echo " sudo tail -f /var/log/adguard-shield.log" - echo "" - echo " Weitere Befehle:" - echo " sudo $INSTALL_DIR/iptables-helper.sh status" - echo " sudo $INSTALL_DIR/adguard-shield.sh flush" - echo " sudo $INSTALL_DIR/adguard-shield.sh unban " - echo "" - echo " E-Mail Report:" - echo " sudo $INSTALL_DIR/report-generator.sh status" - echo " sudo $INSTALL_DIR/report-generator.sh install" - echo " sudo $INSTALL_DIR/report-generator.sh send" - echo "" - echo " Hilfe anzeigen:" - echo " sudo bash install.sh --help" - echo "" - echo " Deinstallieren (auch ohne Installationsdateien):" - echo " sudo bash $INSTALL_DIR/uninstall.sh" - echo "" -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -do_status() { - check_root - - echo -e "${YELLOW}Installationsstatus:${NC}" - echo "" - - # Installiert? - if [[ -d "$INSTALL_DIR" ]]; then - echo -e " ✅ AdGuard Shield ist installiert in: $INSTALL_DIR" - - # Version aus installiertem Script lesen - if [[ -f "$INSTALL_DIR/adguard-shield.sh" ]]; then - local installed_version - installed_version=$(grep -m1 '^VERSION=' "$INSTALL_DIR/adguard-shield.sh" 2>/dev/null | cut -d'"' -f2) - echo -e " 📌 Installierte Version: ${GREEN}${installed_version:-unbekannt}${NC}" - fi - else - echo -e " ❌ AdGuard Shield ist NICHT installiert" - echo "" - return - fi - - # Service-Status - if systemctl is-enabled adguard-shield &>/dev/null 2>&1; then - echo -e " ✅ Autostart: aktiviert" - else - echo -e " ❌ Autostart: deaktiviert" - fi - - if systemctl is-active adguard-shield &>/dev/null 2>&1; then - echo -e " ✅ Service: läuft" - else - echo -e " ❌ Service: gestoppt" - fi - - # Konfig vorhanden? - if [[ -f "$INSTALL_DIR/adguard-shield.conf" ]]; then - echo -e " ✅ Konfiguration: vorhanden" - else - echo -e " ❌ Konfiguration: fehlt!" - fi - - # Watchdog-Status - if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then - echo -e " ✅ Watchdog-Timer: aktiv" - elif systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then - echo -e " ⚠️ Watchdog-Timer: aktiviert aber nicht gestartet" - else - echo -e " ❌ Watchdog-Timer: nicht installiert/deaktiviert" - fi - - echo "" -} - -# ─── Installation ──────────────────────────────────────────────────────────── -do_install() { - check_root - - # Prüfen ob bereits installiert - if [[ -d "$INSTALL_DIR" ]] && [[ -f "$INSTALL_DIR/adguard-shield.sh" ]]; then - echo -e "${YELLOW}AdGuard Shield ist bereits installiert!${NC}" - echo "" - read -rep " Möchtest du stattdessen ein Update durchführen? [j/N]: " do_upd - if [[ "${do_upd,,}" == "j" ]]; then - do_update - return - else - echo -e "${RED}Installation abgebrochen.${NC}" - exit 0 - fi - fi - - check_dependencies - install_files - - # Bei Neuinstallation Konfig kopieren - cp "$SCRIPT_DIR/adguard-shield.conf" "$INSTALL_DIR/" - chmod 600 "$INSTALL_DIR/adguard-shield.conf" - echo -e " ✅ Konfiguration kopiert" - echo "" - - configure - install_service - test_connection - - # Interaktiv: Service jetzt starten? - echo -e "${YELLOW}Service starten:${NC}" - read -rep " Soll der AdGuard Shield Service jetzt gestartet werden? [J/n]: " start_now - if [[ "${start_now,,}" != "n" ]]; then - systemctl start adguard-shield - systemctl start adguard-shield-watchdog.timer 2>/dev/null || true - echo -e " ✅ Service gestartet (inkl. Watchdog-Timer)" - else - echo -e " ℹ️ Service nicht gestartet" - echo -e " ${YELLOW}Später starten mit: sudo systemctl start adguard-shield${NC}" - fi - echo "" - - print_summary -} - -# ─── SQLite-Datenbank-Migration ────────────────────────────────────────────── -# Migriert bestehende Flat-File-Daten (*.ban, *.offenses, History-Log) nach SQLite. -# Läuft synchron im Vordergrund mit sichtbarer Fortschrittsanzeige. -migrate_database() { - echo -e "${YELLOW}Prüfe Datenbank-Migration...${NC}" - - # Konfiguration laden für STATE_DIR und BAN_HISTORY_FILE - local conf="$INSTALL_DIR/adguard-shield.conf" - if [[ ! -f "$conf" ]]; then - echo -e " ${RED}Konfiguration nicht gefunden — Migration übersprungen${NC}" - echo "" - return 0 - fi - - # Nur die benötigten Variablen aus der Konfig laden - STATE_DIR=$(grep '^STATE_DIR=' "$conf" | cut -d= -f2 | tr -d '"') - STATE_DIR="${STATE_DIR:-/var/lib/adguard-shield}" - BAN_HISTORY_FILE=$(grep '^BAN_HISTORY_FILE=' "$conf" | cut -d= -f2 | tr -d '"') - BAN_HISTORY_FILE="${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" - export STATE_DIR BAN_HISTORY_FILE - - # db.sh aus dem Installationsverzeichnis laden - source "$INSTALL_DIR/db.sh" - - # Datenbank initialisieren (Schema anlegen falls nötig) - db_init - - # Prüfen ob Migration bereits durchgeführt wurde - if [[ -f "$_DB_MIGRATION_MARKER" ]]; then - echo -e " ✅ Datenbank ist aktuell — Migration bereits abgeschlossen" - echo "" - return 0 - fi - - # Prüfen ob überhaupt Flat-Files vorhanden sind - local has_files=false - for f in "${STATE_DIR}"/*.ban "${STATE_DIR}"/ext_*.ban "${STATE_DIR}"/*.offenses; do - if [[ -f "$f" ]]; then - has_files=true - break - fi - done - if [[ "$has_files" == "false" && ! -f "$BAN_HISTORY_FILE" ]]; then - # Keine alten Daten vorhanden — Marker setzen und fertig - echo "migrated_at=$(date '+%Y-%m-%d %H:%M:%S')" > "$_DB_MIGRATION_MARKER" - echo "bans=0" >> "$_DB_MIGRATION_MARKER" - echo "offenses=0" >> "$_DB_MIGRATION_MARKER" - echo "history=0" >> "$_DB_MIGRATION_MARKER" - echo "whitelist=0" >> "$_DB_MIGRATION_MARKER" - echo -e " ✅ Keine bestehenden Daten gefunden — Datenbank bereit" - echo "" - return 0 - fi - - echo -e " ${CYAN}Migriere bestehende Daten nach SQLite...${NC}" - echo "" - - local migrated - migrated=$(db_migrate_from_files) - - if [[ "${migrated:-0}" -gt 0 ]]; then - # Details aus dem Marker lesen - local m_bans m_offenses m_history m_whitelist - m_bans=$(grep '^bans=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) - m_offenses=$(grep '^offenses=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) - m_history=$(grep '^history=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) - m_whitelist=$(grep '^whitelist=' "$_DB_MIGRATION_MARKER" 2>/dev/null | cut -d= -f2) - - echo -e " ${GREEN}═══════════════════════════════════════════════════════════${NC}" - echo -e " ${GREEN} SQLite-Migration erfolgreich abgeschlossen!${NC}" - echo -e " ${GREEN}═══════════════════════════════════════════════════════════${NC}" - echo "" - echo -e " Migrierte Einträge gesamt: ${BOLD}${migrated}${NC}" - [[ "${m_bans:-0}" -gt 0 ]] && echo -e " • Aktive Bans: ${m_bans}" - [[ "${m_offenses:-0}" -gt 0 ]] && echo -e " • Offense-Tracking: ${m_offenses}" - [[ "${m_history:-0}" -gt 0 ]] && echo -e " • Ban-History: ${m_history}" - [[ "${m_whitelist:-0}" -gt 0 ]] && echo -e " • Whitelist-Cache: ${m_whitelist}" - echo "" - echo -e " 📦 Backup der alten Dateien: ${STATE_DIR}/.backup_pre_sqlite/" - echo -e " 📂 Neue Datenbank: ${STATE_DIR}/adguard-shield.db" - else - echo -e " ✅ Migration abgeschlossen — keine Daten zum Migrieren" - fi - echo "" -} - -# ─── Update ────────────────────────────────────────────────────────────────── -do_update() { - check_root - - # Prüfen ob installiert - if [[ ! -d "$INSTALL_DIR" ]] || [[ ! -f "$INSTALL_DIR/adguard-shield.sh" ]]; then - echo -e "${RED}AdGuard Shield ist nicht installiert!${NC}" - echo "Bitte zuerst installieren: sudo bash $0 install" - exit 1 - fi - - echo -e "${YELLOW}Starte Update von AdGuard Shield...${NC}" - echo "" - - check_dependencies - install_files - - # Konfigurations-Migration durchführen - migrate_config - - # SQLite-Datenbank-Migration durchführen - migrate_database - - # Service-Datei aktualisieren - echo -e "${YELLOW}Aktualisiere systemd Service...${NC}" - cp "$SCRIPT_DIR/adguard-shield.service" "$SERVICE_FILE" - cp "$SCRIPT_DIR/adguard-shield-watchdog.service" /etc/systemd/system/adguard-shield-watchdog.service - cp "$SCRIPT_DIR/adguard-shield-watchdog.timer" /etc/systemd/system/adguard-shield-watchdog.timer - systemctl daemon-reload - echo -e " ✅ Service-Dateien aktualisiert (inkl. Watchdog)" - echo "" - - # Interaktiv: Autostart beim Booten? - if systemctl is-enabled adguard-shield &>/dev/null; then - echo -e " ℹ️ Autostart ist bereits aktiviert" - # Watchdog-Timer auch aktivieren falls noch nicht aktiv - if ! systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then - systemctl enable adguard-shield-watchdog.timer - systemctl start adguard-shield-watchdog.timer - echo -e " ✅ Watchdog-Timer aktiviert" - fi - else - read -rep " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart - if [[ "${autostart,,}" != "n" ]]; then - systemctl enable adguard-shield.service - systemctl enable adguard-shield-watchdog.timer - systemctl start adguard-shield-watchdog.timer - echo -e " ✅ Autostart aktiviert (inkl. Watchdog-Timer)" - else - echo -e " ℹ️ Autostart bleibt deaktiviert" - fi - fi - echo "" - - # Interaktiv: Service neu starten? - local service_was_active=false - if systemctl is-active adguard-shield &>/dev/null; then - service_was_active=true - fi - - if [[ "$service_was_active" == "true" ]]; then - read -rep " Soll der Service jetzt neu gestartet werden? [J/n]: " restart_now - if [[ "${restart_now,,}" != "n" ]]; then - systemctl restart adguard-shield - echo -e " ✅ Service wurde neu gestartet" - else - echo -e " ℹ️ Service wurde NICHT neu gestartet" - echo -e " ${YELLOW}Bitte manuell neustarten: sudo systemctl restart adguard-shield${NC}" - fi - else - read -rep " Soll der Service jetzt gestartet werden? [J/n]: " start_now - if [[ "${start_now,,}" != "n" ]]; then - systemctl start adguard-shield - echo -e " ✅ Service gestartet" - else - echo -e " ℹ️ Service nicht gestartet" - echo -e " ${YELLOW}Später starten mit: sudo systemctl start adguard-shield${NC}" - fi - fi - echo "" - - echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" - echo -e "${GREEN} AdGuard Shield - Update abgeschlossen!${NC}" - echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" - echo "" - echo " Bitte prüfe bei Bedarf die Konfiguration:" - echo " sudo nano $INSTALL_DIR/adguard-shield.conf" - echo "" - if [[ -f "$INSTALL_DIR/adguard-shield.conf.old" ]]; then - echo " Backup der vorherigen Konfiguration:" - echo " $INSTALL_DIR/adguard-shield.conf.old" - echo "" - fi -} - -# ─── Deinstallation ───────────────────────────────────────────────────────── -do_uninstall() { - check_root - - # Prüfen ob installiert - if [[ ! -d "$INSTALL_DIR" ]]; then - echo -e "${RED}AdGuard Shield ist nicht installiert!${NC}" - exit 1 - fi - - # An den im Installationsverzeichnis liegenden Uninstaller delegieren - if [[ -f "$INSTALL_DIR/uninstall.sh" ]]; then - exec bash "$INSTALL_DIR/uninstall.sh" - fi - - # Fallback für ältere Installationen ohne uninstall.sh - echo -e "${YELLOW}Deinstalliere AdGuard Shield (Fallback-Modus)...${NC}" - echo "" - - read -rep " Wirklich deinstallieren? [j/N]: " confirm - if [[ "${confirm,,}" != "j" ]]; then - echo -e "${GREEN}Deinstallation abgebrochen.${NC}" - exit 0 - fi - echo "" - - if systemctl is-active adguard-shield &>/dev/null; then - systemctl stop adguard-shield - echo " ✅ Service gestoppt" - fi - if systemctl is-enabled adguard-shield &>/dev/null; then - systemctl disable adguard-shield - echo " ✅ Service deaktiviert" - fi - rm -f "$SERVICE_FILE" - systemctl daemon-reload - echo " ✅ Service-Datei entfernt" - - if [[ -f "$INSTALL_DIR/iptables-helper.sh" ]]; then - bash "$INSTALL_DIR/iptables-helper.sh" remove || true - fi - - read -rep " Konfiguration und Logs behalten? [j/N]: " keep - if [[ "${keep,,}" == "j" ]]; then - rm -f "$INSTALL_DIR/adguard-shield.sh" - rm -f "$INSTALL_DIR/iptables-helper.sh" - rm -f "$INSTALL_DIR/unban-expired.sh" - rm -f "$INSTALL_DIR/external-blocklist-worker.sh" - rm -f "$INSTALL_DIR/external-whitelist-worker.sh" - rm -f "$INSTALL_DIR/offense-cleanup-worker.sh" - rm -f "$INSTALL_DIR/geoip-worker.sh" - rm -f "$INSTALL_DIR/report-generator.sh" - rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh" - rm -f "$INSTALL_DIR/db.sh" - rm -f "$INSTALL_DIR/uninstall.sh" - rm -rf "$INSTALL_DIR/templates" - rm -rf "$INSTALL_DIR/geoip" - echo " ✅ Scripts entfernt (Konfiguration und Logs behalten)" - else - rm -rf "$INSTALL_DIR" - rm -rf /var/lib/adguard-shield - rm -f /var/log/adguard-shield.log* - rm -f /var/log/adguard-shield-bans.log - echo " ✅ Alles entfernt" - fi - - echo "" - echo -e "${GREEN}Deinstallation abgeschlossen.${NC}" -} - -# ─── Hauptprogramm ────────────────────────────────────────────────────────── -main() { - case "${1:-}" in - install) - print_header - do_install - ;; - update) - print_header - do_update - ;; - uninstall) - # print_header wird vom delegierten uninstall.sh übernommen - do_uninstall - ;; - status) - print_header - do_status - ;; - --help|-h) - print_header - print_help - ;; - "") - # Kein Argument → interaktives Menü anzeigen - print_header - show_menu - ;; - *) - echo -e "${RED}Unbekannter Befehl: $1${NC}" - echo "" - print_help - exit 1 - ;; - esac -} - -main "$@" diff --git a/internal/appinfo/appinfo.go b/internal/appinfo/appinfo.go new file mode 100644 index 0000000..ae9d825 --- /dev/null +++ b/internal/appinfo/appinfo.go @@ -0,0 +1,5 @@ +package appinfo + +var Version = "v1.0.0" + +const ProjectURL = "https://tnvs.de/as" diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..13a6b7c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,295 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type Config struct { + Path string + + AdGuardURL string + AdGuardUser string + AdGuardPass string + + RateLimitMaxRequests int + RateLimitWindow int + CheckInterval int + APIQueryLimit int + + SubdomainFloodEnabled bool + SubdomainFloodMaxUnique int + SubdomainFloodWindow int + + DNSFloodWatchlistEnabled bool + DNSFloodWatchlist []string + + BanDuration int64 + Chain string + BlockedPorts []string + FirewallBackend string + FirewallMode string + DryRun bool + + Whitelist []string + + LogFile string + LogLevel string + StateDir string + PIDFile string + + NotifyEnabled bool + NotifyType string + NotifyWebhook string + NTFYServerURL string + NTFYTopic string + NTFYToken string + NTFYPriority string + + ReportEnabled bool + ReportInterval string + ReportTime string + ReportEmailTo string + ReportEmailFrom string + ReportFormat string + ReportMailCmd string + ReportBusiestDayRange int + + ExternalWhitelistEnabled bool + ExternalWhitelistURLs []string + ExternalWhitelistInterval int + ExternalWhitelistCacheDir string + + ExternalBlocklistEnabled bool + ExternalBlocklistURLs []string + ExternalBlocklistInterval int + ExternalBlocklistCacheDir string + ExternalBlocklistDuration int64 + ExternalBlocklistAutoUnban bool + ExternalBlocklistNotify bool + + ProgressiveBanEnabled bool + ProgressiveBanMultiplier int + ProgressiveBanMaxLevel int + ProgressiveBanResetAfter int64 + + AbuseIPDBEnabled bool + AbuseIPDBAPIKey string + AbuseIPDBCategories string + + GeoIPEnabled bool + GeoIPMode string + GeoIPCountries []string + GeoIPNotify bool + GeoIPSkipPrivate bool + GeoIPLicenseKey string + GeoIPMMDBPath string + GeoIPCacheTTL int64 + GeoIPCheckInterval int +} + +func Load(path string) (*Config, error) { + values, err := parseFile(path) + if err != nil { + return nil, err + } + c := &Config{Path: path} + c.AdGuardURL = stringVal(values, "ADGUARD_URL", "") + c.AdGuardUser = stringVal(values, "ADGUARD_USER", "") + c.AdGuardPass = stringVal(values, "ADGUARD_PASS", "") + c.RateLimitMaxRequests = intVal(values, "RATE_LIMIT_MAX_REQUESTS", 30) + c.RateLimitWindow = intVal(values, "RATE_LIMIT_WINDOW", 60) + c.CheckInterval = intVal(values, "CHECK_INTERVAL", 10) + c.APIQueryLimit = intVal(values, "API_QUERY_LIMIT", 500) + c.SubdomainFloodEnabled = boolVal(values, "SUBDOMAIN_FLOOD_ENABLED", true) + c.SubdomainFloodMaxUnique = intVal(values, "SUBDOMAIN_FLOOD_MAX_UNIQUE", 50) + c.SubdomainFloodWindow = intVal(values, "SUBDOMAIN_FLOOD_WINDOW", 60) + c.DNSFloodWatchlistEnabled = boolVal(values, "DNS_FLOOD_WATCHLIST_ENABLED", false) + c.DNSFloodWatchlist = csv(values["DNS_FLOOD_WATCHLIST"]) + c.BanDuration = int64(intVal(values, "BAN_DURATION", 3600)) + c.Chain = stringVal(values, "IPTABLES_CHAIN", "ADGUARD_SHIELD") + c.BlockedPorts = fields(stringVal(values, "BLOCKED_PORTS", "53 443 853")) + c.FirewallBackend = stringVal(values, "FIREWALL_BACKEND", "ipset") + c.FirewallMode = strings.ToLower(strings.TrimSpace(stringVal(values, "FIREWALL_MODE", "host"))) + c.DryRun = boolVal(values, "DRY_RUN", false) + if strings.EqualFold(os.Getenv("DRY_RUN"), "true") || os.Getenv("DRY_RUN") == "1" { + c.DryRun = true + } + c.Whitelist = csv(values["WHITELIST"]) + c.LogFile = stringVal(values, "LOG_FILE", "/var/log/adguard-shield.log") + c.LogLevel = stringVal(values, "LOG_LEVEL", "INFO") + c.StateDir = stringVal(values, "STATE_DIR", "/var/lib/adguard-shield") + c.PIDFile = stringVal(values, "PID_FILE", "/var/run/adguard-shield.pid") + c.NotifyEnabled = boolVal(values, "NOTIFY_ENABLED", false) + c.NotifyType = stringVal(values, "NOTIFY_TYPE", "ntfy") + c.NotifyWebhook = stringVal(values, "NOTIFY_WEBHOOK_URL", "") + c.NTFYServerURL = stringVal(values, "NTFY_SERVER_URL", "https://ntfy.sh") + c.NTFYTopic = stringVal(values, "NTFY_TOPIC", "") + c.NTFYToken = stringVal(values, "NTFY_TOKEN", "") + c.NTFYPriority = stringVal(values, "NTFY_PRIORITY", "4") + c.ReportEnabled = boolVal(values, "REPORT_ENABLED", false) + c.ReportInterval = stringVal(values, "REPORT_INTERVAL", "weekly") + c.ReportTime = stringVal(values, "REPORT_TIME", "08:00") + c.ReportEmailTo = stringVal(values, "REPORT_EMAIL_TO", "admin@example.com") + c.ReportEmailFrom = stringVal(values, "REPORT_EMAIL_FROM", "adguard-shield@example.com") + c.ReportFormat = strings.ToLower(stringVal(values, "REPORT_FORMAT", "html")) + c.ReportMailCmd = stringVal(values, "REPORT_MAIL_CMD", "msmtp") + c.ReportBusiestDayRange = intVal(values, "REPORT_BUSIEST_DAY_RANGE", 30) + c.ExternalWhitelistEnabled = boolVal(values, "EXTERNAL_WHITELIST_ENABLED", false) + c.ExternalWhitelistURLs = csv(values["EXTERNAL_WHITELIST_URLS"]) + c.ExternalWhitelistInterval = intVal(values, "EXTERNAL_WHITELIST_INTERVAL", 300) + c.ExternalWhitelistCacheDir = stringVal(values, "EXTERNAL_WHITELIST_CACHE_DIR", filepath.Join(c.StateDir, "external-whitelist")) + c.ExternalBlocklistEnabled = boolVal(values, "EXTERNAL_BLOCKLIST_ENABLED", false) + c.ExternalBlocklistURLs = csv(values["EXTERNAL_BLOCKLIST_URLS"]) + c.ExternalBlocklistInterval = intVal(values, "EXTERNAL_BLOCKLIST_INTERVAL", 300) + c.ExternalBlocklistCacheDir = stringVal(values, "EXTERNAL_BLOCKLIST_CACHE_DIR", filepath.Join(c.StateDir, "external-blocklist")) + c.ExternalBlocklistDuration = int64(intVal(values, "EXTERNAL_BLOCKLIST_BAN_DURATION", 0)) + c.ExternalBlocklistAutoUnban = boolVal(values, "EXTERNAL_BLOCKLIST_AUTO_UNBAN", true) + c.ExternalBlocklistNotify = boolVal(values, "EXTERNAL_BLOCKLIST_NOTIFY", false) + c.ProgressiveBanEnabled = boolVal(values, "PROGRESSIVE_BAN_ENABLED", true) + c.ProgressiveBanMultiplier = intVal(values, "PROGRESSIVE_BAN_MULTIPLIER", 2) + c.ProgressiveBanMaxLevel = intVal(values, "PROGRESSIVE_BAN_MAX_LEVEL", 5) + c.ProgressiveBanResetAfter = int64(intVal(values, "PROGRESSIVE_BAN_RESET_AFTER", 86400)) + c.AbuseIPDBEnabled = boolVal(values, "ABUSEIPDB_ENABLED", false) + c.AbuseIPDBAPIKey = stringVal(values, "ABUSEIPDB_API_KEY", "") + c.AbuseIPDBCategories = stringVal(values, "ABUSEIPDB_CATEGORIES", "4") + c.GeoIPEnabled = boolVal(values, "GEOIP_ENABLED", false) + c.GeoIPMode = strings.ToLower(stringVal(values, "GEOIP_MODE", "blocklist")) + c.GeoIPCountries = upperCSV(values["GEOIP_COUNTRIES"]) + c.GeoIPNotify = boolVal(values, "GEOIP_NOTIFY", true) + c.GeoIPSkipPrivate = boolVal(values, "GEOIP_SKIP_PRIVATE", true) + c.GeoIPLicenseKey = stringVal(values, "GEOIP_LICENSE_KEY", "") + c.GeoIPMMDBPath = stringVal(values, "GEOIP_MMDB_PATH", "") + c.GeoIPCacheTTL = int64(intVal(values, "GEOIP_CACHE_TTL", 86400)) + c.GeoIPCheckInterval = intVal(values, "GEOIP_CHECK_INTERVAL", 0) + return c, nil +} + +func DefaultPath() string { + if v := os.Getenv("ADGUARD_SHIELD_CONFIG"); v != "" { + return v + } + if _, err := os.Stat("/opt/adguard-shield/adguard-shield.conf"); err == nil { + return "/opt/adguard-shield/adguard-shield.conf" + } + return filepath.Join(".", "adguard-shield.conf") +} + +func (c *Config) DBPath() string { return filepath.Join(c.StateDir, "adguard-shield.db") } +func (c *Config) GeoIPDir(scriptDir string) string { return filepath.Join(scriptDir, "geoip") } + +func parseFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open config %s: %w", path, err) + } + defer f.Close() + out := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx < 1 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := stripInlineComment(strings.TrimSpace(line[idx+1:])) + out[key] = unquote(val) + } + return out, sc.Err() +} + +func stripInlineComment(s string) string { + inSingle, inDouble := false, false + for i, r := range s { + switch r { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case '#': + if !inSingle && !inDouble { + if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' { + return strings.TrimSpace(s[:i]) + } + } + } + } + return strings.TrimSpace(s) +} + +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} + +func stringVal(m map[string]string, k, def string) string { + if v, ok := m[k]; ok { + return v + } + return def +} +func intVal(m map[string]string, k string, def int) int { + v, ok := m[k] + if !ok || strings.TrimSpace(v) == "" { + return def + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return def + } + return n +} +func boolVal(m map[string]string, k string, def bool) bool { + v, ok := m[k] + if !ok { + return def + } + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return def + } +} +func csv(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} +func upperCSV(s string) []string { + parts := csv(s) + for i := range parts { + parts[i] = strings.ToUpper(parts[i]) + } + return parts +} +func fields(s string) []string { + out := strings.Fields(s) + if len(out) == 0 { + return []string{"53"} + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..410b301 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadParsesShellStyleConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "adguard-shield.conf") + err := os.WriteFile(path, []byte(` +ADGUARD_URL="https://dns.example" +ADGUARD_USER="admin" +ADGUARD_PASS='pa#ss' +CHECK_INTERVAL=7 +BLOCKED_PORTS="53 443 853" +FIREWALL_BACKEND="ipset" +FIREWALL_MODE="docker-bridge" +GEOIP_ENABLED=true +GEOIP_MODE="allowlist" +GEOIP_COUNTRIES="DE, us" +GEOIP_CACHE_TTL=123 +`), 0600) + if err != nil { + t.Fatal(err) + } + c, err := Load(path) + if err != nil { + t.Fatal(err) + } + if c.AdGuardPass != "pa#ss" { + t.Fatalf("quoted # was not preserved: %q", c.AdGuardPass) + } + if c.CheckInterval != 7 || c.FirewallBackend != "ipset" || c.FirewallMode != "docker-bridge" { + t.Fatalf("unexpected config: %+v", c) + } + if got := c.GeoIPCountries; len(got) != 2 || got[0] != "DE" || got[1] != "US" { + t.Fatalf("unexpected countries: %#v", got) + } + if c.GeoIPCacheTTL != 123 { + t.Fatalf("unexpected GeoIP cache ttl: %d", c.GeoIPCacheTTL) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..5bbf5d6 --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,1221 @@ +package daemon + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "adguard-shield/internal/appinfo" + "adguard-shield/internal/config" + "adguard-shield/internal/db" + "adguard-shield/internal/firewall" + "adguard-shield/internal/geoip" + "adguard-shield/internal/syslog" +) + +type Daemon struct { + Config *config.Config + Store *db.Store + FW *firewall.Firewall + Geo *geoip.Resolver + Client *http.Client + Logger *syslog.Logger + + mu sync.Mutex + seen map[string]time.Time + events []queryEvent + geoSeen map[string]bool + wl map[string]bool + + serviceMu sync.Mutex + serviceStartNotified bool + serviceStopNotified bool +} + +type queryLogResponse struct { + Data []queryItem `json:"data"` +} + +type queryItem struct { + Time string `json:"time"` + Client string `json:"client"` + ClientProto string `json:"client_proto"` + ClientInfo struct { + IP string `json:"ip"` + } `json:"client_info"` + Question struct { + Name string `json:"name"` + Host string `json:"host"` + } `json:"question"` +} + +type queryEvent struct { + At time.Time + Client string + Domain string + Protocol string +} + +func New(c *config.Config) (*Daemon, error) { + if err := os.MkdirAll(c.StateDir, 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(c.LogFile), 0755); err != nil { + return nil, err + } + st, err := db.Open(c.DBPath()) + if err != nil { + return nil, err + } + logFile, err := os.OpenFile(c.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + logger := syslog.New(io.MultiWriter(os.Stderr, logFile), c.LogLevel) + fw := firewall.New(firewall.OSExecutor{}, c.Chain, c.BlockedPorts, c.FirewallMode, c.DryRun) + tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + d := &Daemon{ + Config: c, Store: st, FW: fw, Logger: logger, + Client: &http.Client{Timeout: 20 * time.Second, Transport: tr}, + seen: map[string]time.Time{}, geoSeen: map[string]bool{}, + } + d.Geo = geoip.New(c.GeoIPMMDBPath, c.GeoIPLicenseKey, filepath.Join(filepath.Dir(c.Path), "geoip"), c.GeoIPCacheTTL, st) + return d, nil +} + +func (d *Daemon) Close() { + if d.Geo != nil { + _ = d.Geo.Close() + } + if d.Store != nil { + _ = d.Store.Close() + } +} + +func (d *Daemon) Run(ctx context.Context) error { + d.info("AdGuard Shield Go-Daemon gestartet") + d.info("Konfiguration: Limit %d Anfragen/%ds, Polling alle %ds, Dry-Run: %v", d.Config.RateLimitMaxRequests, d.Config.RateLimitWindow, d.Config.CheckInterval, d.Config.DryRun) + d.info("Module: GeoIP=%v, externe Blocklist=%v, externe Whitelist=%v, Progressive-Ban=%v", d.Config.GeoIPEnabled, d.Config.ExternalBlocklistEnabled, d.Config.ExternalWhitelistEnabled, d.Config.ProgressiveBanEnabled) + d.NotifyServiceStart(context.Background()) + defer d.NotifyServiceStop(context.Background()) + if err := d.FW.Setup(ctx); err != nil { + d.warn("Firewall Setup Warnung: %v", err) + } + if err := d.Geo.Open(ctx); err != nil && d.Config.GeoIPEnabled { + d.warn("GeoIP Warnung: %v", err) + } + if err := d.loadCaches(); err != nil { + return err + } + if err := d.AutoUnbanGeoIP(ctx); err != nil { + d.warn("GeoIP Auto-Unban Warnung: %v", err) + } + if err := d.reconcileFirewall(ctx); err != nil { + d.warn("Firewall Reconcile Warnung: %v", err) + } + d.runJob(ctx, "external-whitelist", d.Config.ExternalWhitelistEnabled, time.Duration(d.Config.ExternalWhitelistInterval)*time.Second, d.SyncWhitelist) + d.runJob(ctx, "external-blocklist", d.Config.ExternalBlocklistEnabled, time.Duration(d.Config.ExternalBlocklistInterval)*time.Second, d.SyncBlocklist) + d.runJob(ctx, "offense-cleanup", d.Config.ProgressiveBanEnabled, time.Hour, func(ctx context.Context) error { + n, err := d.Store.CleanupOffenses(d.Config.ProgressiveBanResetAfter) + if n > 0 { + d.info("Offense-Cleanup: %d abgelaufene Zähler entfernt", n) + } + return err + }) + ticker := time.NewTicker(time.Duration(d.Config.CheckInterval) * time.Second) + defer ticker.Stop() + for { + if err := d.pollOnce(ctx); err != nil { + d.error("Poll Fehler: %v", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (d *Daemon) runJob(ctx context.Context, name string, enabled bool, interval time.Duration, fn func(context.Context) error) { + if !enabled || interval <= 0 { + d.debug("Worker %s deaktiviert", name) + return + } + go func() { + d.info("Worker %s gestartet (Intervall: %s)", name, interval) + if err := fn(ctx); err != nil { + d.error("%s Fehler: %v", name, err) + } else { + d.debug("%s Lauf abgeschlossen", name) + } + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + d.info("Worker %s gestoppt", name) + return + case <-t.C: + if err := fn(ctx); err != nil { + d.error("%s Fehler: %v", name, err) + } else { + d.debug("%s Lauf abgeschlossen", name) + } + } + } + }() +} + +func (d *Daemon) loadCaches() error { + wl, err := d.Store.AllWhitelist() + if err != nil { + return err + } + d.wl = wl + for _, ip := range d.Config.Whitelist { + d.wl[ip] = true + } + return nil +} + +func (d *Daemon) reconcileFirewall(ctx context.Context) error { + now := time.Now().Unix() + expired, err := d.Store.ExpiredBans(now) + if err != nil { + return err + } + for _, ip := range expired { + _ = d.Unban(ctx, ip, "expired") + } + bans, err := d.Store.ActiveBans() + if err != nil { + return err + } + for _, b := range bans { + timeout := int64(0) + if !b.Permanent && b.BanUntil > now { + timeout = b.BanUntil - now + } + _ = d.FW.Add(ctx, b.IP, timeout) + } + return nil +} + +func (d *Daemon) pollOnce(ctx context.Context) error { + entries, err := d.FetchQueryLog(ctx) + if err != nil { + return err + } + events := d.toEvents(entries) + d.mu.Lock() + for _, ev := range events { + key := ev.At.Format(time.RFC3339Nano) + "|" + ev.Client + "|" + ev.Domain + "|" + ev.Protocol + if _, ok := d.seen[key]; ok { + continue + } + d.seen[key] = ev.At + d.events = append(d.events, ev) + if d.Config.GeoIPEnabled { + d.debug("GeoIP-Prüfung geplant: %s", ev.Client) + go d.checkGeoIP(context.Background(), ev.Client) + } + } + d.pruneLocked() + snapshot := append([]queryEvent(nil), d.events...) + d.mu.Unlock() + return d.analyze(ctx, snapshot) +} + +func (d *Daemon) FetchQueryLog(ctx context.Context) ([]queryItem, error) { + url := strings.TrimRight(d.Config.AdGuardURL, "/") + "/control/querylog?limit=" + strconv.Itoa(d.Config.APIQueryLimit) + "&response_status=all" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if d.Config.AdGuardUser != "" || d.Config.AdGuardPass != "" { + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(d.Config.AdGuardUser+":"+d.Config.AdGuardPass))) + } + resp, err := d.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("AdGuard API HTTP %d", resp.StatusCode) + } + var out queryLogResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out.Data, nil +} + +func (d *Daemon) toEvents(items []queryItem) []queryEvent { + var out []queryEvent + for _, it := range items { + t, err := parseAGHTime(it.Time) + if err != nil { + continue + } + client := strings.TrimSpace(it.Client) + if client == "" { + client = strings.TrimSpace(it.ClientInfo.IP) + } + domain := strings.TrimSuffix(strings.ToLower(firstNonEmpty(it.Question.Name, it.Question.Host)), ".") + if client == "" || domain == "" { + continue + } + proto := it.ClientProto + if proto == "" { + proto = "dns" + } + out = append(out, queryEvent{At: t, Client: client, Domain: domain, Protocol: proto}) + } + return out +} + +func (d *Daemon) ToEventsForCommand(items []queryItem) []string { + seen := map[string]bool{} + var out []string + for _, ev := range d.toEvents(items) { + if seen[ev.Client] { + continue + } + seen[ev.Client] = true + out = append(out, ev.Client) + } + return out +} + +func (d *Daemon) CheckGeoIPForCommand(ctx context.Context, ip string) { + d.checkGeoIP(ctx, ip) +} + +func parseAGHTime(s string) (time.Time, error) { + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t, nil + } + return time.Parse(time.RFC3339, s) +} + +func (d *Daemon) pruneLocked() { + maxWindow := max(d.Config.RateLimitWindow, d.Config.SubdomainFloodWindow) + cut := time.Now().Add(-time.Duration(maxWindow+30) * time.Second) + var kept []queryEvent + for _, ev := range d.events { + if ev.At.After(cut) { + kept = append(kept, ev) + } + } + d.events = kept + for k, t := range d.seen { + if t.Before(cut) { + delete(d.seen, k) + } + } +} + +func (d *Daemon) analyze(ctx context.Context, events []queryEvent) error { + now := time.Now() + rateCut := now.Add(-time.Duration(d.Config.RateLimitWindow) * time.Second) + counts := map[string]int{} + protos := map[string]string{} + for _, ev := range events { + if ev.At.Before(rateCut) { + continue + } + key := ev.Client + "|" + ev.Domain + counts[key]++ + protos[key] = ev.Protocol + } + for key, count := range counts { + if count <= d.Config.RateLimitMaxRequests { + continue + } + parts := strings.SplitN(key, "|", 2) + reason := "rate-limit" + if d.watchlisted(parts[1]) { + reason = "dns-flood-watchlist" + } + perm := reason == "dns-flood-watchlist" + if err := d.Ban(ctx, parts[0], parts[1], count, protos[key], reason, "monitor", "", perm); err != nil { + d.error("Ban Fehler: %v", err) + } + } + if d.Config.SubdomainFloodEnabled { + d.analyzeSubdomains(ctx, events, now) + } + return nil +} + +func (d *Daemon) analyzeSubdomains(ctx context.Context, events []queryEvent, now time.Time) { + cut := now.Add(-time.Duration(d.Config.SubdomainFloodWindow) * time.Second) + sets := map[string]map[string]bool{} + for _, ev := range events { + if ev.At.Before(cut) { + continue + } + base := baseDomain(ev.Domain) + if base == "" || base == ev.Domain { + continue + } + key := ev.Client + "|" + base + if sets[key] == nil { + sets[key] = map[string]bool{} + } + sets[key][ev.Domain] = true + } + for key, set := range sets { + if len(set) <= d.Config.SubdomainFloodMaxUnique { + continue + } + parts := strings.SplitN(key, "|", 2) + reason := "subdomain-flood" + perm := d.watchlisted(parts[1]) + if perm { + reason = "dns-flood-watchlist" + } + _ = d.Ban(ctx, parts[0], "*."+parts[1], len(set), "dns", reason, "monitor", "", perm) + } +} + +func (d *Daemon) checkGeoIP(ctx context.Context, ip string) { + if d.Config.GeoIPSkipPrivate && geoip.IsPrivateIP(ip) { + return + } + d.mu.Lock() + if d.geoSeen[ip] { + d.mu.Unlock() + return + } + d.geoSeen[ip] = true + d.mu.Unlock() + if d.isWhitelisted(ip) { + return + } + exists, _ := d.Store.BanExists(ip) + if exists { + return + } + cc, err := d.Geo.Lookup(ip) + if err != nil || cc == "" { + return + } + if geoip.ShouldBlock(cc, d.Config.GeoIPMode, d.Config.GeoIPCountries) { + _ = d.Ban(ctx, ip, "GeoIP:"+cc, 0, "-", "geoip", "geoip", cc, true) + } +} + +func (d *Daemon) Ban(ctx context.Context, ip, domain string, count int, proto, reason, source, country string, permanent bool) error { + if d.isWhitelisted(ip) { + return nil + } + exists, err := d.Store.BanExists(ip) + if err != nil || exists { + return err + } + if d.Config.DryRun { + _ = d.Store.History("DRY", ip, domain, strconv.Itoa(count), "dry-run", proto, "dry-run ("+reason+")") + d.warn("[DRY-RUN] Würde sperren: %s (%s, %s)", ip, reason, domain) + return nil + } + duration := d.Config.BanDuration + level := 0 + if source == "monitor" && d.Config.ProgressiveBanEnabled && !permanent { + level, err = d.Store.IncrementOffense(ip, d.Config.ProgressiveBanResetAfter) + if err != nil { + return err + } + duration = durationForLevel(d.Config.BanDuration, level, d.Config.ProgressiveBanMultiplier) + if d.Config.ProgressiveBanMaxLevel > 0 && level >= d.Config.ProgressiveBanMaxLevel { + permanent = true + duration = 0 + } + } + banUntil := int64(0) + if !permanent && duration > 0 { + banUntil = time.Now().Unix() + duration + } + if err := d.FW.Add(ctx, ip, duration); err != nil { + d.warn("Firewall Add Warnung: %v", err) + } + b := db.Ban{IP: ip, Domain: domain, Count: count, BanUntil: banUntil, Duration: duration, OffenseLevel: level, Permanent: permanent, Reason: reason, Protocol: proto, Source: source, GeoIPCountry: country, GeoIPMode: d.Config.GeoIPMode} + if err := d.Store.InsertBan(b); err != nil { + return err + } + _ = d.Store.History("BAN", ip, domain, strconv.Itoa(count), formatDuration(duration, permanent), proto, reason) + abuseReported := d.shouldReportAbuseIPDB(b) + d.notifyBan(context.Background(), b, abuseReported) + if abuseReported { + d.reportAbuseIPDB(context.Background(), b) + } + d.warn("BAN %s (%s, %s)", ip, reason, domain) + return nil +} + +func (d *Daemon) Unban(ctx context.Context, ip, reason string) error { + return d.unban(ctx, ip, reason, true) +} + +func (d *Daemon) UnbanQuiet(ctx context.Context, ip, reason string) error { + return d.unban(ctx, ip, reason, false) +} + +func (d *Daemon) unban(ctx context.Context, ip, reason string, notify bool) error { + _ = d.FW.Del(ctx, ip) + if err := d.Store.DeleteBan(ip); err != nil { + return err + } + _ = d.Store.History("UNBAN", ip, "-", "-", "-", "-", reason) + if notify { + d.notifyUnban(context.Background(), ip, reason) + } + d.info("UNBAN %s (%s)", ip, reason) + return nil +} + +func (d *Daemon) AutoUnbanGeoIP(ctx context.Context) error { + bans, err := d.Store.BansByReason("geoip") + if err != nil { + return err + } + for _, b := range bans { + shouldUnban := false + if !d.Config.GeoIPEnabled { + shouldUnban = true + } else if b.GeoIPMode != "" && b.GeoIPMode != d.Config.GeoIPMode { + shouldUnban = true + } else if b.GeoIPCountry != "" && !geoip.ShouldBlock(b.GeoIPCountry, d.Config.GeoIPMode, d.Config.GeoIPCountries) { + shouldUnban = true + } + if shouldUnban { + _ = d.Unban(ctx, b.IP, "geoip-auto-unban") + } + } + return nil +} + +func (d *Daemon) isWhitelisted(ip string) bool { + if d.wl == nil { + _ = d.loadCaches() + } + if d.wl[ip] { + return true + } + ok, _ := d.Store.WhitelistContains(ip) + return ok +} + +func (d *Daemon) watchlisted(domain string) bool { + if !d.Config.DNSFloodWatchlistEnabled { + return false + } + for _, w := range d.Config.DNSFloodWatchlist { + w = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(w)), ".") + if domain == w || strings.HasSuffix(domain, "."+w) { + return true + } + } + return false +} + +func (d *Daemon) SyncWhitelist(ctx context.Context) error { + if err := os.MkdirAll(d.Config.ExternalWhitelistCacheDir, 0755); err != nil { + return err + } + ips := map[string]bool{} + for i, u := range d.Config.ExternalWhitelistURLs { + lines, err := d.fetchCachedLines(ctx, u, d.Config.ExternalWhitelistCacheDir, "whitelist", i) + if err != nil { + d.warn("Whitelist Download Warnung %s: %v", u, err) + continue + } + for _, line := range lines { + for _, ip := range parseListEntry(line) { + for _, resolved := range resolveEntry(ip) { + ips[resolved] = true + } + } + } + } + var list []string + for ip := range ips { + list = append(list, ip) + } + sort.Strings(list) + if err := d.Store.ReplaceWhitelist(list, "external"); err != nil { + return err + } + _ = d.loadCaches() + bans, err := d.Store.ActiveBans() + if err != nil { + return err + } + for _, b := range bans { + if d.isWhitelisted(b.IP) { + _ = d.Unban(ctx, b.IP, "external-whitelist") + } + } + d.info("Externe Whitelist synchronisiert: %d IPs", len(list)) + return nil +} + +func (d *Daemon) SyncBlocklist(ctx context.Context) error { + if err := os.MkdirAll(d.Config.ExternalBlocklistCacheDir, 0755); err != nil { + return err + } + desired := map[string]bool{} + for i, u := range d.Config.ExternalBlocklistURLs { + lines, err := d.fetchCachedLines(ctx, u, d.Config.ExternalBlocklistCacheDir, "blocklist", i) + if err != nil { + d.warn("Blocklist Download Warnung %s: %v", u, err) + continue + } + for _, line := range lines { + for _, entry := range parseListEntry(line) { + for _, resolved := range resolveEntry(entry) { + desired[resolved] = true + } + } + } + } + for ip := range desired { + if d.isWhitelisted(ip) { + continue + } + if d.Config.DryRun { + _ = d.Store.History("DRY", ip, "-", "-", "dry-run", "-", "dry-run (external-blocklist)") + d.warn("[DRY-RUN] Würde externe Blocklist-IP sperren: %s", ip) + continue + } + perm := d.Config.ExternalBlocklistDuration == 0 + dur := d.Config.ExternalBlocklistDuration + if dur == 0 { + dur = 0 + } + _ = d.FW.Add(ctx, ip, dur) + exists, _ := d.Store.BanExists(ip) + if !exists { + banUntil := int64(0) + if !perm && dur > 0 { + banUntil = time.Now().Unix() + dur + } + b := db.Ban{IP: ip, Domain: "-", BanUntil: banUntil, Duration: dur, Permanent: perm, Reason: "external-blocklist", Protocol: "-", Source: "external-blocklist"} + _ = d.Store.InsertBan(b) + _ = d.Store.History("BAN", ip, "-", "-", formatDuration(dur, perm), "-", "external-blocklist") + d.notifyBan(context.Background(), b, false) + } + } + if d.Config.ExternalBlocklistAutoUnban { + current, err := d.Store.BansBySource("external-blocklist") + if err != nil { + return err + } + for _, b := range current { + if !desired[b.IP] { + _ = d.Unban(ctx, b.IP, "external-blocklist-removed") + } + } + } + d.info("Externe Blocklist synchronisiert: %d IPs/Netze", len(desired)) + return nil +} + +func (d *Daemon) notifyBan(ctx context.Context, b db.Ban, abuseReported bool) { + if !d.Config.NotifyEnabled { + return + } + if b.Source == "geoip" && !d.Config.GeoIPNotify { + return + } + if b.Source == "external-blocklist" && !d.Config.ExternalBlocklistNotify { + return + } + title := notificationTitle(b) + msg := d.banNotificationMessage(ctx, b, abuseReported) + d.sendNotification(ctx, title, msg, b) +} + +func (d *Daemon) notifyUnban(ctx context.Context, ip, reason string) { + if !d.Config.NotifyEnabled { + return + } + host := d.serverHostname() + ptr := lookupPTR(ctx, ip) + msg := fmt.Sprintf("✅ AdGuard Shield Freigabe auf %s\n---\nIP: %s\nHostname: %s\n\nAbuseIPDB: %s", host, ip, ptr, abuseIPDBCheckURL(ip)) + d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{IP: ip, Reason: reason}) +} + +func (d *Daemon) NotifyBulkUnban(ctx context.Context, reason string, count int) { + if !d.Config.NotifyEnabled || count <= 0 { + return + } + msg := fmt.Sprintf("✅ AdGuard Shield Bulk-Freigabe auf %s\n---\nFreigegebene IPs: %d\nAktion: %s", d.serverHostname(), count, displayReason(reason)) + d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{Reason: reason}) +} + +func (d *Daemon) NotifyServiceStart(ctx context.Context) { + d.notifyServiceOnce(ctx, "service_start") +} + +func (d *Daemon) NotifyServiceStop(ctx context.Context) { + d.notifyServiceOnce(ctx, "service_stop") +} + +func (d *Daemon) notifyServiceOnce(ctx context.Context, action string) { + d.serviceMu.Lock() + switch action { + case "service_start": + if d.serviceStartNotified { + d.serviceMu.Unlock() + return + } + d.serviceStartNotified = true + case "service_stop": + if d.serviceStopNotified { + d.serviceMu.Unlock() + return + } + d.serviceStopNotified = true + } + d.serviceMu.Unlock() + + notifyCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + d.notifyService(notifyCtx, action) +} + +func (d *Daemon) notifyService(ctx context.Context, action string) { + if !d.Config.NotifyEnabled { + return + } + state := "gestartet" + icon := "🟢" + if action == "service_stop" { + state = "gestoppt" + icon = "🔴" + } + msg := fmt.Sprintf("%s AdGuard Shield %s wurde auf %s %s.", icon, appinfo.Version, d.serverHostname(), state) + d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{Reason: action, Source: "service"}) +} + +func (d *Daemon) banNotificationMessage(ctx context.Context, b db.Ban, abuseReported bool) string { + host := d.serverHostname() + if b.Source == "geoip" { + return fmt.Sprintf("🌍 AdGuard Shield GeoIP-Sperre auf %s\n---\nIP: %s\nLand: %s\nModus: %s\nDauer: %s\n\nAbuseIPDB: %s", + host, b.IP, b.GeoIPCountry, displayGeoIPMode(b.GeoIPMode), formatNotificationDuration(b.Duration, b.Permanent), abuseIPDBCheckURL(b.IP)) + } + + var lines []string + lines = append(lines, fmt.Sprintf("🚫 AdGuard Shield Ban auf %s", host)) + if abuseReported { + lines = append(lines, "⚠️ IP wurde an AbuseIPDB gemeldet") + } + lines = append(lines, "---") + lines = append(lines, "IP: "+b.IP) + lines = append(lines, "Hostname: "+lookupPTR(ctx, b.IP)) + lines = append(lines, "Grund: "+d.displayBanReason(b)) + lines = append(lines, "Dauer: "+d.displayBanDuration(b)) + lines = append(lines, "", "AbuseIPDB: "+abuseIPDBCheckURL(b.IP)) + return strings.Join(lines, "\n") +} + +func (d *Daemon) displayBanReason(b db.Ban) string { + if b.Count > 0 && strings.TrimSpace(b.Domain) != "" && b.Domain != "-" { + return fmt.Sprintf("%dx %s in %ds via %s, %s", b.Count, b.Domain, d.notificationWindow(b), displayProtocol(b.Protocol), displayReason(b.Reason)) + } + return displayReason(b.Reason) +} + +func (d *Daemon) displayBanDuration(b db.Ban) string { + out := formatNotificationDuration(b.Duration, b.Permanent) + if b.OffenseLevel > 0 { + if d.Config.ProgressiveBanMaxLevel > 0 { + out += fmt.Sprintf(" [Stufe %d/%d]", b.OffenseLevel, d.Config.ProgressiveBanMaxLevel) + } else { + out += fmt.Sprintf(" [Stufe %d]", b.OffenseLevel) + } + } + return out +} + +func (d *Daemon) notificationWindow(b db.Ban) int { + if b.Reason == "subdomain-flood" || strings.HasPrefix(b.Domain, "*.") { + return d.Config.SubdomainFloodWindow + } + return d.Config.RateLimitWindow +} + +func (d *Daemon) shouldReportAbuseIPDB(b db.Ban) bool { + return b.Source == "monitor" && b.Permanent && d.Config.AbuseIPDBEnabled && d.Config.AbuseIPDBAPIKey != "" +} + +func (d *Daemon) serverHostname() string { + name, err := os.Hostname() + if err != nil || strings.TrimSpace(name) == "" { + return "unbekannt" + } + return strings.TrimSpace(name) +} + +func notificationTitle(b db.Ban) string { + return "🛡️ AdGuard Shield" +} + +func lookupPTR(ctx context.Context, ip string) string { + if _, err := netip.ParseAddr(ip); err != nil { + return "(unbekannt)" + } + lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + names, err := net.DefaultResolver.LookupAddr(lookupCtx, ip) + if err != nil || len(names) == 0 { + return "(unbekannt)" + } + return strings.TrimSuffix(strings.TrimSpace(names[0]), ".") +} + +func abuseIPDBCheckURL(ip string) string { + return "https://www.abuseipdb.com/check/" + ip +} + +func displayGeoIPMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "allowlist": + return "Allowlist" + default: + return "Blocklist" + } +} + +func displayProtocol(proto string) string { + proto = strings.TrimSpace(proto) + if proto == "" || proto == "-" { + return "DNS" + } + return strings.ToUpper(proto) +} + +func displayReason(reason string) string { + switch strings.ToLower(strings.TrimSpace(reason)) { + case "dns-flood-watchlist": + return "DNS-Flood-Watchlist" + case "rate-limit": + return "Rate-Limit" + case "subdomain-flood": + return "Subdomain-Flood" + case "external-blocklist": + return "Externe Blocklist" + case "geoip": + return "GeoIP" + default: + parts := strings.FieldsFunc(reason, func(r rune) bool { return r == '-' || r == '_' }) + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:]) + } + if len(parts) == 0 { + return "Unbekannt" + } + return strings.Join(parts, "-") + } +} + +func formatNotificationDuration(sec int64, perm bool) string { + if perm || sec == 0 { + return "PERMANENT" + } + if sec < 60 { + return strconv.FormatInt(sec, 10) + "s" + } + h := sec / 3600 + m := (sec % 3600) / 60 + s := sec % 60 + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + if s > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%dm", m) +} + +func (d *Daemon) sendNotification(ctx context.Context, title, msg string, b db.Ban) { + action := notificationAction(b) + req, err := d.notificationRequest(ctx, title, msg, b) + if err != nil { + d.warn("Benachrichtigung nicht vorbereitet (%s/%s): %v", d.Config.NotifyType, action, err) + return + } + if req == nil { + d.warn("Benachrichtigung übersprungen (%s/%s): Ziel nicht konfiguriert oder Typ unbekannt", d.Config.NotifyType, action) + return + } + resp, err := d.Client.Do(req) + if err != nil { + d.warn("Benachrichtigung fehlgeschlagen (%s/%s): %v", d.Config.NotifyType, action, err) + return + } + if resp == nil { + d.warn("Benachrichtigung fehlgeschlagen (%s/%s): keine HTTP-Antwort", d.Config.NotifyType, action) + return + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + d.warn("Benachrichtigung fehlgeschlagen (%s/%s): HTTP %d", d.Config.NotifyType, action, resp.StatusCode) + return + } + d.debug("Benachrichtigung gesendet (%s/%s): HTTP %d", d.Config.NotifyType, action, resp.StatusCode) +} + +func (d *Daemon) reportAbuseIPDB(ctx context.Context, b db.Ban) { + if d.Config.AbuseIPDBAPIKey == "" { + d.warn("AbuseIPDB: API-Key nicht konfiguriert") + return + } + form := url.Values{} + form.Set("ip", b.IP) + form.Set("categories", d.Config.AbuseIPDBCategories) + form.Set("comment", d.abuseIPDBComment(b)) + go func() { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.abuseipdb.com/api/v2/report", strings.NewReader(form.Encode())) + if err != nil { + return + } + req.Header.Set("Key", d.Config.AbuseIPDBAPIKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := d.Client.Do(req) + if err != nil { + d.error("AbuseIPDB Fehler: %v", err) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + d.info("AbuseIPDB: %s erfolgreich gemeldet", b.IP) + } else { + d.warn("AbuseIPDB: HTTP %d für %s", resp.StatusCode, b.IP) + } + }() +} + +func (d *Daemon) abuseIPDBComment(b db.Ban) string { + return fmt.Sprintf("DNS flooding on our DNS server: %dx %s in %ds. Banned by Adguard Shield 🔗 %s", b.Count, b.Domain, d.notificationWindow(b), appinfo.ProjectURL) +} + +func (d *Daemon) notificationRequest(ctx context.Context, title, msg string, b db.Ban) (*http.Request, error) { + action := notificationAction(b) + switch d.Config.NotifyType { + case "ntfy": + if d.Config.NTFYTopic == "" { + return nil, nil + } + url := strings.TrimRight(d.Config.NTFYServerURL, "/") + "/" + d.Config.NTFYTopic + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg)) + if err != nil { + return nil, err + } + req.Header.Set("Title", title) + req.Header.Set("Priority", d.Config.NTFYPriority) + if d.Config.NTFYToken != "" { + req.Header.Set("Authorization", "Bearer "+d.Config.NTFYToken) + } + return req, nil + case "discord": + return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"content": title + "\n\n" + msg}) + case "slack": + return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"text": title + "\n\n" + msg}) + case "generic": + return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"title": title, "message": msg, "client": b.IP, "action": action}) + case "gotify": + form := url.Values{} + form.Set("title", title) + form.Set("message", msg) + form.Set("priority", "5") + req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.Config.NotifyWebhook, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req, nil + default: + return nil, nil + } +} + +func notificationAction(b db.Ban) string { + if strings.HasPrefix(b.Reason, "service_") { + return b.Reason + } + if strings.HasSuffix(b.Reason, "-flush") { + return b.Reason + } + if b.Source == "" && b.Domain == "" { + return "unban" + } + return "ban" +} + +func jsonPost(ctx context.Context, url string, payload any) (*http.Request, error) { + if url == "" { + return nil, nil + } + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +func (d *Daemon) fetchLines(ctx context.Context, url string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := d.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20)) + if err != nil { + return nil, err + } + return strings.Split(string(b), "\n"), nil +} + +func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, prefix string, index int) ([]string, error) { + cacheFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.txt", prefix, index)) + etagFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.etag", prefix, index)) + tmpFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.tmp", prefix, index)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, err + } + if etag, err := os.ReadFile(etagFile); err == nil { + if value := strings.TrimSpace(string(etag)); value != "" { + req.Header.Set("If-None-Match", value) + } + } + resp, err := d.Client.Do(req) + if err != nil { + if b, readErr := os.ReadFile(cacheFile); readErr == nil { + d.warn("%s Download fehlgeschlagen, nutze Cache: %v", prefix, err) + return strings.Split(string(b), "\n"), nil + } + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotModified { + d.debug("%s Liste unverändert: %s", prefix, sourceURL) + b, err := os.ReadFile(cacheFile) + if err != nil { + return nil, err + } + return strings.Split(string(b), "\n"), nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if b, readErr := os.ReadFile(cacheFile); readErr == nil { + d.warn("%s Download HTTP %d, nutze Cache: %s", prefix, resp.StatusCode, sourceURL) + return strings.Split(string(b), "\n"), nil + } + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20)) + if err != nil { + return nil, err + } + if err := os.WriteFile(tmpFile, b, 0644); err != nil { + return nil, err + } + if etag := resp.Header.Get("ETag"); etag != "" { + _ = os.WriteFile(etagFile, []byte(etag+"\n"), 0644) + } + if err := os.Rename(tmpFile, cacheFile); err != nil { + return nil, err + } + d.debug("%s Liste aktualisiert: %s (%d Bytes)", prefix, sourceURL, len(b)) + return strings.Split(string(b), "\n"), nil +} + +func parseListEntry(line string) []string { + line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff")) + if line == "" || strings.HasPrefix(line, "#") { + return nil + } + if i := strings.IndexAny(line, "#;"); i >= 0 { + line = strings.TrimSpace(line[:i]) + } + parts := strings.Fields(line) + if len(parts) >= 2 && (parts[0] == "0.0.0.0" || strings.HasPrefix(parts[0], "127.") || parts[0] == "::" || parts[0] == "::1") { + line = parts[1] + } else if len(parts) > 1 { + return nil + } + if strings.Contains(line, "://") { + return nil + } + if _, err := netip.ParseAddr(line); err == nil { + return []string{line} + } + if _, err := netip.ParsePrefix(line); err == nil { + return []string{line} + } + if isHostname(line) { + return []string{line} + } + return nil +} + +func resolveEntry(entry string) []string { + if _, err := netip.ParseAddr(entry); err == nil { + return []string{entry} + } + if _, err := netip.ParsePrefix(entry); err == nil { + return []string{entry} + } + addrs, err := net.LookupHost(entry) + if err != nil { + return nil + } + var out []string + for _, a := range addrs { + if ip, err := netip.ParseAddr(a); err == nil && !ip.IsUnspecified() { + out = append(out, ip.String()) + } + } + return out +} + +func isHostname(s string) bool { + if len(s) > 253 || strings.ContainsAny(s, "/:") { + return false + } + for _, p := range strings.Split(s, ".") { + if p == "" || len(p) > 63 { + return false + } + } + return true +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} +func baseDomain(domain string) string { + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return "" + } + if len(parts) >= 3 { + lastTwo := parts[len(parts)-2] + "." + parts[len(parts)-1] + if isMultipartPublicSuffix(lastTwo) { + return parts[len(parts)-3] + "." + lastTwo + } + } + return parts[len(parts)-2] + "." + parts[len(parts)-1] +} + +func isMultipartPublicSuffix(s string) bool { + first, rest, ok := strings.Cut(s, ".") + if !ok || len(rest) < 2 || len(rest) > 3 { + return false + } + switch first { + case "co", "com", "net", "org", "gov", "edu", "ac", "gv", "ne", "or", "go": + return true + default: + return false + } +} + +func (d *Daemon) SaveFirewallRules(ctx context.Context) error { + if err := os.MkdirAll(d.Config.StateDir, 0755); err != nil { + return err + } + if err := saveCommand(ctx, filepath.Join(d.Config.StateDir, "iptables-rules.v4"), "iptables-save"); err != nil { + return err + } + return saveCommand(ctx, filepath.Join(d.Config.StateDir, "iptables-rules.v6"), "ip6tables-save") +} + +func saveCommand(ctx context.Context, path, name string) error { + out, err := exec.CommandContext(ctx, name).Output() + if err != nil { + return err + } + return os.WriteFile(path, out, 0644) +} +func durationForLevel(base int64, level, mult int) int64 { + if level <= 1 { + return base + } + if mult < 1 { + mult = 1 + } + d := base + for i := 1; i < level; i++ { + d *= int64(mult) + } + return d +} +func formatDuration(sec int64, perm bool) string { + if perm || sec == 0 { + return "permanent" + } + return strconv.FormatInt(sec, 10) + "s" +} +func (d *Daemon) log(format string, args ...any) { + d.info(format, args...) +} + +func (d *Daemon) debug(format string, args ...any) { + if d.Logger != nil { + d.Logger.Debugf(format, args...) + } +} +func (d *Daemon) info(format string, args ...any) { + if d.Logger != nil { + d.Logger.Infof(format, args...) + } +} +func (d *Daemon) warn(format string, args ...any) { + if d.Logger != nil { + d.Logger.Warnf(format, args...) + } +} +func (d *Daemon) error(format string, args ...any) { + if d.Logger != nil { + d.Logger.Errorf(format, args...) + } +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..55df9fc --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,365 @@ +package daemon + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "adguard-shield/internal/config" + "adguard-shield/internal/db" + "adguard-shield/internal/firewall" +) + +func TestParseListEntry(t *testing.T) { + cases := map[string]string{ + "1.2.3.4 # comment": "1.2.3.4", + "0.0.0.0 bad.example": "bad.example", + "2001:db8::/32": "2001:db8::/32", + } + for input, want := range cases { + got := parseListEntry(input) + if len(got) != 1 || got[0] != want { + t.Fatalf("%q -> %#v, want %q", input, got, want) + } + } + if got := parseListEntry("http://example.invalid/list"); got != nil { + t.Fatalf("URL should be rejected: %#v", got) + } +} + +func TestNotificationFormatting(t *testing.T) { + d := &Daemon{Config: &config.Config{ + RateLimitWindow: 60, + SubdomainFloodWindow: 120, + ProgressiveBanMaxLevel: 3, + }} + b := db.Ban{ + IP: "203.0.113.7", + Domain: "abb.com", + Count: 110, + Duration: 3600, + OffenseLevel: 1, + Reason: "rate-limit", + Protocol: "dns", + Source: "monitor", + } + if got, want := d.displayBanReason(b), "110x abb.com in 60s via DNS, Rate-Limit"; got != want { + t.Fatalf("reason = %q, want %q", got, want) + } + if got, want := d.displayBanDuration(b), "1h 0m [Stufe 1/3]"; got != want { + t.Fatalf("duration = %q, want %q", got, want) + } + + b.Permanent = true + b.Duration = 0 + b.OffenseLevel = 3 + if got, want := d.displayBanDuration(b), "PERMANENT [Stufe 3/3]"; got != want { + t.Fatalf("permanent duration = %q, want %q", got, want) + } +} + +func TestNTFYNotificationTitleDoesNotDuplicateShieldTag(t *testing.T) { + d := &Daemon{Config: &config.Config{ + NotifyType: "ntfy", + NTFYServerURL: "https://ntfy.example", + NTFYTopic: "adguard-shield", + NTFYPriority: "4", + }} + req, err := d.notificationRequest(context.Background(), "🛡️ AdGuard Shield", "test", db.Ban{IP: "203.0.113.7", Reason: "rate-limit", Source: "monitor"}) + if err != nil { + t.Fatal(err) + } + if req == nil { + t.Fatal("request must be created") + } + if got, want := req.Header.Get("Title"), "🛡️ AdGuard Shield"; got != want { + t.Fatalf("title = %q, want %q", got, want) + } + if got := req.Header.Get("Tags"); strings.Contains(got, "shield") { + t.Fatalf("tags must not duplicate title shield emoji: %q", got) + } +} + +func TestNotificationRequestsForWebhookProviders(t *testing.T) { + cases := []struct { + name string + notifyType string + wantType string + wantPayload []string + }{ + { + name: "discord", + notifyType: "discord", + wantType: "application/json", + wantPayload: []string{`"content":"title\n\nmessage"`}, + }, + { + name: "slack", + notifyType: "slack", + wantType: "application/json", + wantPayload: []string{`"text":"title\n\nmessage"`}, + }, + { + name: "generic", + notifyType: "generic", + wantType: "application/json", + wantPayload: []string{`"action":"unban"`, `"client":"203.0.113.7"`, `"message":"message"`}, + }, + { + name: "gotify", + notifyType: "gotify", + wantType: "application/x-www-form-urlencoded", + wantPayload: []string{`title=title`, `message=message`, `priority=5`}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &Daemon{Config: &config.Config{ + NotifyType: tc.notifyType, + NotifyWebhook: "https://hooks.example/notify", + }} + req, err := d.notificationRequest(context.Background(), "title", "message", db.Ban{IP: "203.0.113.7", Reason: "manual"}) + if err != nil { + t.Fatal(err) + } + if req == nil { + t.Fatal("request must be created") + } + if req.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", req.Method) + } + if got := req.Header.Get("Content-Type"); got != tc.wantType { + t.Fatalf("content type = %q, want %q", got, tc.wantType) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + payload := string(body) + for _, want := range tc.wantPayload { + if !strings.Contains(payload, want) { + t.Fatalf("payload %q does not contain %q", payload, want) + } + } + }) + } +} + +func TestServiceNotificationsSendStartAndStopOnce(t *testing.T) { + requests := make(chan string, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + d := &Daemon{ + Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL}, + Client: srv.Client(), + } + d.NotifyServiceStart(context.Background()) + d.NotifyServiceStart(context.Background()) + d.NotifyServiceStop(context.Background()) + d.NotifyServiceStop(context.Background()) + + var payloads []string + for len(payloads) < 2 { + select { + case payload := <-requests: + payloads = append(payloads, payload) + case <-time.After(4 * time.Second): + t.Fatalf("service notifications sent %d payloads, want 2", len(payloads)) + } + } + if !strings.Contains(payloads[0], `"action":"service_start"`) || !strings.Contains(payloads[0], "gestartet") { + t.Fatalf("unexpected service start payload: %s", payloads[0]) + } + if !strings.Contains(payloads[1], `"action":"service_stop"`) || !strings.Contains(payloads[1], "gestoppt") { + t.Fatalf("unexpected service stop payload: %s", payloads[1]) + } + select { + case payload := <-requests: + t.Fatalf("duplicate service notification sent: %s", payload) + case <-time.After(150 * time.Millisecond): + } +} + +func TestUnbanSendsNotificationForMonitorBan(t *testing.T) { + requests := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + store, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil { + t.Fatal(err) + } + d := &Daemon{ + Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL}, + Store: store, + FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true), + Client: srv.Client(), + } + if err := d.Unban(context.Background(), "127.0.0.1", "manual"); err != nil { + t.Fatal(err) + } + select { + case payload := <-requests: + if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") { + t.Fatalf("unexpected payload: %s", payload) + } + case <-time.After(4 * time.Second): + t.Fatal("unban notification was not sent") + } +} + +func TestUnbanStillSendsExternalBlocklistNotificationWhenBanNotificationsDisabled(t *testing.T) { + requests := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + store, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "external-blocklist", Source: "external-blocklist"}); err != nil { + t.Fatal(err) + } + d := &Daemon{ + Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL, ExternalBlocklistNotify: false}, + Store: store, + FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true), + Client: srv.Client(), + } + if err := d.Unban(context.Background(), "127.0.0.1", "external-blocklist-removed"); err != nil { + t.Fatal(err) + } + select { + case payload := <-requests: + if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") { + t.Fatalf("unexpected payload: %s", payload) + } + case <-time.After(4 * time.Second): + t.Fatal("external blocklist unban notification was not sent") + } +} + +func TestUnbanQuietSkipsIndividualNotificationAndBulkSummarySendsOnce(t *testing.T) { + requests := make(chan string, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests <- string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + store, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil { + t.Fatal(err) + } + d := &Daemon{ + Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL}, + Store: store, + FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true), + Client: srv.Client(), + } + if err := d.UnbanQuiet(context.Background(), "127.0.0.1", "manual-flush"); err != nil { + t.Fatal(err) + } + select { + case payload := <-requests: + t.Fatalf("quiet unban sent individual notification: %s", payload) + case <-time.After(150 * time.Millisecond): + } + + d.NotifyBulkUnban(context.Background(), "manual-flush", 1) + select { + case payload := <-requests: + if !strings.Contains(payload, `"action":"manual-flush"`) || !strings.Contains(payload, "Bulk-Freigabe") || !strings.Contains(payload, "Freigegebene IPs: 1") { + t.Fatalf("unexpected payload: %s", payload) + } + case <-time.After(4 * time.Second): + t.Fatal("bulk unban notification was not sent") + } +} + +func TestAbuseReportingScope(t *testing.T) { + d := &Daemon{Config: &config.Config{AbuseIPDBEnabled: true, AbuseIPDBAPIKey: "key"}} + if !d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "monitor"}) { + t.Fatal("monitor permanent ban should be reported") + } + if d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "geoip"}) { + t.Fatal("geoip ban must not be reported") + } + if d.shouldReportAbuseIPDB(db.Ban{Permanent: false, Source: "monitor"}) { + t.Fatal("temporary ban must not be reported") + } + + d.Config.RateLimitWindow = 60 + got := d.abuseIPDBComment(db.Ban{Count: 110, Domain: "abb.com", Reason: "rate-limit"}) + want := "DNS flooding on our DNS server: 110x abb.com in 60s. Banned by Adguard Shield 🔗 https://tnvs.de/as" + if got != want { + t.Fatalf("comment = %q, want %q", got, want) + } +} + +func TestAbuseIPDBCheckURL(t *testing.T) { + if got := abuseIPDBCheckURL("65.185.189.75"); !strings.Contains(got, "https://www.abuseipdb.com/check/65.185.189.75") { + t.Fatalf("unexpected AbuseIPDB url: %s", got) + } +} + +func TestBaseDomain(t *testing.T) { + if got := baseDomain("a.b.example.com"); got != "example.com" { + t.Fatalf("unexpected base domain: %s", got) + } + if got := baseDomain("a.b.example.co.uk"); got != "example.co.uk" { + t.Fatalf("unexpected multipart base domain: %s", got) + } +} + +func TestDryRunDoesNotInsertActiveBan(t *testing.T) { + store, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + d := &Daemon{ + Config: &config.Config{DryRun: true, BanDuration: 60}, + Store: store, + FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true), + wl: map[string]bool{}, + } + if err := d.Ban(context.Background(), "1.2.3.4", "example.com", 99, "dns", "rate-limit", "monitor", "", false); err != nil { + t.Fatal(err) + } + ok, err := store.BanExists("1.2.3.4") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("dry-run must not create an active ban") + } +} diff --git a/internal/daemon/live.go b/internal/daemon/live.go new file mode 100644 index 0000000..8723353 --- /dev/null +++ b/internal/daemon/live.go @@ -0,0 +1,384 @@ +package daemon + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "adguard-shield/internal/db" + "adguard-shield/internal/syslog" +) + +type LiveOptions struct { + Interval time.Duration + Top int + Recent int + LogLevel string + Once bool +} + +type liveSnapshot struct { + At time.Time + APIEntries int + Window int + Limit int + Events []queryEvent + TopPairs []liveCount + SubdomainGroups []liveCount + ActiveBans []db.Ban + Offenses int + ExpiredOffenses int + WhitelistCount int + BlocklistBans int + SystemLogs []string +} + +type liveCount struct { + Client string + Domain string + Count int + Protocol string +} + +func (d *Daemon) Live(ctx context.Context, w io.Writer, opts LiveOptions) error { + if opts.Interval <= 0 { + opts.Interval = time.Duration(d.Config.CheckInterval) * time.Second + } + if opts.Interval <= 0 { + opts.Interval = 2 * time.Second + } + if opts.Top <= 0 { + opts.Top = 10 + } + if opts.Recent <= 0 { + opts.Recent = 12 + } + if strings.TrimSpace(opts.LogLevel) == "" { + opts.LogLevel = "INFO" + } + + for { + snap, err := d.liveSnapshot(ctx, opts) + renderLive(w, d, snap, err, opts) + if opts.Once { + return err + } + timer := time.NewTimer(opts.Interval) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (d *Daemon) liveSnapshot(ctx context.Context, opts LiveOptions) (liveSnapshot, error) { + snap := liveSnapshot{ + At: time.Now(), + Window: d.Config.RateLimitWindow, + Limit: d.Config.RateLimitMaxRequests, + } + items, err := d.FetchQueryLog(ctx) + if err != nil { + return snap, err + } + snap.APIEntries = len(items) + events := dedupeEvents(d.toEvents(items)) + sort.Slice(events, func(i, j int) bool { return events[i].At.After(events[j].At) }) + if len(events) > opts.Recent { + snap.Events = append([]queryEvent(nil), events[:opts.Recent]...) + } else { + snap.Events = append([]queryEvent(nil), events...) + } + snap.TopPairs = topQueryPairs(events, d.Config.RateLimitWindow, opts.Top) + snap.SubdomainGroups = topSubdomainGroups(events, d.Config.SubdomainFloodWindow, opts.Top) + + if bans, err := d.Store.ActiveBans(); err == nil { + snap.ActiveBans = bans + } + if n, err := d.Store.CountOffenses(); err == nil { + snap.Offenses = n + } + if n, err := d.Store.CountExpiredOffenses(d.Config.ProgressiveBanResetAfter); err == nil { + snap.ExpiredOffenses = n + } + if wl, err := d.Store.AllWhitelist(); err == nil { + snap.WhitelistCount = len(wl) + } + if n, err := d.Store.CountBySource("external-blocklist"); err == nil { + snap.BlocklistBans = n + } + snap.SystemLogs = RecentLogLines(d.Config.LogFile, opts.LogLevel, opts.Recent) + return snap, nil +} + +func dedupeEvents(events []queryEvent) []queryEvent { + seen := map[string]bool{} + out := make([]queryEvent, 0, len(events)) + for _, ev := range events { + key := ev.At.Format(time.RFC3339Nano) + "|" + ev.Client + "|" + ev.Domain + "|" + ev.Protocol + if seen[key] { + continue + } + seen[key] = true + out = append(out, ev) + } + return out +} + +func topQueryPairs(events []queryEvent, window, limit int) []liveCount { + cut := time.Now().Add(-time.Duration(window) * time.Second) + counts := map[string]*liveCount{} + protos := map[string]map[string]bool{} + for _, ev := range events { + if ev.At.Before(cut) { + continue + } + key := ev.Client + "|" + ev.Domain + if counts[key] == nil { + counts[key] = &liveCount{Client: ev.Client, Domain: ev.Domain} + protos[key] = map[string]bool{} + } + counts[key].Count++ + protos[key][formatProtocol(ev.Protocol)] = true + } + out := make([]liveCount, 0, len(counts)) + for key, item := range counts { + item.Protocol = strings.Join(sortedKeys(protos[key]), ",") + out = append(out, *item) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain + } + return out[i].Count > out[j].Count + }) + if limit > 0 && len(out) > limit { + return out[:limit] + } + return out +} + +func topSubdomainGroups(events []queryEvent, window, limit int) []liveCount { + cut := time.Now().Add(-time.Duration(window) * time.Second) + sets := map[string]map[string]bool{} + protos := map[string]map[string]bool{} + for _, ev := range events { + if ev.At.Before(cut) { + continue + } + base := baseDomain(ev.Domain) + if base == "" || base == ev.Domain { + continue + } + key := ev.Client + "|" + base + if sets[key] == nil { + sets[key] = map[string]bool{} + protos[key] = map[string]bool{} + } + sets[key][ev.Domain] = true + protos[key][formatProtocol(ev.Protocol)] = true + } + out := make([]liveCount, 0, len(sets)) + for key, set := range sets { + client, domain, _ := strings.Cut(key, "|") + out = append(out, liveCount{ + Client: client, + Domain: domain, + Count: len(set), + Protocol: strings.Join(sortedKeys(protos[key]), ","), + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain + } + return out[i].Count > out[j].Count + }) + if limit > 0 && len(out) > limit { + return out[:limit] + } + return out +} + +func renderLive(w io.Writer, d *Daemon, snap liveSnapshot, snapErr error, opts LiveOptions) { + fmt.Fprint(w, "\033[H\033[2J") + fmt.Fprintf(w, "AdGuard Shield Live | %s | Strg+C beendet\n", snap.At.Format("2006-01-02 15:04:05")) + fmt.Fprintln(w, strings.Repeat("=", 92)) + fmt.Fprintf(w, "Config: %s | API: %s | Log: %s (ab %s)\n", d.Config.Path, d.Config.AdGuardURL, d.Config.LogFile, strings.ToUpper(opts.LogLevel)) + if snapErr != nil { + fmt.Fprintf(w, "\nFEHLER: Live-Snapshot konnte nicht geladen werden: %v\n", snapErr) + return + } + + fmt.Fprintf(w, "\nWorker und Module\n") + fmt.Fprintf(w, " Query-Poller: alle %ds | API-Eintraege: %d | Zeitfenster: %ds | Limit: %d\n", d.Config.CheckInterval, snap.APIEntries, snap.Window, snap.Limit) + fmt.Fprintf(w, " GeoIP: %s | Modus: %s | Laender: %s\n", enabled(d.Config.GeoIPEnabled), d.Config.GeoIPMode, listOrDash(d.Config.GeoIPCountries)) + fmt.Fprintf(w, " Externe Blocklist: %s | Intervall: %ds | URLs: %d | aktive Sperren: %d\n", enabled(d.Config.ExternalBlocklistEnabled), d.Config.ExternalBlocklistInterval, len(d.Config.ExternalBlocklistURLs), snap.BlocklistBans) + fmt.Fprintf(w, " Externe Whitelist: %s | Intervall: %ds | URLs: %d | aufgeloeste IPs: %d\n", enabled(d.Config.ExternalWhitelistEnabled), d.Config.ExternalWhitelistInterval, len(d.Config.ExternalWhitelistURLs), snap.WhitelistCount) + fmt.Fprintf(w, " Offense-Cleanup: %s | Zaehler: %d | davon abgelaufen: %d\n", enabled(d.Config.ProgressiveBanEnabled), snap.Offenses, snap.ExpiredOffenses) + + fmt.Fprintf(w, "\nTop Client/Domain im Rate-Limit-Fenster\n") + if len(snap.TopPairs) == 0 { + fmt.Fprintln(w, " Keine Anfragen im aktuellen Zeitfenster.") + } else { + for _, item := range snap.TopPairs { + fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, snap.Limit), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol) + } + } + + if d.Config.SubdomainFloodEnabled { + fmt.Fprintf(w, "\nSubdomain-Flood-Kandidaten\n") + if len(snap.SubdomainGroups) == 0 { + fmt.Fprintln(w, " Keine Subdomain-Gruppen im aktuellen Zeitfenster.") + } else { + for _, item := range snap.SubdomainGroups { + fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, d.Config.SubdomainFloodMaxUnique), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol) + } + } + } + + fmt.Fprintf(w, "\nLetzte Queries\n") + if len(snap.Events) == 0 { + fmt.Fprintln(w, " Keine Querylog-Eintraege gefunden.") + } else { + for _, ev := range snap.Events { + fmt.Fprintf(w, " %s %-39s %-8s %s\n", ev.At.Local().Format("15:04:05"), trim(ev.Client, 39), formatProtocol(ev.Protocol), trim(ev.Domain, 44)) + } + } + + fmt.Fprintf(w, "\nAktive Sperren\n") + if len(snap.ActiveBans) == 0 { + fmt.Fprintln(w, " Keine aktiven Sperren.") + } else { + maxBans := opts.Top + if len(snap.ActiveBans) < maxBans { + maxBans = len(snap.ActiveBans) + } + for _, b := range snap.ActiveBans[:maxBans] { + fmt.Fprintf(w, " %-39s %-20s %-18s %s\n", trim(b.IP, 39), trim(b.Source, 20), trim(b.Reason, 18), banUntil(b)) + } + if len(snap.ActiveBans) > maxBans { + fmt.Fprintf(w, " ... %d weitere\n", len(snap.ActiveBans)-maxBans) + } + } + + if strings.ToLower(opts.LogLevel) != "off" { + fmt.Fprintf(w, "\nSystemereignisse\n") + if len(snap.SystemLogs) == 0 { + fmt.Fprintln(w, " Keine passenden Logeintraege.") + } else { + for _, line := range snap.SystemLogs { + fmt.Fprintf(w, " %s\n", trim(line, 88)) + } + } + } +} + +func RecentLogLines(path, minLevel string, limit int) []string { + if strings.EqualFold(strings.TrimSpace(minLevel), "off") || path == "" || limit <= 0 { + return nil + } + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + min := syslog.ParseLevel(minLevel, syslog.Info) + ring := make([]string, limit) + count := 0 + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + if logLineLevel(line) < min { + continue + } + ring[count%limit] = line + count++ + } + n := count + if n > limit { + n = limit + } + out := make([]string, 0, n) + start := count - n + for i := 0; i < n; i++ { + out = append(out, ring[(start+i)%limit]) + } + return out +} + +func logLineLevel(line string) syslog.Level { + for _, level := range []syslog.Level{syslog.Error, syslog.Warn, syslog.Info, syslog.Debug} { + if strings.Contains(line, "["+syslog.LevelName(level)+"]") { + return level + } + } + return syslog.Info +} + +func sortedKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + if k != "" { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +func formatProtocol(proto string) string { + switch strings.ToLower(strings.TrimSpace(proto)) { + case "doh": + return "DoH" + case "dot": + return "DoT" + case "doq": + return "DoQ" + case "dnscrypt": + return "DNSCrypt" + case "", "dns": + return "DNS" + default: + return proto + } +} + +func enabled(ok bool) string { + if ok { + return "aktiv" + } + return "inaktiv" +} + +func listOrDash(items []string) string { + if len(items) == 0 { + return "-" + } + return strings.Join(items, ",") +} + +func trim(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return s[:max] + } + return s[:max-1] + "~" +} + +func banUntil(b db.Ban) string { + if b.Permanent || b.BanUntil == 0 { + return "permanent" + } + return time.Unix(b.BanUntil, 0).Format("2006-01-02 15:04:05") +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..0358215 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,408 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct{ DB *sql.DB } + +type Ban struct { + IP string + Domain string + Count int + BanUntil int64 + Duration int64 + OffenseLevel int + Permanent bool + Reason string + Protocol string + Source string + GeoIPCountry string + GeoIPMode string +} + +type ReportStats struct { + Since int64 + Until int64 + TotalBans int + TotalUnbans int + ActiveBans int + TopClients []ReportCount + Reasons []ReportCount + Sources []ReportCount + RecentEvents []string +} + +type ReportCount struct { + Name string + Count int +} + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") + if err != nil { + return nil, err + } + s := &Store{DB: db} + if err := s.Init(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.DB.Close() } + +func (s *Store) Init() error { + schema := ` +PRAGMA journal_mode=WAL; +PRAGMA busy_timeout=5000; +CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now', 'localtime'))); +CREATE TABLE IF NOT EXISTS active_bans ( + client_ip TEXT PRIMARY KEY, domain TEXT, count INTEGER, ban_time TEXT, + ban_until_epoch INTEGER DEFAULT 0, ban_duration INTEGER DEFAULT 0, offense_level INTEGER DEFAULT 0, + is_permanent INTEGER DEFAULT 0, reason TEXT DEFAULT 'rate-limit', protocol TEXT DEFAULT 'DNS', + source TEXT DEFAULT 'monitor', geoip_country TEXT, geoip_mode TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime'))); +CREATE TABLE IF NOT EXISTS offense_tracking ( + client_ip TEXT PRIMARY KEY, offense_level INTEGER DEFAULT 0, last_offense_epoch INTEGER, + last_offense TEXT, first_offense TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime'))); +CREATE TABLE IF NOT EXISTS ban_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp_epoch INTEGER NOT NULL, timestamp_text TEXT NOT NULL, + action TEXT NOT NULL, client_ip TEXT NOT NULL, domain TEXT, count TEXT, duration TEXT, protocol TEXT, reason TEXT); +CREATE TABLE IF NOT EXISTS whitelist_cache (ip_address TEXT PRIMARY KEY, source TEXT, resolved_at TEXT DEFAULT (datetime('now', 'localtime'))); +CREATE TABLE IF NOT EXISTS geoip_cache (ip TEXT PRIMARY KEY, country_code TEXT NOT NULL, looked_up_at_epoch INTEGER NOT NULL, db_mtime INTEGER DEFAULT 0); +CREATE INDEX IF NOT EXISTS idx_bans_until ON active_bans(ban_until_epoch); +CREATE INDEX IF NOT EXISTS idx_bans_source ON active_bans(source); +CREATE INDEX IF NOT EXISTS idx_bans_reason ON active_bans(reason); +CREATE INDEX IF NOT EXISTS idx_history_timestamp ON ban_history(timestamp_epoch); +CREATE INDEX IF NOT EXISTS idx_history_action ON ban_history(action); +CREATE INDEX IF NOT EXISTS idx_history_ip ON ban_history(client_ip); +CREATE INDEX IF NOT EXISTS idx_offenses_last ON offense_tracking(last_offense_epoch); +CREATE INDEX IF NOT EXISTS idx_geoip_cache_age ON geoip_cache(looked_up_at_epoch); +INSERT OR IGNORE INTO schema_version (version) VALUES (1);` + _, err := s.DB.Exec(schema) + return err +} + +func (s *Store) BanExists(ip string) (bool, error) { + var one int + err := s.DB.QueryRow(`SELECT 1 FROM active_bans WHERE client_ip=? LIMIT 1`, ip).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (s *Store) InsertBan(b Ban) error { + now := time.Now() + perm := 0 + if b.Permanent { + perm = 1 + } + _, err := s.DB.Exec(`INSERT OR REPLACE INTO active_bans +(client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + b.IP, b.Domain, b.Count, now.Format("2006-01-02 15:04:05"), b.BanUntil, b.Duration, b.OffenseLevel, perm, + b.Reason, b.Protocol, b.Source, b.GeoIPCountry, b.GeoIPMode) + return err +} + +func (s *Store) DeleteBan(ip string) error { + _, err := s.DB.Exec(`DELETE FROM active_bans WHERE client_ip=?`, ip) + return err +} + +func (s *Store) ActiveBans() ([]Ban, error) { + rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0), +COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''), +COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ban + for rows.Next() { + var b Ban + var perm int + if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil { + return nil, err + } + b.Permanent = perm == 1 + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) BansBySource(source string) ([]Ban, error) { + rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0), +COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''), +COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE source=?`, source) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ban + for rows.Next() { + var b Ban + var perm int + if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil { + return nil, err + } + b.Permanent = perm == 1 + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) BansByReason(reason string) ([]Ban, error) { + rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0), +COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''), +COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE reason=?`, reason) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Ban + for rows.Next() { + var b Ban + var perm int + if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil { + return nil, err + } + b.Permanent = perm == 1 + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) CountBySource(source string) (int, error) { + var count int + err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans WHERE source=?`, source).Scan(&count) + return count, err +} + +func (s *Store) ExpiredBans(now int64) ([]string, error) { + rows, err := s.DB.Query(`SELECT client_ip FROM active_bans WHERE ban_until_epoch > 0 AND is_permanent = 0 AND ban_until_epoch <= ?`, now) + if err != nil { + return nil, err + } + defer rows.Close() + var ips []string + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err != nil { + return nil, err + } + ips = append(ips, ip) + } + return ips, rows.Err() +} + +func (s *Store) History(action, ip, domain, count, duration, protocol, reason string) error { + now := time.Now() + _, err := s.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, now.Unix(), now.Format("2006-01-02 15:04:05"), action, ip, domain, count, duration, protocol, reason) + return err +} + +func (s *Store) RecentHistory(limit int) ([]string, error) { + rows, err := s.DB.Query(`SELECT timestamp_text, action, client_ip, COALESCE(domain,''), COALESCE(count,''), COALESCE(duration,''), COALESCE(protocol,''), COALESCE(reason,'') +FROM ban_history ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var ts, action, ip, domain, count, duration, proto, reason string + if err := rows.Scan(&ts, &action, &ip, &domain, &count, &duration, &proto, &reason); err != nil { + return nil, err + } + out = append(out, fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s | %s", ts, action, ip, domain, count, duration, proto, reason)) + } + return out, rows.Err() +} + +func (s *Store) WhitelistContains(ip string) (bool, error) { + var one int + err := s.DB.QueryRow(`SELECT 1 FROM whitelist_cache WHERE ip_address=? LIMIT 1`, ip).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +func (s *Store) ReplaceWhitelist(ips []string, source string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM whitelist_cache WHERE source=? OR source IS NULL`, source); err != nil { + return err + } + stmt, err := tx.Prepare(`INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES (?, ?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, ip := range ips { + if _, err := stmt.Exec(ip, source); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) AllWhitelist() (map[string]bool, error) { + rows, err := s.DB.Query(`SELECT ip_address FROM whitelist_cache`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var ip string + if err := rows.Scan(&ip); err != nil { + return nil, err + } + out[ip] = true + } + return out, rows.Err() +} + +func (s *Store) IncrementOffense(ip string, resetAfter int64) (int, error) { + now := time.Now() + var level int + var last int64 + var first string + err := s.DB.QueryRow(`SELECT offense_level, COALESCE(last_offense_epoch,0), COALESCE(first_offense,'') FROM offense_tracking WHERE client_ip=?`, ip).Scan(&level, &last, &first) + if err != nil && err != sql.ErrNoRows { + return 0, err + } + if err == sql.ErrNoRows || (last > 0 && now.Unix()-last > resetAfter) { + level = 0 + first = now.Format("2006-01-02 15:04:05") + } + level++ + _, err = s.DB.Exec(`INSERT OR REPLACE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense, updated_at) +VALUES (?, ?, ?, ?, ?, ?)`, ip, level, now.Unix(), now.Format("2006-01-02 15:04:05"), first, now.Format("2006-01-02 15:04:05")) + return level, err +} + +func (s *Store) ResetOffense(ip string) error { + if ip == "" { + _, err := s.DB.Exec(`DELETE FROM offense_tracking`) + return err + } + _, err := s.DB.Exec(`DELETE FROM offense_tracking WHERE client_ip=?`, ip) + return err +} + +func (s *Store) CleanupOffenses(resetAfter int64) (int64, error) { + cutoff := time.Now().Unix() - resetAfter + res, err := s.DB.Exec(`DELETE FROM offense_tracking WHERE last_offense_epoch <= ?`, cutoff) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *Store) CountOffenses() (int, error) { + var count int + err := s.DB.QueryRow(`SELECT COUNT(*) FROM offense_tracking`).Scan(&count) + return count, err +} + +func (s *Store) CountExpiredOffenses(resetAfter int64) (int, error) { + var count int + cutoff := time.Now().Unix() - resetAfter + err := s.DB.QueryRow(`SELECT COUNT(*) FROM offense_tracking WHERE last_offense_epoch <= ?`, cutoff).Scan(&count) + return count, err +} + +func (s *Store) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) { + rows, err := s.DB.Query(`SELECT ip, country_code FROM geoip_cache WHERE looked_up_at_epoch >= ? AND (db_mtime=? OR db_mtime=0)`, time.Now().Unix()-ttl, dbMtime) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var ip, cc string + if err := rows.Scan(&ip, &cc); err != nil { + return nil, err + } + out[ip] = cc + } + return out, rows.Err() +} + +func (s *Store) UpsertGeoIP(ip, country string, dbMtime int64) error { + _, err := s.DB.Exec(`INSERT OR REPLACE INTO geoip_cache (ip, country_code, looked_up_at_epoch, db_mtime) VALUES (?, ?, ?, ?)`, ip, country, time.Now().Unix(), dbMtime) + return err +} + +func (s *Store) ClearGeoIPCache() (int64, error) { + res, err := s.DB.Exec(`DELETE FROM geoip_cache`) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *Store) ReportStats(since, until int64, limit int) (ReportStats, error) { + st := ReportStats{Since: since, Until: until} + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalBans); err != nil { + return st, err + } + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='UNBAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalUnbans); err != nil { + return st, err + } + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&st.ActiveBans); err != nil { + return st, err + } + var err error + st.TopClients, err = s.reportCounts(`SELECT client_ip, COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY client_ip ORDER BY COUNT(*) DESC, client_ip LIMIT ?`, since, until, limit) + if err != nil { + return st, err + } + st.Reasons, err = s.reportCounts(`SELECT COALESCE(NULLIF(reason,''), 'unknown'), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY COALESCE(NULLIF(reason,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, since, until, limit) + if err != nil { + return st, err + } + st.Sources, err = s.reportCounts(`SELECT COALESCE(NULLIF(source,''), 'unknown'), COUNT(*) FROM active_bans GROUP BY COALESCE(NULLIF(source,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, 0, 0, limit) + if err != nil { + return st, err + } + st.RecentEvents, err = s.RecentHistory(limit) + return st, err +} + +func (s *Store) reportCounts(query string, since, until int64, limit int) ([]ReportCount, error) { + var rows *sql.Rows + var err error + if since == 0 && until == 0 { + rows, err = s.DB.Query(query, limit) + } else { + rows, err = s.DB.Query(query, since, until, limit) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []ReportCount + for rows.Next() { + var item ReportCount + if err := rows.Scan(&item.Name, &item.Count); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..d808a62 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,31 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestStoreBanAndGeoIPCache(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.InsertBan(Ban{IP: "1.2.3.4", Domain: "example.com", Permanent: true, Reason: "geoip", Source: "geoip", GeoIPCountry: "CN"}); err != nil { + t.Fatal(err) + } + ok, err := s.BanExists("1.2.3.4") + if err != nil || !ok { + t.Fatalf("ban not found: %v %v", ok, err) + } + if err := s.UpsertGeoIP("1.2.3.4", "CN", 123); err != nil { + t.Fatal(err) + } + cache, err := s.LoadGeoIPCache(86400, 123) + if err != nil { + t.Fatal(err) + } + if cache["1.2.3.4"] != "CN" { + t.Fatalf("unexpected cache: %#v", cache) + } +} diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go new file mode 100644 index 0000000..ed26cba --- /dev/null +++ b/internal/firewall/firewall.go @@ -0,0 +1,203 @@ +package firewall + +import ( + "context" + "fmt" + "net/netip" + "os/exec" + "strconv" + "strings" +) + +type Executor interface { + Run(ctx context.Context, name string, args ...string) error +} + +type OSExecutor struct{} + +func (OSExecutor) Run(ctx context.Context, name string, args ...string) error { + return exec.CommandContext(ctx, name, args...).Run() +} + +type Firewall struct { + Exec Executor + Chain string + Ports []string + Mode string + DryRun bool + Set4 string + Set6 string +} + +func New(exec Executor, chain string, ports []string, mode string, dry bool) *Firewall { + return &Firewall{Exec: exec, Chain: chain, Ports: ports, Mode: normalizeMode(mode), DryRun: dry, Set4: "adguard_shield_v4", Set6: "adguard_shield_v6"} +} + +func (f *Firewall) Setup(ctx context.Context) error { + if f.DryRun { + return nil + } + if len(f.hooks("iptables")) == 0 { + return fmt.Errorf("unsupported firewall mode %q", f.Mode) + } + _ = f.Exec.Run(ctx, "ipset", "create", f.Set4, "hash:net", "family", "inet", "timeout", "0", "-exist") + _ = f.Exec.Run(ctx, "ipset", "create", f.Set6, "hash:net", "family", "inet6", "timeout", "0", "-exist") + _ = f.Exec.Run(ctx, "iptables", "-N", f.Chain) + _ = f.Exec.Run(ctx, "ip6tables", "-N", f.Chain) + if err := ensureSetDrop(ctx, f.Exec, "iptables", f.Chain, f.Set4); err != nil { + return err + } + if err := ensureSetDrop(ctx, f.Exec, "ip6tables", f.Chain, f.Set6); err != nil { + return err + } + if err := f.ensureHooks(ctx, "iptables"); err != nil { + return err + } + if err := f.ensureHooks(ctx, "ip6tables"); err != nil { + return err + } + return nil +} + +func ensureRule(ctx context.Context, ex Executor, bin string, args ...string) bool { + return ex.Run(ctx, bin, args...) == nil +} + +func ensureSetDrop(ctx context.Context, ex Executor, bin, chain, set string) error { + check := []string{"-C", chain, "-m", "set", "--match-set", set, "src", "-j", "DROP"} + if ex.Run(ctx, bin, check...) == nil { + return nil + } + return ex.Run(ctx, bin, "-I", chain, "-m", "set", "--match-set", set, "src", "-j", "DROP") +} + +type hook struct { + Chain string + OptionalMissing bool +} + +func normalizeMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "host", "classic", "native", "docker-host": + return "host" + case "docker", "docker-bridge", "docker-published", "published": + return "docker-bridge" + case "hybrid", "both": + return "hybrid" + default: + return strings.ToLower(strings.TrimSpace(mode)) + } +} + +func (f *Firewall) hooks(bin string) []hook { + docker := hook{Chain: "DOCKER-USER", OptionalMissing: bin == "ip6tables"} + switch f.Mode { + case "host": + return []hook{{Chain: "INPUT"}} + case "docker-bridge": + return []hook{docker} + case "hybrid": + return []hook{{Chain: "INPUT"}, docker} + default: + return nil + } +} + +func (f *Firewall) ensureHooks(ctx context.Context, bin string) error { + for _, h := range f.hooks(bin) { + if !chainExists(ctx, f.Exec, bin, h.Chain) { + if h.OptionalMissing { + continue + } + return fmt.Errorf("%s chain %s not found", bin, h.Chain) + } + for _, p := range f.Ports { + for _, proto := range []string{"tcp", "udp"} { + check := []string{"-C", h.Chain, "-p", proto, "--dport", p, "-j", f.Chain} + if ensureRule(ctx, f.Exec, bin, check...) { + continue + } + _ = f.Exec.Run(ctx, bin, "-I", h.Chain, "-p", proto, "--dport", p, "-j", f.Chain) + } + } + } + return nil +} + +func chainExists(ctx context.Context, ex Executor, bin, chain string) bool { + return ex.Run(ctx, bin, "-n", "-L", chain) == nil +} + +func (f *Firewall) Add(ctx context.Context, ip string, timeout int64) error { + if f.DryRun { + return nil + } + set, err := f.setFor(ip) + if err != nil { + return err + } + args := []string{"add", set, ip, "-exist"} + if timeout > 0 { + args = append(args, "timeout", strconv.FormatInt(timeout, 10)) + } + return f.Exec.Run(ctx, "ipset", args...) +} + +func (f *Firewall) Del(ctx context.Context, ip string) error { + if f.DryRun { + return nil + } + set, err := f.setFor(ip) + if err != nil { + return err + } + _ = f.Exec.Run(ctx, "ipset", "del", set, ip) + return nil +} + +func (f *Firewall) Flush(ctx context.Context) error { + if f.DryRun { + return nil + } + _ = f.Exec.Run(ctx, "ipset", "flush", f.Set4) + _ = f.Exec.Run(ctx, "ipset", "flush", f.Set6) + return nil +} + +func (f *Firewall) Remove(ctx context.Context) error { + if f.DryRun { + return nil + } + for _, p := range f.Ports { + for _, proto := range []string{"tcp", "udp"} { + for _, parent := range []string{"INPUT", "DOCKER-USER"} { + _ = f.Exec.Run(ctx, "iptables", "-D", parent, "-p", proto, "--dport", p, "-j", f.Chain) + _ = f.Exec.Run(ctx, "ip6tables", "-D", parent, "-p", proto, "--dport", p, "-j", f.Chain) + } + } + } + _ = f.Exec.Run(ctx, "iptables", "-F", f.Chain) + _ = f.Exec.Run(ctx, "ip6tables", "-F", f.Chain) + _ = f.Exec.Run(ctx, "iptables", "-X", f.Chain) + _ = f.Exec.Run(ctx, "ip6tables", "-X", f.Chain) + _ = f.Exec.Run(ctx, "ipset", "destroy", f.Set4) + _ = f.Exec.Run(ctx, "ipset", "destroy", f.Set6) + return nil +} + +func (f *Firewall) setFor(s string) (string, error) { + if p, err := netip.ParsePrefix(s); err == nil { + if p.Addr().Is4() { + return f.Set4, nil + } + return f.Set6, nil + } + a, err := netip.ParseAddr(s) + if err != nil { + return "", fmt.Errorf("invalid IP/prefix %q", s) + } + if a.Is4() { + return f.Set4, nil + } + return f.Set6, nil +} diff --git a/internal/firewall/firewall_test.go b/internal/firewall/firewall_test.go new file mode 100644 index 0000000..5e20215 --- /dev/null +++ b/internal/firewall/firewall_test.go @@ -0,0 +1,142 @@ +package firewall + +import ( + "context" + "strings" + "testing" +) + +type fakeExec struct { + calls []string + failChecks bool + missing map[string]bool +} + +func (f *fakeExec) Run(_ context.Context, name string, args ...string) error { + call := name + " " + strings.Join(args, " ") + f.calls = append(f.calls, call) + if f.missing != nil && f.missing[call] { + return errFake + } + if f.failChecks && len(args) > 0 && args[0] == "-C" { + return errFake + } + return nil +} + +type fakeErr string + +func (e fakeErr) Error() string { return string(e) } + +var errFake = fakeErr("missing") + +func TestFirewallSetupCreatesSetsAndRules(t *testing.T) { + ex := &fakeExec{failChecks: true} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false) + if err := fw.Setup(context.Background()); err != nil { + t.Fatal(err) + } + joined := strings.Join(ex.calls, "\n") + for _, want := range []string{ + "ipset create adguard_shield_v4 hash:net family inet timeout 0 -exist", + "iptables -I INPUT -p tcp --dport 53 -j ADGUARD_SHIELD", + "iptables -I ADGUARD_SHIELD -m set --match-set adguard_shield_v4 src -j DROP", + "ip6tables -I ADGUARD_SHIELD -m set --match-set adguard_shield_v6 src -j DROP", + } { + if !strings.Contains(joined, want) { + t.Fatalf("missing call %q in:\n%s", want, joined) + } + } +} + +func TestFirewallSetupUsesDockerUserForBridgeMode(t *testing.T) { + ex := &fakeExec{failChecks: true} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false) + if err := fw.Setup(context.Background()); err != nil { + t.Fatal(err) + } + joined := strings.Join(ex.calls, "\n") + if !strings.Contains(joined, "iptables -I DOCKER-USER -p udp --dport 53 -j ADGUARD_SHIELD") { + t.Fatalf("missing docker hook in:\n%s", joined) + } + if strings.Contains(joined, "iptables -I INPUT -p udp --dport 53 -j ADGUARD_SHIELD") { + t.Fatalf("unexpected INPUT hook in docker-bridge mode:\n%s", joined) + } +} + +func TestFirewallSetupHybridUsesInputAndDockerUser(t *testing.T) { + ex := &fakeExec{failChecks: true} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "hybrid", false) + if err := fw.Setup(context.Background()); err != nil { + t.Fatal(err) + } + joined := strings.Join(ex.calls, "\n") + for _, want := range []string{ + "iptables -I INPUT -p tcp --dport 53 -j ADGUARD_SHIELD", + "iptables -I DOCKER-USER -p tcp --dport 53 -j ADGUARD_SHIELD", + } { + if !strings.Contains(joined, want) { + t.Fatalf("missing call %q in:\n%s", want, joined) + } + } +} + +func TestFirewallSetupRequiresDockerUserForIPv4BridgeMode(t *testing.T) { + ex := &fakeExec{missing: map[string]bool{"iptables -n -L DOCKER-USER": true}} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false) + if err := fw.Setup(context.Background()); err == nil || !strings.Contains(err.Error(), "DOCKER-USER") { + t.Fatalf("expected DOCKER-USER error, got %v", err) + } +} + +func TestFirewallSetupSkipsMissingIPv6DockerUser(t *testing.T) { + ex := &fakeExec{ + failChecks: true, + missing: map[string]bool{"ip6tables -n -L DOCKER-USER": true}, + } + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false) + if err := fw.Setup(context.Background()); err != nil { + t.Fatal(err) + } + joined := strings.Join(ex.calls, "\n") + if strings.Contains(joined, "ip6tables -I DOCKER-USER") { + t.Fatalf("unexpected IPv6 docker hook with missing DOCKER-USER:\n%s", joined) + } +} + +func TestFirewallSetupRejectsUnknownMode(t *testing.T) { + fw := New(&fakeExec{}, "ADGUARD_SHIELD", []string{"53"}, "surprise", false) + err := fw.Setup(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unsupported firewall mode") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFirewallAddChoosesFamily(t *testing.T) { + ex := &fakeExec{} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false) + if err := fw.Add(context.Background(), "2001:db8::1", 30); err != nil { + t.Fatal(err) + } + got := strings.Join(ex.calls, "\n") + if !strings.Contains(got, "ipset add adguard_shield_v6 2001:db8::1 -exist timeout 30") { + t.Fatalf("unexpected calls:\n%s", got) + } +} + +func TestFirewallRemoveDeletesAllKnownHooks(t *testing.T) { + ex := &fakeExec{} + fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false) + if err := fw.Remove(context.Background()); err != nil { + t.Fatal(err) + } + joined := strings.Join(ex.calls, "\n") + for _, want := range []string{ + "iptables -D INPUT -p tcp --dport 53 -j ADGUARD_SHIELD", + "iptables -D DOCKER-USER -p tcp --dport 53 -j ADGUARD_SHIELD", + } { + if !strings.Contains(joined, want) { + t.Fatalf("missing cleanup call %q in:\n%s", want, joined) + } + } +} diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go new file mode 100644 index 0000000..6a1e1f3 --- /dev/null +++ b/internal/geoip/geoip.go @@ -0,0 +1,245 @@ +package geoip + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/oschwald/maxminddb-golang" +) + +type Store interface { + LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) + UpsertGeoIP(ip, country string, dbMtime int64) error +} + +type Resolver struct { + DBPath string + effectivePath string + LicenseKey string + Dir string + TTL int64 + Store Store + reader *maxminddb.Reader + cache map[string]string + mtime int64 +} + +func New(dbPath, licenseKey, dir string, ttl int64, store Store) *Resolver { + return &Resolver{DBPath: dbPath, LicenseKey: licenseKey, Dir: dir, TTL: ttl, Store: store, cache: map[string]string{}} +} + +func (r *Resolver) Open(ctx context.Context) error { + path := r.DBPath + if path == "" && r.LicenseKey != "" { + var err error + path, err = r.ensureAutoDB(ctx) + if err != nil { + return err + } + } + if path == "" { + return nil + } + r.effectivePath = path + st, err := os.Stat(path) + if err != nil { + return err + } + reader, err := maxminddb.Open(path) + if err != nil { + return err + } + r.reader = reader + r.mtime = st.ModTime().Unix() + if r.Store != nil { + if c, err := r.Store.LoadGeoIPCache(r.TTL, r.mtime); err == nil { + r.cache = c + } + } + return nil +} + +func (r *Resolver) Close() error { + if r.reader != nil { + return r.reader.Close() + } + return nil +} + +func (r *Resolver) Lookup(ip string) (string, error) { + if v, ok := r.cache[ip]; ok { + return v, nil + } + if r.reader == nil { + return r.lookupLegacy(ip) + } + parsed := net.ParseIP(ip) + if parsed == nil { + return "", fmt.Errorf("invalid IP %q", ip) + } + var rec struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + RegisteredCountry struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"registered_country"` + } + if err := r.reader.Lookup(parsed, &rec); err != nil { + return "", err + } + cc := strings.ToUpper(rec.Country.ISOCode) + if cc == "" { + cc = strings.ToUpper(rec.RegisteredCountry.ISOCode) + } + if cc != "" { + r.cache[ip] = cc + if r.Store != nil { + _ = r.Store.UpsertGeoIP(ip, cc, r.mtime) + } + } + return cc, nil +} + +func (r *Resolver) lookupLegacy(ip string) (string, error) { + if strings.Contains(ip, ":") { + if cc, err := runGeoIPCommand("geoiplookup6", ip); err == nil && cc != "" { + return cc, nil + } + } else { + if cc, err := runGeoIPCommand("geoiplookup", ip); err == nil && cc != "" { + return cc, nil + } + } + if r.effectivePath != "" { + if cc, err := runGeoIPCommand("mmdblookup", "--file", r.effectivePath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" { + return cc, nil + } + } + return "", fmt.Errorf("no GeoIP result for %s", ip) +} + +func runGeoIPCommand(name string, args ...string) (string, error) { + if _, err := exec.LookPath(name); err != nil { + return "", err + } + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return "", err + } + re := regexp.MustCompile(`\b[A-Z]{2}\b`) + matches := re.FindAllString(string(out), -1) + for _, m := range matches { + if m != "IP" { + return strings.ToUpper(m), nil + } + } + return "", nil +} + +func ShouldBlock(country, mode string, countries []string) bool { + if country == "" || len(countries) == 0 { + return false + } + found := false + country = strings.ToUpper(country) + for _, c := range countries { + if strings.ToUpper(strings.TrimSpace(c)) == country { + found = true + break + } + } + if strings.ToLower(mode) == "allowlist" { + return !found + } + return found +} + +func IsPrivateIP(s string) bool { + if p, err := netip.ParsePrefix(s); err == nil { + return isPrivateAddr(p.Addr()) + } + a, err := netip.ParseAddr(s) + if err != nil { + return false + } + return isPrivateAddr(a) +} + +func isPrivateAddr(a netip.Addr) bool { + return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || + (a.Is4() && strings.HasPrefix(a.String(), "100.") && isCGNAT(a)) +} + +func isCGNAT(a netip.Addr) bool { + p := a.As4() + return p[0] == 100 && p[1] >= 64 && p[1] <= 127 +} + +func (r *Resolver) ensureAutoDB(ctx context.Context) (string, error) { + if err := os.MkdirAll(r.Dir, 0755); err != nil { + return "", err + } + dst := filepath.Join(r.Dir, "GeoLite2-Country.mmdb") + if st, err := os.Stat(dst); err == nil && time.Since(st.ModTime()) < 24*time.Hour { + return dst, nil + } + url := "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=" + r.LicenseKey + "&suffix=tar.gz" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("MaxMind download failed: HTTP %d", resp.StatusCode) + } + gzr, err := gzip.NewReader(resp.Body) + if err != nil { + return "", err + } + defer gzr.Close() + tr := tar.NewReader(gzr) + tmp := dst + ".tmp" + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + if h.FileInfo().IsDir() || filepath.Base(h.Name) != "GeoLite2-Country.mmdb" { + continue + } + f, err := os.Create(tmp) + if err != nil { + return "", err + } + _, copyErr := io.Copy(f, tr) + closeErr := f.Close() + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + return dst, os.Rename(tmp, dst) + } + return "", fmt.Errorf("GeoLite2-Country.mmdb not found in archive") +} diff --git a/internal/geoip/geoip_test.go b/internal/geoip/geoip_test.go new file mode 100644 index 0000000..8ae40b2 --- /dev/null +++ b/internal/geoip/geoip_test.go @@ -0,0 +1,30 @@ +package geoip + +import "testing" + +func TestShouldBlockModes(t *testing.T) { + countries := []string{"CN", "RU"} + if !ShouldBlock("cn", "blocklist", countries) { + t.Fatal("blocklist should block listed country") + } + if ShouldBlock("DE", "blocklist", countries) { + t.Fatal("blocklist should allow unlisted country") + } + if ShouldBlock("CN", "allowlist", countries) { + t.Fatal("allowlist should allow listed country") + } + if !ShouldBlock("DE", "allowlist", countries) { + t.Fatal("allowlist should block unlisted country") + } +} + +func TestIsPrivateIP(t *testing.T) { + for _, ip := range []string{"127.0.0.1", "192.168.1.10", "10.1.2.3", "100.64.0.1", "::1", "fd00::1"} { + if !IsPrivateIP(ip) { + t.Fatalf("%s should be private", ip) + } + } + if IsPrivateIP("8.8.8.8") { + t.Fatal("8.8.8.8 should be public") + } +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go new file mode 100644 index 0000000..13b0c05 --- /dev/null +++ b/internal/installer/installer.go @@ -0,0 +1,642 @@ +package installer + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + DefaultInstallDir = "/opt/adguard-shield" + DefaultStateDir = "/var/lib/adguard-shield" + DefaultLogFile = "/var/log/adguard-shield.log" + ServiceName = "adguard-shield.service" + ServicePath = "/etc/systemd/system/adguard-shield.service" +) + +type Options struct { + InstallDir string + ConfigSource string + Enable bool + SkipDeps bool + KeepConfig bool +} + +type Status struct { + InstallDir string + BinaryPath string + ConfigPath string + BinaryExists bool + ConfigExists bool + ServiceExists bool + ServiceEnabled bool + ServiceActive bool + Version string + LegacyFindings []string +} + +type LegacyError struct { + Findings []string +} + +func (e *LegacyError) Error() string { + return "scriptbasierte AdGuard-Shield-Installation gefunden" +} + +func DefaultOptions() Options { + return Options{InstallDir: DefaultInstallDir, Enable: true} +} + +func Install(opts Options) error { + opts = normalize(opts) + fmt.Println("AdGuard Shield Go-Installation") + fmt.Printf("Installationspfad: %s\n", opts.InstallDir) + fmt.Println("1/8 Pruefe Betriebssystem und root-Rechte ...") + if err := requireLinuxRoot(); err != nil { + return err + } + fmt.Println("2/8 Pruefe auf scriptbasierte Altinstallation ...") + if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 { + return &LegacyError{Findings: findings} + } + if !opts.SkipDeps { + fmt.Println("3/8 Pruefe System-Abhaengigkeiten ...") + if err := ensureDependencies(); err != nil { + return err + } + } else { + fmt.Println("3/8 System-Abhaengigkeiten uebersprungen (--skip-deps)") + } + fmt.Println("4/8 Erstelle Verzeichnisse ...") + if err := os.MkdirAll(opts.InstallDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(DefaultStateDir, 0755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(opts.InstallDir, "geoip"), 0755); err != nil { + return err + } + fmt.Println("5/8 Installiere Binary ...") + if err := copySelf(filepath.Join(opts.InstallDir, "adguard-shield")); err != nil { + return err + } + fmt.Println("6/8 Installiere oder migriere Konfiguration ...") + if err := ensureConfig(opts); err != nil { + return err + } + fmt.Println("7/8 Schreibe systemd-Service ...") + if err := writeService(opts.InstallDir); err != nil { + return err + } + fmt.Println("8/8 Aktualisiere systemd ...") + _ = run("systemctl", "daemon-reload") + if opts.Enable { + fmt.Println("Aktiviere Autostart ...") + if err := run("systemctl", "enable", ServiceName); err != nil { + return err + } + } + if askStartService() { + fmt.Println("Starte Service neu ...") + if err := run("systemctl", "restart", ServiceName); err != nil { + return err + } + } + fmt.Println("Installation fertig.") + return nil +} + +func Update(opts Options) error { + opts = normalize(opts) + if err := requireLinuxRoot(); err != nil { + return err + } + if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 { + return &LegacyError{Findings: findings} + } + return Install(opts) +} + +func Uninstall(opts Options) error { + opts = normalize(opts) + if err := requireLinuxRoot(); err != nil { + return err + } + _ = run("systemctl", "stop", ServiceName) + _ = run("systemctl", "disable", ServiceName) + if _, err := os.Stat(filepath.Join(opts.InstallDir, "adguard-shield")); err == nil { + _ = run(filepath.Join(opts.InstallDir, "adguard-shield"), "-config", filepath.Join(opts.InstallDir, "adguard-shield.conf"), "firewall-remove") + } + _ = os.Remove(ServicePath) + _ = run("systemctl", "daemon-reload") + if opts.KeepConfig { + for _, p := range []string{ + filepath.Join(opts.InstallDir, "adguard-shield"), + filepath.Join(opts.InstallDir, "adguard-shield.conf.old"), + } { + _ = os.Remove(p) + } + return nil + } + _ = os.RemoveAll(opts.InstallDir) + _ = os.RemoveAll(DefaultStateDir) + _ = os.Remove(DefaultLogFile) + return nil +} + +func GetStatus(installDir string) Status { + if installDir == "" { + installDir = DefaultInstallDir + } + bin := filepath.Join(installDir, "adguard-shield") + conf := filepath.Join(installDir, "adguard-shield.conf") + st := Status{ + InstallDir: installDir, + BinaryPath: bin, + ConfigPath: conf, + BinaryExists: fileExists(bin), + ConfigExists: fileExists(conf), + ServiceExists: fileExists(ServicePath), + LegacyFindings: DetectLegacy(installDir), + } + if st.BinaryExists { + if out, err := exec.Command(bin, "version").Output(); err == nil { + st.Version = strings.TrimSpace(string(out)) + } + } + st.ServiceEnabled = commandOK("systemctl", "is-enabled", "adguard-shield") + st.ServiceActive = commandOK("systemctl", "is-active", "adguard-shield") + return st +} + +func DetectLegacy(installDir string) []string { + if installDir == "" { + installDir = DefaultInstallDir + } + var findings []string + for _, p := range []string{ + "adguard-shield.sh", + "iptables-helper.sh", + "db.sh", + "external-blocklist-worker.sh", + "external-whitelist-worker.sh", + "geoip-worker.sh", + "offense-cleanup-worker.sh", + "report-generator.sh", + "unban-expired.sh", + "adguard-shield-watchdog.sh", + } { + full := filepath.Join(installDir, p) + if fileExists(full) { + findings = append(findings, full) + } + } + for _, p := range []string{ + "/etc/systemd/system/adguard-shield-watchdog.service", + "/etc/systemd/system/adguard-shield-watchdog.timer", + } { + if fileExists(p) { + findings = append(findings, p) + } + } + if b, err := os.ReadFile(ServicePath); err == nil { + s := string(b) + if strings.Contains(s, ".sh") || strings.Contains(s, "/bin/bash") || strings.Contains(s, "adguard-shield-watchdog") { + findings = append(findings, ServicePath+" verweist auf Shell/Watchdog") + } + } + sort.Strings(findings) + return findings +} + +func FormatLegacyMessage(err *LegacyError, installDir string) string { + if installDir == "" { + installDir = DefaultInstallDir + } + var b strings.Builder + b.WriteString("Die scriptbasierte Installation ist noch vorhanden und muss zuerst deinstalliert werden.\n\n") + b.WriteString("Gefunden:\n") + for _, f := range err.Findings { + b.WriteString(" - ") + b.WriteString(f) + b.WriteByte('\n') + } + b.WriteString("\nKonfiguration uebernehmen:\n") + b.WriteString(" 1. Backup behalten: ") + b.WriteString(filepath.Join(installDir, "adguard-shield.conf")) + b.WriteByte('\n') + b.WriteString(" 2. Alte Shell-Version mit deren uninstall.sh entfernen und die Konfiguration behalten.\n") + b.WriteString(" 3. Danach dieses Binary erneut ausfuehren: adguard-shield install\n") + return b.String() +} + +func PrintStatus(st Status) string { + var b strings.Builder + b.WriteString("AdGuard Shield Installationsstatus\n") + b.WriteString(fmt.Sprintf("Installationspfad: %s\n", st.InstallDir)) + b.WriteString(fmt.Sprintf("Binary: %s\n", yesNo(st.BinaryExists))) + if st.Version != "" { + b.WriteString(fmt.Sprintf("Version: %s\n", st.Version)) + } + b.WriteString(fmt.Sprintf("Konfiguration: %s\n", yesNo(st.ConfigExists))) + b.WriteString(fmt.Sprintf("systemd Service: %s\n", yesNo(st.ServiceExists))) + b.WriteString(fmt.Sprintf("Autostart: %s\n", yesNo(st.ServiceEnabled))) + b.WriteString(fmt.Sprintf("Service aktiv: %s\n", yesNo(st.ServiceActive))) + if len(st.LegacyFindings) > 0 { + b.WriteString("\nScriptbasierte Altinstallation/Altartefakte gefunden:\n") + for _, f := range st.LegacyFindings { + b.WriteString(" - ") + b.WriteString(f) + b.WriteByte('\n') + } + } + return b.String() +} + +func normalize(opts Options) Options { + if opts.InstallDir == "" { + opts.InstallDir = DefaultInstallDir + } + return opts +} + +func askStartService() bool { + fmt.Print("AdGuard Shield jetzt (neu) starten? [j/N] ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && len(line) == 0 { + fmt.Println("Keine Eingabe gelesen, Service wird nicht gestartet.") + return false + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "j", "ja", "y", "yes": + return true + default: + fmt.Println("Service wird nicht gestartet.") + return false + } +} + +func requireLinuxRoot() error { + if runtime.GOOS != "linux" { + return fmt.Errorf("Installation ist nur auf Linux-Servern unterstuetzt") + } + if os.Geteuid() != 0 { + return fmt.Errorf("Installation muss als root ausgefuehrt werden") + } + return nil +} + +func ensureDependencies() error { + missing := missingCommands("iptables", "ip6tables", "ipset", "systemctl") + if len(missing) == 0 { + fmt.Println(" Alle benoetigten Befehle sind vorhanden.") + return nil + } + fmt.Printf(" Fehlende Befehle: %s\n", strings.Join(missing, ", ")) + if _, err := exec.LookPath("apt-get"); err != nil { + return fmt.Errorf("fehlende Abhaengigkeiten (%s), apt-get nicht gefunden", strings.Join(missing, ", ")) + } + pkgs := map[string]bool{"iptables": false, "ipset": false, "systemd": false, "ca-certificates": false} + for _, m := range missing { + switch m { + case "iptables", "ip6tables": + pkgs["iptables"] = true + case "ipset": + pkgs["ipset"] = true + case "systemctl": + pkgs["systemd"] = true + } + } + var install []string + for p, needed := range pkgs { + if needed || p == "ca-certificates" { + install = append(install, p) + } + } + sort.Strings(install) + fmt.Printf(" Installiere Pakete via apt-get: %s\n", strings.Join(install, ", ")) + fmt.Println(" apt-get update ...") + if err := runStreaming("apt-get", "update"); err != nil { + return err + } + fmt.Println(" apt-get install ...") + args := append([]string{"install", "-y", "-qq"}, install...) + return runStreaming("apt-get", args...) +} + +func missingCommands(names ...string) []string { + var missing []string + for _, name := range names { + if _, err := exec.LookPath(name); err != nil { + missing = append(missing, name) + } + } + return missing +} + +func copySelf(dst string) error { + src, err := os.Executable() + if err != nil { + return err + } + if sameFile(src, dst) { + return os.Chmod(dst, 0755) + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + if err := os.Chmod(tmp, 0755); err != nil { + return err + } + return os.Rename(tmp, dst) +} + +func sameFile(a, b string) bool { + aa, errA := filepath.Abs(a) + bb, errB := filepath.Abs(b) + if errA == nil && errB == nil && aa == bb { + return true + } + ai, errA := os.Stat(a) + bi, errB := os.Stat(b) + return errA == nil && errB == nil && os.SameFile(ai, bi) +} + +func ensureConfig(opts Options) error { + target := filepath.Join(opts.InstallDir, "adguard-shield.conf") + defaults := []byte(defaultConfig) + if opts.ConfigSource != "" { + b, err := os.ReadFile(opts.ConfigSource) + if err != nil { + return err + } + defaults = b + } + if !fileExists(target) { + if err := os.WriteFile(target, defaults, 0600); err != nil { + return err + } + return nil + } + current, err := os.ReadFile(target) + if err != nil { + return err + } + merged, changed := mergeConfig(current, []byte(defaultConfig)) + if !changed { + return os.Chmod(target, 0600) + } + if err := os.WriteFile(target+".old", current, 0600); err != nil { + return err + } + if err := os.WriteFile(target, merged, 0600); err != nil { + return err + } + return nil +} + +func mergeConfig(current, defaults []byte) ([]byte, bool) { + existing := configKeys(current) + var add [][]byte + for _, block := range configBlocks(defaults) { + key := blockKey(block) + if key == "" || existing[key] { + continue + } + add = append(add, block) + } + if len(add) == 0 { + return current, false + } + out := bytes.TrimRight(current, "\r\n") + out = append(out, '\n', '\n') + out = append(out, []byte("# Neue Parameter aus der Go-Version\n")...) + for _, block := range add { + out = append(out, bytes.Trim(block, "\r\n")...) + out = append(out, '\n') + } + return out, true +} + +func configKeys(data []byte) map[string]bool { + keys := map[string]bool{} + for _, line := range bytes.Split(data, []byte{'\n'}) { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' { + continue + } + if i := bytes.IndexByte(line, '='); i > 0 { + keys[string(bytes.TrimSpace(line[:i]))] = true + } + } + return keys +} + +func configBlocks(data []byte) [][]byte { + lines := bytes.Split(data, []byte{'\n'}) + var blocks [][]byte + var comments [][]byte + for _, line := range lines { + trim := bytes.TrimSpace(line) + if len(trim) == 0 || trim[0] == '#' { + comments = append(comments, append([]byte(nil), line...)) + continue + } + block := bytes.Join(append(comments, line), []byte{'\n'}) + blocks = append(blocks, block) + comments = nil + } + return blocks +} + +func blockKey(block []byte) string { + for _, line := range bytes.Split(block, []byte{'\n'}) { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' { + continue + } + if i := bytes.IndexByte(line, '='); i > 0 { + return string(bytes.TrimSpace(line[:i])) + } + } + return "" +} + +func writeService(installDir string) error { + service := fmt.Sprintf(`[Unit] +Description=AdGuard Shield - Go DNS Rate-Limit Monitor +After=network.target AdGuardHome.service +Wants=AdGuardHome.service +StartLimitBurst=5 +StartLimitIntervalSec=300 + +[Service] +Type=simple +ExecStart=%s/adguard-shield -config %s/adguard-shield.conf run +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=30 +ProtectSystem=full +ReadWritePaths=/var/log /var/lib/adguard-shield /var/run %s/geoip +ProtectHome=true +NoNewPrivileges=false +PrivateTmp=true +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL CAP_SETUID CAP_SETGID CAP_CHOWN +StandardOutput=journal +StandardError=journal +SyslogIdentifier=adguard-shield + +[Install] +WantedBy=multi-user.target +`, installDir, installDir, installDir) + return os.WriteFile(ServicePath, []byte(service), 0644) +} + +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return nil +} + +func runStreaming(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +func commandOK(name string, args ...string) bool { + return exec.Command(name, args...).Run() == nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func yesNo(ok bool) string { + if ok { + return "ja" + } + return "nein" +} + +func IsLegacyError(err error) (*LegacyError, bool) { + var le *LegacyError + if errors.As(err, &le) { + return le, true + } + return nil, false +} + +const defaultConfig = `# AdGuard Shield Konfiguration + +ADGUARD_URL="https://dns1.domain.com" +ADGUARD_USER="admin" +ADGUARD_PASS='changeme' + +RATE_LIMIT_MAX_REQUESTS=30 +RATE_LIMIT_WINDOW=60 +CHECK_INTERVAL=10 +API_QUERY_LIMIT=500 + +SUBDOMAIN_FLOOD_ENABLED=true +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 +SUBDOMAIN_FLOOD_WINDOW=60 + +DNS_FLOOD_WATCHLIST_ENABLED=false +DNS_FLOOD_WATCHLIST="" + +BAN_DURATION=3600 +IPTABLES_CHAIN="ADGUARD_SHIELD" +BLOCKED_PORTS="53 443 853" +FIREWALL_BACKEND="ipset" +FIREWALL_MODE="host" +DRY_RUN=false + +WHITELIST="127.0.0.1,::1" + +LOG_FILE="/var/log/adguard-shield.log" +LOG_LEVEL="INFO" +STATE_DIR="/var/lib/adguard-shield" +PID_FILE="/var/run/adguard-shield.pid" + +NOTIFY_ENABLED=false +NOTIFY_TYPE="ntfy" +NOTIFY_WEBHOOK_URL="" +NTFY_SERVER_URL="https://ntfy.sh" +NTFY_TOPIC="" +NTFY_TOKEN="" +NTFY_PRIORITY="4" + +REPORT_ENABLED=false +REPORT_INTERVAL="weekly" +REPORT_TIME="08:00" +REPORT_EMAIL_TO="admin@example.com" +REPORT_EMAIL_FROM="adguard-shield@example.com" +REPORT_FORMAT="html" +REPORT_MAIL_CMD="msmtp" +REPORT_BUSIEST_DAY_RANGE=30 + +EXTERNAL_WHITELIST_ENABLED=false +EXTERNAL_WHITELIST_URLS="" +EXTERNAL_WHITELIST_INTERVAL=300 +EXTERNAL_WHITELIST_CACHE_DIR="/var/lib/adguard-shield/external-whitelist" + +EXTERNAL_BLOCKLIST_ENABLED=false +EXTERNAL_BLOCKLIST_URLS="" +EXTERNAL_BLOCKLIST_INTERVAL=300 +EXTERNAL_BLOCKLIST_BAN_DURATION=0 +EXTERNAL_BLOCKLIST_AUTO_UNBAN=true +EXTERNAL_BLOCKLIST_NOTIFY=false +EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist" + +PROGRESSIVE_BAN_ENABLED=true +PROGRESSIVE_BAN_MULTIPLIER=2 +PROGRESSIVE_BAN_MAX_LEVEL=5 +PROGRESSIVE_BAN_RESET_AFTER=86400 + +ABUSEIPDB_ENABLED=false +ABUSEIPDB_API_KEY="" +ABUSEIPDB_CATEGORIES="4" + +GEOIP_ENABLED=false +GEOIP_MODE="blocklist" +GEOIP_COUNTRIES="" +GEOIP_CHECK_INTERVAL=0 +GEOIP_NOTIFY=true +GEOIP_SKIP_PRIVATE=true +GEOIP_LICENSE_KEY="" +GEOIP_MMDB_PATH="" +GEOIP_CACHE_TTL=86400 +` diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..fa2ffc5 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,242 @@ +package report + +import ( + "bytes" + "context" + "fmt" + "html" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "adguard-shield/internal/config" + "adguard-shield/internal/db" +) + +type Store interface { + ReportStats(since, until int64, limit int) (db.ReportStats, error) +} + +const cronPath = "/etc/cron.d/adguard-shield-report" + +func Status(c *config.Config) string { + cron := "nicht installiert" + if _, err := os.Stat(cronPath); err == nil { + cron = "installiert (" + cronPath + ")" + } + return fmt.Sprintf(`E-Mail Report +Aktiv: %v +Intervall: %s +Zeit: %s +Empfaenger: %s +Absender: %s +Format: %s +Mail-Befehl: %s +Cron: %s +`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportEmailTo, c.ReportEmailFrom, c.ReportFormat, c.ReportMailCmd, cron) +} + +func Generate(c *config.Config, st Store, format string) (string, error) { + if format == "" { + format = c.ReportFormat + } + since, until := window(c.ReportInterval) + stats, err := st.ReportStats(since, until, 20) + if err != nil { + return "", err + } + if strings.EqualFold(format, "html") { + return renderHTML(c, stats), nil + } + return renderText(c, stats), nil +} + +func Send(ctx context.Context, c *config.Config, st Store) error { + body, err := Generate(c, st, c.ReportFormat) + if err != nil { + return err + } + return sendMail(ctx, c, "AdGuard Shield Report", body) +} + +func SendTest(ctx context.Context, c *config.Config) error { + body := fmt.Sprintf("AdGuard Shield Test-Mail\n\nHostname: %s\nZeitpunkt: %s\nEmpfaenger: %s\nAbsender: %s\n", hostname(), time.Now().Format("2006-01-02 15:04:05"), c.ReportEmailTo, c.ReportEmailFrom) + if strings.EqualFold(c.ReportFormat, "html") { + body = "

AdGuard Shield Test-Mail

Hostname: " + html.EscapeString(hostname()) + "

Zeitpunkt: " + html.EscapeString(time.Now().Format("2006-01-02 15:04:05")) + "

" + } + return sendMail(ctx, c, "AdGuard Shield Test-Mail", body) +} + +func InstallCron(binary, configPath string, c *config.Config) error { + minute, hour, err := parseReportTime(c.ReportTime) + if err != nil { + return err + } + schedule := cronSchedule(c.ReportInterval, minute, hour) + if binary == "" { + binary = "/opt/adguard-shield/adguard-shield" + } + if configPath == "" { + configPath = "/opt/adguard-shield/adguard-shield.conf" + } + line := fmt.Sprintf("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n%s root %s -config %s report-send\n", schedule, binary, configPath) + return os.WriteFile(cronPath, []byte(line), 0644) +} + +func RemoveCron() error { + if err := os.Remove(cronPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func sendMail(ctx context.Context, c *config.Config, subject, body string) error { + if c.ReportEmailTo == "" { + return fmt.Errorf("REPORT_EMAIL_TO ist leer") + } + if c.ReportMailCmd == "" { + return fmt.Errorf("REPORT_MAIL_CMD ist leer") + } + contentType := "text/plain; charset=utf-8" + if strings.EqualFold(c.ReportFormat, "html") { + contentType = "text/html; charset=utf-8" + } + msg := "From: " + c.ReportEmailFrom + "\n" + + "To: " + c.ReportEmailTo + "\n" + + "Subject: " + subject + "\n" + + "Content-Type: " + contentType + "\n\n" + body + parts := strings.Fields(c.ReportMailCmd) + if len(parts) == 0 { + return fmt.Errorf("REPORT_MAIL_CMD ist leer") + } + args := append(parts[1:], "-t") + cmd := exec.CommandContext(ctx, parts[0], args...) + cmd.Stdin = strings.NewReader(msg) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func parseReportTime(value string) (string, string, error) { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return "", "", fmt.Errorf("REPORT_TIME muss HH:MM sein") + } + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Stunde") + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Minute") + } + return strconv.Itoa(minute), strconv.Itoa(hour), nil +} + +func cronSchedule(interval, minute, hour string) string { + switch strings.ToLower(interval) { + case "daily": + return fmt.Sprintf("%s %s * * *", minute, hour) + case "biweekly": + return fmt.Sprintf("%s %s 1,15 * *", minute, hour) + case "monthly": + return fmt.Sprintf("%s %s 1 * *", minute, hour) + default: + return fmt.Sprintf("%s %s * * 1", minute, hour) + } +} + +func window(interval string) (int64, int64) { + now := time.Now() + days := 7 + switch strings.ToLower(interval) { + case "daily": + days = 1 + case "biweekly": + days = 14 + case "monthly": + days = 30 + } + return now.AddDate(0, 0, -days).Unix(), now.Unix() +} + +func renderText(c *config.Config, st db.ReportStats) string { + var b strings.Builder + b.WriteString("AdGuard Shield Report\n") + b.WriteString("Zeitraum: " + formatTime(st.Since) + " bis " + formatTime(st.Until) + "\n\n") + b.WriteString("Bans: " + strconv.Itoa(st.TotalBans) + "\n") + b.WriteString("Unbans: " + strconv.Itoa(st.TotalUnbans) + "\n") + b.WriteString("Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "\n\n") + writeCountsText(&b, "Top Clients", st.TopClients) + writeCountsText(&b, "Gruende", st.Reasons) + writeCountsText(&b, "Aktive Quellen", st.Sources) + if len(st.RecentEvents) > 0 { + b.WriteString("Letzte Ereignisse\n") + for _, e := range st.RecentEvents { + b.WriteString("- " + e + "\n") + } + } + _ = c + return b.String() +} + +func renderHTML(c *config.Config, st db.ReportStats) string { + var b bytes.Buffer + b.WriteString("AdGuard Shield Report") + b.WriteString("") + b.WriteString("") + b.WriteString("

AdGuard Shield Report

") + b.WriteString("

Zeitraum: " + html.EscapeString(formatTime(st.Since)) + " bis " + html.EscapeString(formatTime(st.Until)) + "

") + b.WriteString("
  • Bans: " + strconv.Itoa(st.TotalBans) + "
  • Unbans: " + strconv.Itoa(st.TotalUnbans) + "
  • Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "
") + writeCountsHTML(&b, "Top Clients", st.TopClients) + writeCountsHTML(&b, "Gruende", st.Reasons) + writeCountsHTML(&b, "Aktive Quellen", st.Sources) + if len(st.RecentEvents) > 0 { + b.WriteString("

Letzte Ereignisse

") + for _, e := range st.RecentEvents { + b.WriteString("") + } + b.WriteString("
Ereignis
" + html.EscapeString(e) + "
") + } + b.WriteString("") + _ = c + return b.String() +} + +func writeCountsText(b *strings.Builder, title string, rows []db.ReportCount) { + b.WriteString(title + "\n") + if len(rows) == 0 { + b.WriteString("- keine Daten\n\n") + return + } + for _, r := range rows { + b.WriteString("- " + r.Name + ": " + strconv.Itoa(r.Count) + "\n") + } + b.WriteByte('\n') +} + +func writeCountsHTML(b *bytes.Buffer, title string, rows []db.ReportCount) { + b.WriteString("

" + html.EscapeString(title) + "

") + if len(rows) == 0 { + b.WriteString("") + } + for _, r := range rows { + b.WriteString("") + } + b.WriteString("
NameAnzahl
keine Daten
" + html.EscapeString(r.Name) + "" + strconv.Itoa(r.Count) + "
") +} + +func formatTime(epoch int64) string { + return time.Unix(epoch, 0).Format("2006-01-02 15:04:05") +} + +func hostname() string { + name, err := os.Hostname() + if err != nil || name == "" { + return filepath.Base(os.Args[0]) + } + return name +} diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go new file mode 100644 index 0000000..4b4d4f2 --- /dev/null +++ b/internal/syslog/syslog.go @@ -0,0 +1,82 @@ +package syslog + +import ( + "fmt" + "io" + "log" + "strings" + "sync" +) + +type Level int + +const ( + Debug Level = iota + Info + Warn + Error +) + +type Logger struct { + mu sync.Mutex + min Level + log *log.Logger +} + +func New(w io.Writer, min string) *Logger { + return &Logger{ + min: ParseLevel(min, Info), + log: log.New(w, "", log.LstdFlags), + } +} + +func ParseLevel(s string, fallback Level) Level { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "DEBUG": + return Debug + case "INFO", "": + return Info + case "WARN", "WARNING": + return Warn + case "ERROR", "ERR": + return Error + default: + return fallback + } +} + +func LevelName(l Level) string { + switch l { + case Debug: + return "DEBUG" + case Info: + return "INFO" + case Warn: + return "WARN" + case Error: + return "ERROR" + default: + return "INFO" + } +} + +func (l *Logger) Enabled(level Level) bool { + if l == nil { + return false + } + return level >= l.min +} + +func (l *Logger) Logf(level Level, format string, args ...any) { + if !l.Enabled(level) { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.log.Printf("[%s] [ADGUARD-SHIELDD] %s", LevelName(level), fmt.Sprintf(format, args...)) +} + +func (l *Logger) Debugf(format string, args ...any) { l.Logf(Debug, format, args...) } +func (l *Logger) Infof(format string, args ...any) { l.Logf(Info, format, args...) } +func (l *Logger) Warnf(format string, args ...any) { l.Logf(Warn, format, args...) } +func (l *Logger) Errorf(format string, args ...any) { l.Logf(Error, format, args...) } diff --git a/iptables-helper.sh b/iptables-helper.sh deleted file mode 100644 index 69bf3b3..0000000 --- a/iptables-helper.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - iptables Helper -# Verwaltet die Firewall-Regeln für AdGuard Shield -# Kann auch standalone genutzt werden zur Verwaltung der Sperren -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -source "$CONFIG_FILE" - -# ─── Chain erstellen ───────────────────────────────────────────────────────── -create_chain() { - echo "Erstelle iptables Chain: $IPTABLES_CHAIN" - - # IPv4 - if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - iptables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - echo " ✅ IPv4 Chain erstellt" - else - echo " ℹ️ IPv4 Chain existiert bereits" - fi - - # IPv6 - if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - ip6tables -N "$IPTABLES_CHAIN" - for port in $BLOCKED_PORTS; do - ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" - ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" - done - echo " ✅ IPv6 Chain erstellt" - else - echo " ℹ️ IPv6 Chain existiert bereits" - fi -} - -# ─── Chain entfernen ───────────────────────────────────────────────────────── -remove_chain() { - echo "Entferne iptables Chain: $IPTABLES_CHAIN" - - # IPv4 - Referenzen entfernen, dann Chain löschen - if iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - for port in $BLOCKED_PORTS; do - iptables -D INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true - iptables -D INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true - done - iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true - iptables -X "$IPTABLES_CHAIN" 2>/dev/null || true - echo " ✅ IPv4 Chain entfernt" - else - echo " ℹ️ IPv4 Chain existiert nicht" - fi - - # IPv6 - if ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then - for port in $BLOCKED_PORTS; do - ip6tables -D INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true - ip6tables -D INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true - done - ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null || true - ip6tables -X "$IPTABLES_CHAIN" 2>/dev/null || true - echo " ✅ IPv6 Chain entfernt" - else - echo " ℹ️ IPv6 Chain existiert nicht" - fi -} - -# ─── Chain leeren ──────────────────────────────────────────────────────────── -flush_chain() { - echo "Leere iptables Chain: $IPTABLES_CHAIN" - iptables -F "$IPTABLES_CHAIN" 2>/dev/null && echo " ✅ IPv4 geleert" || echo " ⚠️ IPv4 Chain nicht gefunden" - ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null && echo " ✅ IPv6 geleert" || echo " ⚠️ IPv6 Chain nicht gefunden" - - # State-Dateien auch aufräumen - rm -f "${STATE_DIR}"/*.ban 2>/dev/null || true - echo " ✅ State-Dateien bereinigt" -} - -# ─── IP manuell sperren ───────────────────────────────────────────────────── -ban_ip() { - local ip="$1" - echo "Sperre IP: $ip" - - if [[ "$ip" == *:* ]]; then - ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP - echo " ✅ IPv6 Adresse gesperrt" - else - iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP - echo " ✅ IPv4 Adresse gesperrt" - fi -} - -# ─── IP entsperren ────────────────────────────────────────────────────────── -unban_ip() { - local ip="$1" - echo "Entsperre IP: $ip" - - if [[ "$ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null \ - && echo " ✅ IPv6 Adresse entsperrt" \ - || echo " ⚠️ IPv6 Regel nicht gefunden" - else - iptables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null \ - && echo " ✅ IPv4 Adresse entsperrt" \ - || echo " ⚠️ IPv4 Regel nicht gefunden" - fi - - # State-Datei entfernen - rm -f "${STATE_DIR}/${ip//[:\/]/_}.ban" 2>/dev/null || true -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_rules() { - echo "" - echo "══════════════════════════════════════════════════════════════════" - echo " iptables Regeln für Chain: $IPTABLES_CHAIN" - echo "══════════════════════════════════════════════════════════════════" - echo "" - - echo " --- IPv4 ---" - if iptables -n -L "$IPTABLES_CHAIN" --line-numbers &>/dev/null; then - iptables -n -L "$IPTABLES_CHAIN" --line-numbers -v 2>/dev/null | sed 's/^/ /' - else - echo " Chain existiert nicht" - fi - - echo "" - echo " --- IPv6 ---" - if ip6tables -n -L "$IPTABLES_CHAIN" --line-numbers &>/dev/null; then - ip6tables -n -L "$IPTABLES_CHAIN" --line-numbers -v 2>/dev/null | sed 's/^/ /' - else - echo " Chain existiert nicht" - fi - - echo "" - echo " --- Aktive Sperren (State) ---" - local count=0 - if [[ -d "$STATE_DIR" ]]; then - for f in "${STATE_DIR}"/*.ban; do - [[ -f "$f" ]] || continue - count=$((count + 1)) - local ip domain ban_time ban_until - ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2) - domain=$(grep '^DOMAIN=' "$f" | cut -d= -f2) - ban_time=$(grep '^BAN_TIME=' "$f" | cut -d= -f2) - ban_until=$(grep '^BAN_UNTIL=' "$f" | cut -d= -f2) - printf " %-20s %-30s seit %-20s bis %s\n" "$ip" "$domain" "$ban_time" "$ban_until" - done - fi - - if [[ $count -eq 0 ]]; then - echo " Keine aktiven Sperren" - fi - echo "" -} - -# ─── Persistenz (iptables-save/restore kompatibel) ────────────────────────── -save_rules() { - local save_file="${STATE_DIR}/iptables-rules.v4" - local save_file6="${STATE_DIR}/iptables-rules.v6" - - iptables-save > "$save_file" 2>/dev/null && echo " ✅ IPv4 Regeln gespeichert: $save_file" - ip6tables-save > "$save_file6" 2>/dev/null && echo " ✅ IPv6 Regeln gespeichert: $save_file6" -} - -restore_rules() { - local save_file="${STATE_DIR}/iptables-rules.v4" - local save_file6="${STATE_DIR}/iptables-rules.v6" - - [[ -f "$save_file" ]] && iptables-restore < "$save_file" && echo " ✅ IPv4 Regeln wiederhergestellt" - [[ -f "$save_file6" ]] && ip6tables-restore < "$save_file6" && echo " ✅ IPv6 Regeln wiederhergestellt" -} - -# ─── Hauptprogramm ────────────────────────────────────────────────────────── -case "${1:-help}" in - create) - create_chain - ;; - remove) - remove_chain - ;; - flush) - flush_chain - ;; - ban) - [[ -z "${2:-}" ]] && { echo "Nutzung: $0 ban " >&2; exit 1; } - ban_ip "$2" - ;; - unban) - [[ -z "${2:-}" ]] && { echo "Nutzung: $0 unban " >&2; exit 1; } - unban_ip "$2" - ;; - status|show) - show_rules - ;; - save) - save_rules - ;; - restore) - restore_rules - ;; - *) - cat << USAGE -iptables Helper für AdGuard Shield - -Nutzung: $0 {create|remove|flush|ban|unban|status|save|restore} - -Befehle: - create Erstellt die iptables Chain - remove Entfernt die Chain und alle Regeln - flush Leert alle Regeln in der Chain - ban Sperrt eine IP-Adresse manuell - unban Entsperrt eine IP-Adresse - status Zeigt alle aktuellen Regeln - save Speichert die aktuellen Regeln - restore Stellt gespeicherte Regeln wieder her - -Chain-Name: $IPTABLES_CHAIN -Gesperrte Ports: $BLOCKED_PORTS - -USAGE - ;; -esac diff --git a/offense-cleanup-worker.sh b/offense-cleanup-worker.sh deleted file mode 100644 index d1904cd..0000000 --- a/offense-cleanup-worker.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Offense-Cleanup-Worker -# Räumt abgelaufene Offense-Zähler (progressive Sperren) automatisch auf. -# Entfernt .offenses-Dateien, deren letztes Vergehen länger als -# PROGRESSIVE_BAN_RESET_AFTER zurückliegt. -# Wird als Hintergrundprozess vom Hauptscript gestartet. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Datum: 2026-04-16 -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Niedrigste Priorität setzen (CPU + I/O) ───────────────────────────────── -# Stellt sicher, dass der Worker auch bei manuellem Start nie andere Dienste -# verdrängt. nice 19 = niedrigste CPU-Priorität, ionice idle = nur bei freier I/O. -renice -n 19 $$ >/dev/null 2>&1 || true -ionice -c 3 -p $$ >/dev/null 2>&1 || true - -# ─── Worker PID-File ────────────────────────────────────────────────────────── -WORKER_PID_FILE="/var/run/adguard-offense-cleanup-worker.pid" - -# ─── Prüfintervall ─────────────────────────────────────────────────────────── -# Prüft einmal pro Stunde – das ist völlig ausreichend für diese Aufgabe -OFFENSE_CLEANUP_INTERVAL=3600 - -# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ─────────────────────── -declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3) - -log() { - local level="$1" - shift - local message="$*" - local configured_level="${LOG_LEVEL:-INFO}" - - if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - local log_entry="[$timestamp] [$level] [OFFENSE-CLEANUP] $message" - echo "$log_entry" | tee -a "$LOG_FILE" >&2 - fi -} - -# ─── Hilfsfunktionen ───────────────────────────────────────────────────────── -format_duration() { - local seconds="$1" - if [[ "$seconds" -eq 0 ]]; then - echo "PERMANENT" - return - fi - if [[ "$seconds" -ge 86400 ]]; then - echo "$((seconds / 86400))d $((seconds % 86400 / 3600))h" - elif [[ "$seconds" -ge 3600 ]]; then - echo "$((seconds / 3600))h $((seconds % 3600 / 60))m" - elif [[ "$seconds" -ge 60 ]]; then - echo "$((seconds / 60))m $((seconds % 60))s" - else - echo "${seconds}s" - fi -} - -# ─── Verzeichnisse erstellen ────────────────────────────────────────────────── -init_directories() { - mkdir -p "${STATE_DIR}" - mkdir -p "$(dirname "$LOG_FILE")" - db_init -} - -# ─── Abgelaufene Offense-Zähler aufräumen ──────────────────────────────────── -cleanup_expired_offenses() { - local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" - local now - now=$(date '+%s') - local cutoff=$((now - reset_after)) - - local expired_rows - expired_rows=$(db_query "SELECT client_ip, offense_level, last_offense_epoch FROM offense_tracking WHERE last_offense_epoch <= $cutoff;") - - if [[ -n "$expired_rows" ]]; then - while IFS='|' read -r client_ip offense_level last_epoch; do - [[ -z "$client_ip" ]] && continue - local elapsed=$((now - last_epoch)) - log "INFO" "Offense-Zähler abgelaufen: $client_ip (Stufe $offense_level, letztes Vergehen vor $(format_duration $elapsed)) → entfernt" - done <<< "$expired_rows" - fi - - local cleaned - cleaned=$(db_offense_delete_expired "$reset_after") - - if [[ "$cleaned" -gt 0 ]]; then - log "INFO" "Offense-Cleanup: $cleaned abgelaufene Zähler entfernt" - else - log "DEBUG" "Offense-Cleanup: keine abgelaufenen Zähler gefunden" - fi -} - -# ─── PID-Management ────────────────────────────────────────────────────────── -write_pid() { - echo $$ > "$WORKER_PID_FILE" -} - -cleanup() { - log "INFO" "Offense-Cleanup-Worker wird beendet..." - rm -f "$WORKER_PID_FILE" - exit 0 -} - -check_already_running() { - if [[ -f "$WORKER_PID_FILE" ]]; then - local old_pid - old_pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$old_pid" 2>/dev/null; then - log "DEBUG" "Offense-Cleanup-Worker läuft bereits (PID: $old_pid)" - return 1 - else - rm -f "$WORKER_PID_FILE" - fi - fi - return 0 -} - -# ─── Status anzeigen ───────────────────────────────────────────────────────── -show_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " Offense-Cleanup-Worker - Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" != "true" ]]; then - echo " ⚠️ Progressive Sperren sind deaktiviert" - echo " Aktivieren: PROGRESSIVE_BAN_ENABLED=true in $CONFIG_FILE" - echo "" - return - fi - - # Worker-Prozess Status - if [[ -f "$WORKER_PID_FILE" ]]; then - local pid - pid=$(cat "$WORKER_PID_FILE") - if kill -0 "$pid" 2>/dev/null; then - echo " 🟢 Worker läuft (PID: $pid)" - else - echo " 🔴 Worker nicht aktiv (veraltete PID-Datei)" - fi - else - echo " 🔴 Worker nicht aktiv" - fi - - echo "" - echo " Reset-Zeitraum: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}")" - echo " Prüfintervall: $(format_duration "$OFFENSE_CLEANUP_INTERVAL")" - - local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" - local total - total=$(db_offense_count) - local expired - expired=$(db_offense_count_expired "$reset_after") - - echo "" - echo " Offense-Zähler gesamt: $total" - echo " Davon abgelaufen: $expired" - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Hauptschleife ────────────────────────────────────────────────────────── -main_loop() { - init_directories - - log "INFO" "═══════════════════════════════════════════════════════════" - log "INFO" "Offense-Cleanup-Worker gestartet" - log "INFO" " Reset-Zeitraum: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}")" - log "INFO" " Prüfintervall: $(format_duration "$OFFENSE_CLEANUP_INTERVAL")" - log "INFO" "═══════════════════════════════════════════════════════════" - - while true; do - cleanup_expired_offenses - sleep "$OFFENSE_CLEANUP_INTERVAL" - done -} - -# ─── Signal-Handler ────────────────────────────────────────────────────────── -trap cleanup SIGTERM SIGINT SIGHUP - -# ─── Kommandozeilen-Argumente ──────────────────────────────────────────────── -case "${1:-start}" in - start) - if ! check_already_running; then - exit 0 - fi - write_pid - main_loop - ;; - stop) - if [[ -f "$WORKER_PID_FILE" ]]; then - kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true - rm -f "$WORKER_PID_FILE" - echo "Offense-Cleanup-Worker gestoppt" - else - echo "Offense-Cleanup-Worker läuft nicht" - fi - ;; - run-once) - init_directories - log "INFO" "Einmaliger Offense-Cleanup..." - cleanup_expired_offenses - log "INFO" "Cleanup abgeschlossen" - ;; - status) - init_directories - show_status - ;; - *) - cat << USAGE -AdGuard Shield - Offense-Cleanup-Worker - -Nutzung: $0 {start|stop|run-once|status} - -Befehle: - start Startet den Worker (Dauerbetrieb) - stop Stoppt den Worker - run-once Einmaliger Cleanup-Durchlauf - status Zeigt Status und aktuelle Offense-Zähler - -Konfiguration: $CONFIG_FILE -USAGE - ;; -esac diff --git a/report-generator.sh b/report-generator.sh deleted file mode 100644 index 9f94979..0000000 --- a/report-generator.sh +++ /dev/null @@ -1,1130 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Report Generator -# Erstellt und versendet periodische Statistik-Reports per E-Mail. -# -# Nutzung: -# report-generator.sh send – Report sofort generieren und versenden -# report-generator.sh test – Test-E-Mail senden (Konfiguration prüfen) -# report-generator.sh generate – Report als Datei generieren (ohne Versand) -# report-generator.sh install – Cron-Job einrichten -# report-generator.sh remove – Cron-Job entfernen -# report-generator.sh status – Cron-Status anzeigen -# -# Crontab-Eintrag (wird automatisch verwaltet): -# Wird je nach REPORT_INTERVAL als Cron-Job unter /etc/cron.d/ angelegt. -# -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" -CRON_FILE="/etc/cron.d/adguard-shield-report" - -# ─── Konfiguration laden ─────────────────────────────────────────────────────── -if [[ ! -f "$CONFIG_FILE" ]]; then - echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2 - exit 1 -fi -# shellcheck source=adguard-shield.conf -source "$CONFIG_FILE" -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -# ─── Standardwerte ──────────────────────────────────────────────────────────── -REPORT_ENABLED="${REPORT_ENABLED:-false}" -REPORT_INTERVAL="${REPORT_INTERVAL:-weekly}" -REPORT_TIME="${REPORT_TIME:-08:00}" -REPORT_EMAIL_TO="${REPORT_EMAIL_TO:-}" -REPORT_EMAIL_FROM="${REPORT_EMAIL_FROM:-adguard-shield@$(hostname -f 2>/dev/null || hostname)}" -REPORT_FORMAT="${REPORT_FORMAT:-html}" -REPORT_MAIL_CMD="${REPORT_MAIL_CMD:-msmtp}" -REPORT_BUSIEST_DAY_RANGE="${REPORT_BUSIEST_DAY_RANGE:-30}" -BAN_HISTORY_FILE="${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" -BAN_HISTORY_RETENTION_DAYS="${BAN_HISTORY_RETENTION_DAYS:-0}" -STATE_DIR="${STATE_DIR:-/var/lib/adguard-shield}" -TEMPLATE_DIR="${SCRIPT_DIR}/templates" - -# Version aus dem Hauptscript auslesen -VERSION="unknown" -if [[ -f "${SCRIPT_DIR}/adguard-shield.sh" ]]; then - VERSION=$(grep -m1 '^VERSION=' "${SCRIPT_DIR}/adguard-shield.sh" 2>/dev/null | cut -d'"' -f2) - VERSION="${VERSION:-unknown}" -fi - -# ─── Logging ────────────────────────────────────────────────────────────────── -LOG_FILE="${LOG_FILE:-/var/log/adguard-shield.log}" - -log() { - local level="$1" - shift - local message="$*" - local timestamp - timestamp="$(date '+%Y-%m-%d %H:%M:%S')" - echo "[$timestamp] [$level] [REPORT] $message" | tee -a "$LOG_FILE" >&2 -} - -# ─── Versionsnummern vergleichen ────────────────────────────────────────────── -# Gibt 0 zurück, wenn $1 > $2 (semver-Vergleich, v-Präfix wird ignoriert) -version_gt() { - local v1="${1#v}" - local v2="${2#v}" - [[ "$v1" == "$v2" ]] && return 1 - local IFS='.' i a b - read -ra ver1 <<< "$v1" - read -ra ver2 <<< "$v2" - local max_len=$(( ${#ver1[@]} > ${#ver2[@]} ? ${#ver1[@]} : ${#ver2[@]} )) - for ((i=0; i/dev/null | grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true) - - [[ -z "$latest_tag" ]] && return - - if version_gt "$latest_tag" "$VERSION"; then - UPDATE_NOTICE_HTML='
🆕 Update verfügbar: '"${latest_tag}"' · Jetzt aktualisieren →
' - UPDATE_NOTICE_TXT=" ⚠ Neue Version verfügbar: ${latest_tag} - Update: https://git.techniverse.net/scriptos/adguard-shield/releases -" - fi -} - - - -# ─── Berichtszeitraum berechnen ─────────────────────────────────────────────── - -# Gibt Epoch-Wert für heute 00:00:00 (Mitternacht) zurück -get_today_midnight() { - date -d "today 00:00:00" '+%s' 2>/dev/null || date -v0H -v0M -v0S '+%s' -} - -get_report_period() { - local today_midnight - today_midnight=$(get_today_midnight) - # Ende des Berichtszeitraums ist immer das Ende von gestern (23:59:59) - local end_epoch=$((today_midnight - 1)) - local start_epoch - local period_label - - case "$REPORT_INTERVAL" in - daily) - start_epoch=$((today_midnight - 86400)) - period_label="Tagesbericht" - ;; - weekly) - start_epoch=$((today_midnight - 7 * 86400)) - period_label="Wochenbericht" - ;; - biweekly) - start_epoch=$((today_midnight - 14 * 86400)) - period_label="Zweiwochenbericht" - ;; - monthly) - start_epoch=$((today_midnight - 30 * 86400)) - period_label="Monatsbericht" - ;; - *) - start_epoch=$((today_midnight - 7 * 86400)) - period_label="Bericht" - ;; - esac - - local start_date - start_date=$(date -d "@$start_epoch" '+%d.%m.%Y' 2>/dev/null || date -r "$start_epoch" '+%d.%m.%Y') - local end_date - end_date=$(date -d "@$end_epoch" '+%d.%m.%Y' 2>/dev/null || date -r "$end_epoch" '+%d.%m.%Y') - - if [[ "$REPORT_INTERVAL" == "daily" ]]; then - echo "${period_label}: ${start_date}" - else - echo "${period_label}: ${start_date} – ${end_date}" - fi -} - -get_period_start_epoch() { - local today_midnight - today_midnight=$(get_today_midnight) - - case "$REPORT_INTERVAL" in - daily) echo $((today_midnight - 86400)) ;; - weekly) echo $((today_midnight - 7 * 86400)) ;; - biweekly) echo $((today_midnight - 14 * 86400)) ;; - monthly) echo $((today_midnight - 30 * 86400)) ;; - *) echo $((today_midnight - 7 * 86400)) ;; - esac -} - -get_period_end_epoch() { - # Ende des Berichtszeitraums = Ende von gestern (heute 00:00:00 minus 1 Sekunde) - local today_midnight - today_midnight=$(get_today_midnight) - echo $((today_midnight - 1)) -} - -# ─── Ban-History bereinigen ──────────────────────────────────────────────────── -cleanup_ban_history() { - [[ "$BAN_HISTORY_RETENTION_DAYS" == "0" || -z "$BAN_HISTORY_RETENTION_DAYS" ]] && return - - local removed - removed=$(db_history_cleanup "$BAN_HISTORY_RETENTION_DAYS") - if [[ "${removed:-0}" -gt 0 ]]; then - log "INFO" "Ban-History bereinigt: $removed Einträge älter als ${BAN_HISTORY_RETENTION_DAYS} Tage entfernt" - fi -} - -# ─── Statistiken für beliebigen Zeitraum berechnen ────────────────────────── -get_stats_for_epoch_range() { - local start_epoch="$1" - local end_epoch="$2" - - local result - result=$(db_history_stats_for_range "$start_epoch" "$end_epoch") - if [[ -z "$result" ]]; then - echo "0|0|0|0" - return - fi - echo "$result" -} - -# ─── Statistiken berechnen ──────────────────────────────────────────────────── -calculate_stats() { - # Ban-History bereinigen (falls Retention konfiguriert) - cleanup_ban_history - - # Datenbank initialisieren - db_init - - local start_epoch - start_epoch=$(get_period_start_epoch) - local end_epoch - end_epoch=$(get_period_end_epoch) - - local total_history - total_history=$(db_history_count) - - if [[ "${total_history:-0}" -eq 0 ]]; then - TOTAL_BANS=0 - TOTAL_UNBANS=0 - UNIQUE_IPS=0 - PERMANENT_BANS=0 - ACTIVE_BANS=0 - ABUSEIPDB_REPORTS=0 - RATELIMIT_BANS=0 - SUBDOMAIN_FLOOD_BANS=0 - EXTERNAL_BLOCKLIST_BANS=0 - BUSIEST_DAY="–" - BUSIEST_DAY_LABEL="Aktivster Tag" - TOP10_IPS="" - TOP10_DOMAINS="" - PROTOCOL_STATS="" - RECENT_BANS="" - return - fi - - # Haupt-Statistiken per SQL - local stats_row - stats_row=$(db_history_report_stats "$start_epoch" "$end_epoch") - IFS='|' read -r TOTAL_BANS TOTAL_UNBANS UNIQUE_IPS PERMANENT_BANS RATELIMIT_BANS SUBDOMAIN_FLOOD_BANS EXTERNAL_BLOCKLIST_BANS <<< "$stats_row" - - # Busiest-Day-Bereich berechnen - local busiest_start_epoch - if [[ "$REPORT_BUSIEST_DAY_RANGE" == "0" || -z "$REPORT_BUSIEST_DAY_RANGE" ]]; then - busiest_start_epoch="$start_epoch" - else - local today_midnight - today_midnight=$(get_today_midnight) - busiest_start_epoch=$((today_midnight - REPORT_BUSIEST_DAY_RANGE * 86400)) - fi - - local busiest_row - busiest_row=$(db_history_busiest_day "$busiest_start_epoch" "$end_epoch") - if [[ -n "$busiest_row" ]]; then - local busiest_raw busiest_cnt - IFS='|' read -r busiest_raw busiest_cnt <<< "$busiest_row" - local busiest_formatted - busiest_formatted=$(date -d "$busiest_raw" '+%d.%m.%Y' 2>/dev/null || echo "$busiest_raw") - BUSIEST_DAY="${busiest_formatted} (${busiest_cnt})" - else - BUSIEST_DAY="–" - fi - - if [[ "$REPORT_BUSIEST_DAY_RANGE" == "0" || -z "$REPORT_BUSIEST_DAY_RANGE" ]]; then - BUSIEST_DAY_LABEL="Aktivster Tag" - else - BUSIEST_DAY_LABEL="Aktivster Tag (${REPORT_BUSIEST_DAY_RANGE} Tage)" - fi - - # Top-Listen per SQL (Ausgabe: "count|value" → umformatieren zu "count value") - TOP10_IPS=$(db_history_top_ips "$start_epoch" "$end_epoch" 10 | sed 's/|/ /') - TOP10_DOMAINS=$(db_history_top_domains "$start_epoch" "$end_epoch" 10 | sed 's/|/ /') - PROTOCOL_STATS=$(db_history_protocol_stats "$start_epoch" "$end_epoch" | sed 's/|/ /') - RECENT_BANS=$(db_history_recent_bans "$start_epoch" "$end_epoch" 10) - - # Aktuell aktive Sperren aus der Datenbank - ACTIVE_BANS=$(db_ban_count) - - # AbuseIPDB Reports – zeitraum-gefiltert aus der Logdatei - ABUSEIPDB_REPORTS=0 - if [[ -f "$LOG_FILE" ]]; then - ABUSEIPDB_REPORTS=$(grep "AbuseIPDB:.*erfolgreich gemeldet" "$LOG_FILE" 2>/dev/null | \ - awk -v s="$start_epoch" -v e="$end_epoch" ' - { - ts = substr($0, 2, 19) - if (ts !~ /^[0-9]{4}/) next - ep = mktime(substr(ts,1,4) " " substr(ts,6,2) " " substr(ts,9,2) " " \ - substr(ts,12,2) " " substr(ts,15,2) " " substr(ts,18,2)) - if (ep >= s && ep <= e) count++ - } - END { print count+0 } - ' || echo "0") - fi -} - -# ─── HTML-Tabellen generieren ───────────────────────────────────────────────── -generate_top10_ips_html() { - if [[ -z "$TOP10_IPS" ]]; then - echo '
Keine Daten im Berichtszeitraum
' - return - fi - - local max_count - max_count=$(echo "$TOP10_IPS" | head -1 | awk '{print $1}') - - local html='' - local rank=0 - - while read -r count ip; do - [[ -z "$count" || -z "$ip" ]] && continue - rank=$((rank + 1)) - local rank_class="" - [[ $rank -le 3 ]] && rank_class=" top3" - local bar_width=100 - if [[ "$max_count" -gt 0 ]]; then - bar_width=$((count * 100 / max_count)) - fi - html+="" - html+="" - html+="" - html+="" - done <<< "$TOP10_IPS" - - html+='
#IP-AdresseSperren
${rank}${ip}
${count}
' - echo "$html" -} - -generate_top10_domains_html() { - if [[ -z "$TOP10_DOMAINS" ]]; then - echo '
Keine Daten im Berichtszeitraum
' - return - fi - - local max_count - max_count=$(echo "$TOP10_DOMAINS" | head -1 | awk '{print $1}') - - local html='' - local rank=0 - - while read -r count domain; do - [[ -z "$count" || -z "$domain" ]] && continue - rank=$((rank + 1)) - local rank_class="" - [[ $rank -le 3 ]] && rank_class=" top3" - local bar_width=100 - if [[ "$max_count" -gt 0 ]]; then - bar_width=$((count * 100 / max_count)) - fi - html+="" - html+="" - html+="" - html+="" - done <<< "$TOP10_DOMAINS" - - html+='
#DomainSperren
${rank}${domain}
${count}
' - echo "$html" -} - -generate_protocol_html() { - if [[ -z "$PROTOCOL_STATS" ]]; then - echo '
Keine Daten im Berichtszeitraum
' - return - fi - - local html='' - - while read -r count proto; do - [[ -z "$count" || -z "$proto" ]] && continue - local badge_class="" - case "${proto,,}" in - dns*) badge_class="dns" ;; - doh*) badge_class="doh" ;; - dot*) badge_class="dot" ;; - doq*) badge_class="doq" ;; - esac - html+="" - html+="" - done <<< "$PROTOCOL_STATS" - - html+='
ProtokollAnzahl Sperren
${proto}${count}
' - echo "$html" -} - -generate_recent_bans_html() { - if [[ -z "$RECENT_BANS" ]]; then - echo '
Keine Sperren im Berichtszeitraum
' - return - fi - - local html='' - - while IFS='|' read -r timestamp action ip domain count duration protocol reason; do - timestamp=$(echo "$timestamp" | xargs) - ip=$(echo "$ip" | xargs) - [[ -z "$timestamp" && -z "$ip" ]] && continue - domain=$(echo "$domain" | xargs) - reason=$(echo "$reason" | xargs) - [[ "$domain" == "-" ]] && domain="–" - [[ -z "$reason" ]] && reason="rate-limit" - - local reason_class="rate-limit" - [[ "$reason" == *"subdomain"* ]] && reason_class="subdomain-flood" - [[ "$reason" == *"external"* ]] && reason_class="external" - - # Datum kürzen für Anzeige - local short_time - short_time=$(echo "$timestamp" | awk '{print $1" "$2}' | cut -c6-) - - html+="" - html+="" - html+="" - html+="" - html+="" - done <<< "$RECENT_BANS" - - html+='
ZeitpunktIPDomainGrund
${short_time}${ip}${domain}${reason}
' - echo "$html" -} - -# ─── Zeitraum-Schnellübersicht (HTML) ───────────────────────────────────────── -generate_period_overview_html() { - local today_midnight - today_midnight=$(get_today_midnight) - local now - now=$(date '+%s') - local yesterday_start=$((today_midnight - 86400)) - local yesterday_end=$((today_midnight - 1)) - - # Zeiträume: "Label:start_epoch:end_epoch" (Doppelpunkt als Trennzeichen) - local periods=() - - # Heute nur nach 20:00 Uhr einblenden - local current_hour - current_hour=$(date '+%H' | sed 's/^0*//') - if [[ "${current_hour:-0}" -ge 20 ]]; then - periods+=("Heute:${today_midnight}:${now}") - fi - - periods+=( - "Gestern:${yesterday_start}:${yesterday_end}" - "Letzte 7 Tage:$((today_midnight - 7 * 86400)):${now}" - "Letzte 14 Tage:$((today_midnight - 14 * 86400)):${now}" - "Letzte 30 Tage:$((today_midnight - 30 * 86400)):${now}" - ) - - local html='' - html+='' - html+='' - html+='' - html+='' - html+='' - html+='' - html+='' - - for period_def in "${periods[@]}"; do - IFS=':' read -r label start_e end_e <<< "$period_def" - local row_class="" - case "$label" in - Heute) row_class=' class="period-today"' ;; - Gestern) row_class=' class="period-gestern"' ;; - esac - local stats - stats=$(get_stats_for_epoch_range "$start_e" "$end_e") - IFS='|' read -r bans unbans unique perm <<< "$stats" - - html+="" - html+="" - html+="" - html+="" - html+="" - html+="" - html+="" - done - - html+='
ZeitraumSperrenEntsperrtUnique IPsDauerhaft gebannt
${label}${bans}${unbans}${unique}${perm}
' - echo "$html" -} - -# ─── TXT-Tabellen generieren ────────────────────────────────────────────────── -generate_top10_ips_txt() { - if [[ -z "$TOP10_IPS" ]]; then - echo " Keine Daten im Berichtszeitraum" - return - fi - - local rank=0 - printf " %-4s %-42s %s\n" "#" "IP-Adresse" "Sperren" - printf " %-4s %-42s %s\n" "──" "──────────────────────────────────────────" "───────" - - while read -r count ip; do - [[ -z "$count" || -z "$ip" ]] && continue - rank=$((rank + 1)) - printf " %-4s %-42s %s\n" "${rank}." "$ip" "$count" - done <<< "$TOP10_IPS" -} - -generate_top10_domains_txt() { - if [[ -z "$TOP10_DOMAINS" ]]; then - echo " Keine Daten im Berichtszeitraum" - return - fi - - local rank=0 - printf " %-4s %-42s %s\n" "#" "Domain" "Sperren" - printf " %-4s %-42s %s\n" "──" "──────────────────────────────────────────" "───────" - - while read -r count domain; do - [[ -z "$count" || -z "$domain" ]] && continue - rank=$((rank + 1)) - printf " %-4s %-42s %s\n" "${rank}." "$domain" "$count" - done <<< "$TOP10_DOMAINS" -} - -generate_protocol_txt() { - if [[ -z "$PROTOCOL_STATS" ]]; then - echo " Keine Daten im Berichtszeitraum" - return - fi - - printf " %-20s %s\n" "Protokoll" "Anzahl" - printf " %-20s %s\n" "────────────────────" "──────" - - while read -r count proto; do - [[ -z "$count" || -z "$proto" ]] && continue - printf " %-20s %s\n" "$proto" "$count" - done <<< "$PROTOCOL_STATS" -} - -generate_recent_bans_txt() { - if [[ -z "$RECENT_BANS" ]]; then - echo " Keine Sperren im Berichtszeitraum" - return - fi - - printf " %-17s %-42s %-30s %s\n" "Zeitpunkt" "IP" "Domain" "Grund" - printf " %-17s %-42s %-30s %s\n" "─────────────────" "──────────────────────────────────────────" "──────────────────────────────" "──────────" - - while IFS='|' read -r timestamp action ip domain count duration protocol reason; do - timestamp=$(echo "$timestamp" | xargs) - ip=$(echo "$ip" | xargs) - [[ -z "$timestamp" && -z "$ip" ]] && continue - domain=$(echo "$domain" | xargs) - reason=$(echo "$reason" | xargs) - [[ "$domain" == "-" ]] && domain="–" - [[ -z "$reason" ]] && reason="rate-limit" - - local short_time - short_time=$(echo "$timestamp" | awk '{print $1" "$2}' | cut -c6-) - - printf " %-17s %-42s %-30s %s\n" "$short_time" "$ip" "$domain" "$reason" - done <<< "$RECENT_BANS" -} - -# ─── Zeitraum-Schnellübersicht (TXT) ────────────────────────────────────────── -generate_period_overview_txt() { - local today_midnight - today_midnight=$(get_today_midnight) - local now - now=$(date '+%s') - local yesterday_start=$((today_midnight - 86400)) - local yesterday_end=$((today_midnight - 1)) - - local periods=() - - # Heute nur nach 20:00 Uhr einblenden - local current_hour - current_hour=$(date '+%H' | sed 's/^0*//') - if [[ "${current_hour:-0}" -ge 20 ]]; then - periods+=("Heute:${today_midnight}:${now}") - fi - - periods+=( - "Gestern:${yesterday_start}:${yesterday_end}" - "Letzte 7 Tage:$((today_midnight - 7 * 86400)):${now}" - "Letzte 14 Tage:$((today_midnight - 14 * 86400)):${now}" - "Letzte 30 Tage:$((today_midnight - 30 * 86400)):${now}" - ) - - printf " %-15s %-9s %-12s %-14s %-11s\n" \ - "Zeitraum" "Sperren" "Entsperrt" "Unique IPs" "Dauerhaft" - printf " %-15s %-9s %-12s %-14s %-11s\n" \ - "───────────────" "─────────" "────────────" "──────────────" "───────────" - - for period_def in "${periods[@]}"; do - IFS=':' read -r label start_e end_e <<< "$period_def" - local stats - stats=$(get_stats_for_epoch_range "$start_e" "$end_e") - IFS='|' read -r bans unbans unique perm <<< "$stats" - printf " %-15s %-9s %-12s %-14s %-11s\n" \ - "$label" "$bans" "$unbans" "$unique" "$perm" - done -} - -# ─── Report generieren ──────────────────────────────────────────────────────── -generate_report() { - local format="${1:-$REPORT_FORMAT}" - - log "INFO" "Generiere ${format^^}-Report..." - - # Statistiken berechnen - calculate_stats - - # Update-Verfügbarkeit prüfen - check_for_update - - local report_period - report_period=$(get_report_period) - local report_date - report_date=$(date '+%d.%m.%Y %H:%M:%S') - local hostname - hostname=$(hostname -f 2>/dev/null || hostname) - - if [[ "$format" == "html" ]]; then - local template_file="${TEMPLATE_DIR}/report.html" - if [[ ! -f "$template_file" ]]; then - log "ERROR" "HTML-Template nicht gefunden: $template_file" - return 1 - fi - - local report - report=$(cat "$template_file") - - # Tabellen generieren - local top10_ips_table - top10_ips_table=$(generate_top10_ips_html) - local top10_domains_table - top10_domains_table=$(generate_top10_domains_html) - local protocol_table - protocol_table=$(generate_protocol_html) - local recent_bans_table - recent_bans_table=$(generate_recent_bans_html) - local period_overview_table - period_overview_table=$(generate_period_overview_html) - - # Platzhalter ersetzen - report="${report//\{\{REPORT_PERIOD\}\}/$report_period}" - report="${report//\{\{REPORT_DATE\}\}/$report_date}" - report="${report//\{\{HOSTNAME\}\}/$hostname}" - report="${report//\{\{VERSION\}\}/$VERSION}" - report="${report//\{\{TOTAL_BANS\}\}/$TOTAL_BANS}" - report="${report//\{\{TOTAL_UNBANS\}\}/$TOTAL_UNBANS}" - report="${report//\{\{UNIQUE_IPS\}\}/$UNIQUE_IPS}" - report="${report//\{\{PERMANENT_BANS\}\}/$PERMANENT_BANS}" - report="${report//\{\{ACTIVE_BANS\}\}/$ACTIVE_BANS}" - report="${report//\{\{ABUSEIPDB_REPORTS\}\}/$ABUSEIPDB_REPORTS}" - report="${report//\{\{RATELIMIT_BANS\}\}/$RATELIMIT_BANS}" - report="${report//\{\{SUBDOMAIN_FLOOD_BANS\}\}/$SUBDOMAIN_FLOOD_BANS}" - report="${report//\{\{EXTERNAL_BLOCKLIST_BANS\}\}/$EXTERNAL_BLOCKLIST_BANS}" - report="${report//\{\{BUSIEST_DAY\}\}/$BUSIEST_DAY}" - report="${report//\{\{BUSIEST_DAY_LABEL\}\}/$BUSIEST_DAY_LABEL}" - report="${report//\{\{TOP10_IPS_TABLE\}\}/$top10_ips_table}" - report="${report//\{\{TOP10_DOMAINS_TABLE\}\}/$top10_domains_table}" - report="${report//\{\{PROTOCOL_TABLE\}\}/$protocol_table}" - report="${report//\{\{RECENT_BANS_TABLE\}\}/$recent_bans_table}" - report="${report//\{\{PERIOD_OVERVIEW_TABLE\}\}/$period_overview_table}" - report="${report//\{\{UPDATE_NOTICE\}\}/$UPDATE_NOTICE_HTML}" - - echo "$report" - - elif [[ "$format" == "txt" ]]; then - local template_file="${TEMPLATE_DIR}/report.txt" - if [[ ! -f "$template_file" ]]; then - log "ERROR" "TXT-Template nicht gefunden: $template_file" - return 1 - fi - - local report - report=$(cat "$template_file") - - # Text-Tabellen generieren - local top10_ips_txt - top10_ips_txt=$(generate_top10_ips_txt) - local top10_domains_txt - top10_domains_txt=$(generate_top10_domains_txt) - local protocol_txt - protocol_txt=$(generate_protocol_txt) - local recent_bans_txt - recent_bans_txt=$(generate_recent_bans_txt) - local period_overview_txt - period_overview_txt=$(generate_period_overview_txt) - - # Platzhalter ersetzen - report="${report//\{\{REPORT_PERIOD\}\}/$report_period}" - report="${report//\{\{REPORT_DATE\}\}/$report_date}" - report="${report//\{\{HOSTNAME\}\}/$hostname}" - report="${report//\{\{VERSION\}\}/$VERSION}" - report="${report//\{\{TOTAL_BANS\}\}/$TOTAL_BANS}" - report="${report//\{\{TOTAL_UNBANS\}\}/$TOTAL_UNBANS}" - report="${report//\{\{UNIQUE_IPS\}\}/$UNIQUE_IPS}" - report="${report//\{\{PERMANENT_BANS\}\}/$PERMANENT_BANS}" - report="${report//\{\{ACTIVE_BANS\}\}/$ACTIVE_BANS}" - report="${report//\{\{ABUSEIPDB_REPORTS\}\}/$ABUSEIPDB_REPORTS}" - report="${report//\{\{RATELIMIT_BANS\}\}/$RATELIMIT_BANS}" - report="${report//\{\{SUBDOMAIN_FLOOD_BANS\}\}/$SUBDOMAIN_FLOOD_BANS}" - report="${report//\{\{EXTERNAL_BLOCKLIST_BANS\}\}/$EXTERNAL_BLOCKLIST_BANS}" - report="${report//\{\{BUSIEST_DAY\}\}/$BUSIEST_DAY}" - report="${report//\{\{BUSIEST_DAY_LABEL\}\}/$BUSIEST_DAY_LABEL}" - report="${report//\{\{TOP10_IPS_TEXT\}\}/$top10_ips_txt}" - report="${report//\{\{TOP10_DOMAINS_TEXT\}\}/$top10_domains_txt}" - report="${report//\{\{PROTOCOL_TEXT\}\}/$protocol_txt}" - report="${report//\{\{RECENT_BANS_TEXT\}\}/$recent_bans_txt}" - report="${report//\{\{PERIOD_OVERVIEW_TEXT\}\}/$period_overview_txt}" - report="${report//\{\{UPDATE_NOTICE_TXT\}\}/$UPDATE_NOTICE_TXT}" - - echo "$report" - else - log "ERROR" "Unbekanntes Report-Format: $format" - return 1 - fi -} - -# ─── E-Mail senden ──────────────────────────────────────────────────────────── -send_report_email() { - if [[ -z "$REPORT_EMAIL_TO" ]]; then - log "ERROR" "Kein E-Mail-Empfänger konfiguriert (REPORT_EMAIL_TO)" - return 1 - fi - - # Prüfen ob Mail-Befehl verfügbar - if ! command -v "$REPORT_MAIL_CMD" &>/dev/null; then - log "ERROR" "Mail-Befehl nicht gefunden: $REPORT_MAIL_CMD" - log "ERROR" "Bitte installieren, z.B.: sudo apt install msmtp msmtp-mta" - log "ERROR" "Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/" - return 1 - fi - - local report_content - report_content=$(generate_report "$REPORT_FORMAT") - - if [[ -z "$report_content" ]]; then - log "ERROR" "Report-Generierung fehlgeschlagen – keine Daten" - return 1 - fi - - local subject="🛡️ AdGuard Shield $(get_report_period) – $(hostname)" - local content_type="text/plain" - [[ "$REPORT_FORMAT" == "html" ]] && content_type="text/html" - - log "INFO" "Sende Report an ${REPORT_EMAIL_TO} via ${REPORT_MAIL_CMD}..." - - # E-Mail zusammenbauen und senden - { - echo "From: ${REPORT_EMAIL_FROM}" - echo "To: ${REPORT_EMAIL_TO}" - echo "Subject: ${subject}" - echo "MIME-Version: 1.0" - echo "Content-Type: ${content_type}; charset=UTF-8" - echo "Content-Transfer-Encoding: 8bit" - echo "X-Mailer: AdGuard Shield Report Generator" - echo "" - echo "$report_content" - } | "$REPORT_MAIL_CMD" -t 2>&1 || { - log "ERROR" "E-Mail-Versand fehlgeschlagen (Exit-Code: $?)" - log "ERROR" "Prüfe die ${REPORT_MAIL_CMD}-Konfiguration" - return 1 - } - - log "INFO" "Report erfolgreich an ${REPORT_EMAIL_TO} gesendet" -} - -# ─── Cron-Job verwalten ─────────────────────────────────────────────────────── -install_cron() { - if [[ "$REPORT_ENABLED" != "true" ]]; then - log "WARN" "Report ist deaktiviert (REPORT_ENABLED=false)" - echo "Report ist deaktiviert. Bitte REPORT_ENABLED=true setzen." - return 1 - fi - - local hour minute - hour=$(echo "$REPORT_TIME" | cut -d: -f1 | sed 's/^0//') - minute=$(echo "$REPORT_TIME" | cut -d: -f2 | sed 's/^0//') - - local cron_schedule - - case "$REPORT_INTERVAL" in - daily) - cron_schedule="${minute} ${hour} * * *" - ;; - weekly) - # Montag - cron_schedule="${minute} ${hour} * * 1" - ;; - biweekly) - # Alle zwei Wochen am Montag (ungerade Kalenderwochen) - # Nutzt einen Test im Befehl selbst, da Cron keine 2-Wochen-Logik kann - cron_schedule="${minute} ${hour} * * 1" - ;; - monthly) - # 1. des Monats - cron_schedule="${minute} ${hour} 1 * *" - ;; - *) - log "ERROR" "Unbekanntes Intervall: $REPORT_INTERVAL" - return 1 - ;; - esac - - local cron_cmd="${SCRIPT_DIR}/report-generator.sh send" - - # Bei biweekly: Prüfung auf ungerade Kalenderwoche einbauen - if [[ "$REPORT_INTERVAL" == "biweekly" ]]; then - cron_cmd="[ \$(( \$(date +\\%V) \\% 2 )) -eq 1 ] && ${SCRIPT_DIR}/report-generator.sh send" - fi - - # Cron-Datei schreiben - cat > "$CRON_FILE" << EOF -# AdGuard Shield - Automatischer Report -# Generiert von: report-generator.sh install -# Intervall: ${REPORT_INTERVAL} -# Uhrzeit: ${REPORT_TIME} -SHELL=/bin/bash -PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - -${cron_schedule} root ${cron_cmd} >> /var/log/adguard-shield.log 2>&1 -EOF - - chmod 644 "$CRON_FILE" - - log "INFO" "Cron-Job installiert: $CRON_FILE" - echo "✅ Cron-Job installiert: $CRON_FILE" - echo " Intervall: $REPORT_INTERVAL" - echo " Uhrzeit: $REPORT_TIME" - echo " Schedule: $cron_schedule" - echo " Empfänger: $REPORT_EMAIL_TO" -} - -remove_cron() { - if [[ -f "$CRON_FILE" ]]; then - rm -f "$CRON_FILE" - log "INFO" "Cron-Job entfernt: $CRON_FILE" - echo "✅ Cron-Job entfernt" - else - echo "ℹ️ Kein Cron-Job vorhanden" - fi -} - -show_cron_status() { - echo "═══════════════════════════════════════════════════════════════" - echo " AdGuard Shield – Report Status" - echo "═══════════════════════════════════════════════════════════════" - echo "" - echo " Report aktiviert: ${REPORT_ENABLED}" - echo " Intervall: ${REPORT_INTERVAL}" - echo " Uhrzeit: ${REPORT_TIME}" - echo " Format: ${REPORT_FORMAT}" - echo " Empfänger: ${REPORT_EMAIL_TO:-nicht konfiguriert}" - echo " Absender: ${REPORT_EMAIL_FROM}" - echo " Mail-Befehl: ${REPORT_MAIL_CMD}" - echo " Aktivster Tag: letzte ${REPORT_BUSIEST_DAY_RANGE:-30} Tage" - echo "" - - if command -v "$REPORT_MAIL_CMD" &>/dev/null; then - echo " ✅ ${REPORT_MAIL_CMD} ist installiert" - else - echo " ❌ ${REPORT_MAIL_CMD} ist NICHT installiert" - echo " → Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/" - fi - - echo "" - - if [[ -f "$CRON_FILE" ]]; then - echo " ✅ Cron-Job aktiv:" - grep -v '^#' "$CRON_FILE" | grep -v '^$' | grep -v '^SHELL\|^PATH' | sed 's/^/ /' - else - echo " ❌ Kein Cron-Job installiert" - echo " → Einrichten mit: sudo $(basename "$0") install" - fi - - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Test-E-Mail senden ─────────────────────────────────────────────────────── -send_test_email() { - echo "═══════════════════════════════════════════════════════════════" - echo " AdGuard Shield – E-Mail Test" - echo "═══════════════════════════════════════════════════════════════" - echo "" - - local errors=0 - - # 1. Empfänger prüfen - echo -n " 1) E-Mail-Empfänger ... " - if [[ -z "$REPORT_EMAIL_TO" ]]; then - echo "❌ nicht konfiguriert (REPORT_EMAIL_TO ist leer)" - errors=$((errors + 1)) - else - echo "✅ $REPORT_EMAIL_TO" - fi - - # 2. Absender prüfen - echo -n " 2) E-Mail-Absender ... " - echo "✅ $REPORT_EMAIL_FROM" - - # 3. Mail-Befehl prüfen - echo -n " 3) Mail-Befehl ($REPORT_MAIL_CMD) ... " - if command -v "$REPORT_MAIL_CMD" &>/dev/null; then - local mail_path - mail_path=$(command -v "$REPORT_MAIL_CMD") - echo "✅ gefunden ($mail_path)" - else - echo "❌ NICHT gefunden" - echo " → Installieren: sudo apt install msmtp msmtp-mta" - echo " → Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/" - errors=$((errors + 1)) - fi - - # 4. Templates prüfen - echo -n " 4) Report-Template ($REPORT_FORMAT) ... " - local tpl="${TEMPLATE_DIR}/report.${REPORT_FORMAT}" - if [[ -f "$tpl" ]]; then - echo "✅ vorhanden" - else - echo "❌ nicht gefunden: $tpl" - errors=$((errors + 1)) - fi - - # 5. Datenbank prüfen - echo -n " 5) Datenbank ... " - if [[ -f "$DB_FILE" ]]; then - local entries - entries=$(db_history_count 2>/dev/null || echo "0") - echo "✅ vorhanden ($entries History-Einträge)" - else - echo "⚠️ nicht vorhanden (Report wird leer sein – das ist OK für einen Test)" - fi - - echo "" - - if [[ $errors -gt 0 ]]; then - echo " ❌ $errors Fehler gefunden – bitte zuerst beheben." - echo "" - echo "═══════════════════════════════════════════════════════════════" - return 1 - fi - - # 6. Test-Mail senden - echo " 6) Sende Test-E-Mail an ${REPORT_EMAIL_TO} ..." - echo "" - - local hostname - hostname=$(hostname -f 2>/dev/null || hostname) - local test_date - test_date=$(date '+%d.%m.%Y %H:%M:%S') - local subject="🧪 AdGuard Shield – Test-Mail von ${hostname}" - local content_type="text/plain" - [[ "$REPORT_FORMAT" == "html" ]] && content_type="text/html" - - local test_update_notice_html - test_update_notice_html='
🆕 Update verfügbar (Testanzeige): '"${VERSION}"' · Jetzt aktualisieren →
' - local test_update_notice_txt - test_update_notice_txt=" ⚠ Neue Version verfügbar (Testanzeige): ${VERSION}\n Update: https://git.techniverse.net/scriptos/adguard-shield/releases\n" - - local test_body - if [[ "$REPORT_FORMAT" == "html" ]]; then - test_body=$(cat < - - -
-
-

🧪 Test-Mail

-

AdGuard Shield Report

-
-
-

✅ E-Mail-Versand funktioniert!

-

Diese Test-Mail bestätigt, dass der E-Mail-Versand für AdGuard Shield korrekt konfiguriert ist.

- - - - - - - - -
Hostname${hostname}
Zeitpunkt${test_date}
Empfänger${REPORT_EMAIL_TO}
Absender${REPORT_EMAIL_FROM}
Mail-Befehl${REPORT_MAIL_CMD}
Format${REPORT_FORMAT}
Intervall${REPORT_INTERVAL}
-

Ab jetzt kannst du den automatischen Versand aktivieren mit:
sudo $(basename "$0") install

-
-
- -
AdGuard Shield ${VERSION} · ${hostname}
-${test_update_notice_html} -
-
- -TESTHTML -) - else - test_body=$(cat <&1 - ) - local exit_code=$? - - if [[ $exit_code -eq 0 ]]; then - echo " ✅ Test-E-Mail erfolgreich gesendet!" - echo "" - echo " Prüfe dein Postfach: ${REPORT_EMAIL_TO}" - echo " (Evtl. auch im Spam-Ordner nachschauen)" - log "INFO" "Test-E-Mail erfolgreich an ${REPORT_EMAIL_TO} gesendet" - else - echo " ❌ Versand fehlgeschlagen (Exit-Code: $exit_code)" - if [[ -n "$send_output" ]]; then - echo "" - echo " Fehlermeldung:" - echo "$send_output" | sed 's/^/ /' - fi - echo "" - echo " Troubleshooting:" - echo " 1) ${REPORT_MAIL_CMD}-Konfiguration prüfen" - echo " 2) Manuell testen: echo 'Test' | ${REPORT_MAIL_CMD} -t ${REPORT_EMAIL_TO}" - echo " 3) Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/" - log "ERROR" "Test-E-Mail fehlgeschlagen (Exit-Code: $exit_code): $send_output" - fi - - echo "" - echo "═══════════════════════════════════════════════════════════════" -} - -# ─── Hilfe ──────────────────────────────────────────────────────────────────── -print_help() { - echo "AdGuard Shield – Report Generator" - echo "" - echo "Nutzung: $(basename "$0") " - echo "" - echo "Befehle:" - echo " send Report generieren und per E-Mail versenden" - echo " test Test-E-Mail senden (prüft Konfiguration + Mailversand)" - echo " generate Report als Datei generieren (Ausgabe auf stdout)" - echo " install Cron-Job für automatischen Versand einrichten" - echo " remove Cron-Job entfernen" - echo " status Report-Konfiguration und Cron-Status anzeigen" - echo " --help Diese Hilfe anzeigen" - echo "" - echo "Beispiele:" - echo " sudo $(basename "$0") send # Report jetzt senden" - echo " sudo $(basename "$0") test # Test-Mail senden" - echo " sudo $(basename "$0") generate > r.html # Report in Datei speichern" - echo " sudo $(basename "$0") install # Automatischen Versand einrichten" - echo " sudo $(basename "$0") status # Status anzeigen" - echo "" -} - -# ─── Kommandozeilen-Argumente ───────────────────────────────────────────────── -case "${1:---help}" in - send) - send_report_email - ;; - test) - send_test_email - ;; - generate) - generate_report "${2:-$REPORT_FORMAT}" - ;; - install) - install_cron - ;; - remove) - remove_cron - ;; - status) - show_cron_status - ;; - --help|-h) - print_help - ;; - *) - echo "Unbekannter Befehl: $1" - print_help - exit 1 - ;; -esac diff --git a/templates/report.html b/templates/report.html deleted file mode 100644 index 0142883..0000000 --- a/templates/report.html +++ /dev/null @@ -1,370 +0,0 @@ - - - - - - AdGuard Shield – Report - - - -
- -
-

🛡️ AdGuard Shield

-

Sicherheits-Report

-
{{REPORT_PERIOD}}
-
- - -
- -

📅 Zeitraum-Schnellübersicht

- {{PERIOD_OVERVIEW_TABLE}} - - -

📊 Übersicht

-
-
-
{{TOTAL_BANS}}
-
Sperren gesamt
-
-
-
{{TOTAL_UNBANS}}
-
Entsperrungen
-
-
-
{{UNIQUE_IPS}}
-
Eindeutige IPs
-
-
-
{{PERMANENT_BANS}}
-
Permanente Sperren
-
-
-
{{ACTIVE_BANS}}
-
Aktuell aktive Sperren
-
-
-
{{ABUSEIPDB_REPORTS}}
-
AbuseIPDB Reports
-
-
- - -

⚔️ Angriffsarten

-
-
-
{{RATELIMIT_BANS}}
-
Rate-Limit Sperren
-
-
-
{{SUBDOMAIN_FLOOD_BANS}}
-
Subdomain-Flood Sperren
-
-
-
{{EXTERNAL_BLOCKLIST_BANS}}
-
Externe Blocklist
-
-
-
{{BUSIEST_DAY}}
-
{{BUSIEST_DAY_LABEL}}
-
-
- - -

🏴‍☠️ Top 10 – Auffälligste IPs

- {{TOP10_IPS_TABLE}} - - -

🌐 Top 10 – Meistbetroffene Domains

- {{TOP10_DOMAINS_TABLE}} - - -

📡 Protokoll-Verteilung

- {{PROTOCOL_TABLE}} - - -

🕐 Letzte 10 Sperren

- {{RECENT_BANS_TABLE}} -
- - - -
- - diff --git a/templates/report.txt b/templates/report.txt deleted file mode 100644 index 4b39b7e..0000000 --- a/templates/report.txt +++ /dev/null @@ -1,68 +0,0 @@ -═══════════════════════════════════════════════════════════════ - 🛡️ AdGuard Shield – Sicherheits-Report -═══════════════════════════════════════════════════════════════ - - Zeitraum: {{REPORT_PERIOD}} - Erstellt: {{REPORT_DATE}} - Host: {{HOSTNAME}} - -─────────────────────────────────────────────────────────────── - � ZEITRAUM-SCHNELLÜBERSICHT -─────────────────────────────────────────────────────────────── - -{{PERIOD_OVERVIEW_TEXT}} - -─────────────────────────────────────────────────────────────── - 📊 ÜBERSICHT (Berichtszeitraum) -─────────────────────────────────────────────────────────────── - - Sperren gesamt: {{TOTAL_BANS}} - Entsperrungen: {{TOTAL_UNBANS}} - Eindeutige IPs: {{UNIQUE_IPS}} - Permanente Sperren: {{PERMANENT_BANS}} - Aktuell aktive Sperren: {{ACTIVE_BANS}} - AbuseIPDB Reports: {{ABUSEIPDB_REPORTS}} - -─────────────────────────────────────────────────────────────── - ⚔️ ANGRIFFSARTEN -─────────────────────────────────────────────────────────────── - - Rate-Limit Sperren: {{RATELIMIT_BANS}} - Subdomain-Flood Sperren: {{SUBDOMAIN_FLOOD_BANS}} - Externe Blocklist: {{EXTERNAL_BLOCKLIST_BANS}} - {{BUSIEST_DAY_LABEL}}: {{BUSIEST_DAY}} - -─────────────────────────────────────────────────────────────── - 🏴‍☠️ TOP 10 – AUFFÄLLIGSTE IPs -─────────────────────────────────────────────────────────────── - -{{TOP10_IPS_TEXT}} - -─────────────────────────────────────────────────────────────── - 🌐 TOP 10 – MEISTBETROFFENE DOMAINS -─────────────────────────────────────────────────────────────── - -{{TOP10_DOMAINS_TEXT}} - -─────────────────────────────────────────────────────────────── - 📡 PROTOKOLL-VERTEILUNG -─────────────────────────────────────────────────────────────── - -{{PROTOCOL_TEXT}} - -─────────────────────────────────────────────────────────────── - 🕐 LETZTE 10 SPERREN -─────────────────────────────────────────────────────────────── - -{{RECENT_BANS_TEXT}} - -{{UPDATE_NOTICE_TXT}} -═══════════════════════════════════════════════════════════════ - Dieser Report wurde automatisch von AdGuard Shield generiert. - AdGuard Shield {{VERSION}} - - Web: https://www.patrick-asmus.de - Blog: https://www.cleveradmin.de - Repo: https://git.techniverse.net/scriptos/adguard-shield.git - Docs: https://git.techniverse.net/scriptos/adguard-shield/src/branch/main/docs -═══════════════════════════════════════════════════════════════ diff --git a/unban-expired.sh b/unban-expired.sh deleted file mode 100644 index 0b6bda9..0000000 --- a/unban-expired.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Cron-basierter Unban-Timer -# Kann als Alternative zum Haupt-Script für das Entsperren genutzt werden. -# Wird z.B. alle 5 Minuten per Cron aufgerufen um abgelaufene Sperren zu prüfen. -# -# Crontab-Eintrag: -# */5 * * * * /opt/adguard-shield/unban-expired.sh -############################################################################### - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf" - -if [[ ! -f "$CONFIG_FILE" ]]; then - exit 1 -fi -source "$CONFIG_FILE" -# shellcheck source=db.sh -source "${SCRIPT_DIR}/db.sh" - -LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')] [UNBAN-TIMER]" - -# Datenbank initialisieren -mkdir -p "${STATE_DIR}" -db_init - -unban_count=0 - -# Abgelaufene Sperren aus der Datenbank abfragen -expired_ips=$(db_ban_get_expired) - -if [[ -n "$expired_ips" ]]; then - while IFS= read -r client_ip; do - [[ -z "$client_ip" ]] && continue - - # Domain und Protokoll für History-Eintrag holen - local_ban_data=$(db_ban_get "$client_ip") - domain=$(echo "$local_ban_data" | cut -d'|' -f2) - protocol=$(echo "$local_ban_data" | cut -d'|' -f10) - - echo "$LOG_PREFIX Entsperre abgelaufene Sperre: $client_ip" >> "$LOG_FILE" - - # iptables Regel entfernen - if [[ "$client_ip" == *:* ]]; then - ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - else - iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true - fi - - # Ban-History Eintrag - db_history_add "UNBAN" "$client_ip" "${domain:--}" "-" "expired-cron" "-" "${protocol:-}" - - db_ban_delete "$client_ip" - unban_count=$((unban_count + 1)) - done <<< "$expired_ips" -fi - -if [[ $unban_count -gt 0 ]]; then - echo "$LOG_PREFIX $unban_count Sperren aufgehoben" >> "$LOG_FILE" -fi diff --git a/uninstall.sh b/uninstall.sh deleted file mode 100644 index ba964f7..0000000 --- a/uninstall.sh +++ /dev/null @@ -1,151 +0,0 @@ -#!/bin/bash -############################################################################### -# AdGuard Shield - Uninstaller -# Autor: Patrick Asmus -# E-Mail: support@techniverse.net -# Lizenz: MIT -# -# Dieses Script befindet sich im Installationsverzeichnis und kann daher -# ohne die originalen Installationsdateien ausgeführt werden: -# sudo bash /opt/adguard-shield/uninstall.sh -############################################################################### - -# INSTALL_DIR ergibt sich aus dem Verzeichnis, in dem dieses Script liegt -INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SERVICE_FILE="/etc/systemd/system/adguard-shield.service" -WATCHDOG_SERVICE_FILE="/etc/systemd/system/adguard-shield-watchdog.service" -WATCHDOG_TIMER_FILE="/etc/systemd/system/adguard-shield-watchdog.timer" - -# Farben -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -BOLD='\033[1m' -NC='\033[0m' - -print_header() { - echo "" - echo -e "${BLUE}" - echo " ▄▄▄ ▓█████▄ ▄████ █ ██ ▄▄▄ ██▀███ ▓█████▄ ██████ ██░ ██ ██▓▓█████ ██▓ ▓█████▄ " - echo "▒████▄ ▒██▀ ██▌ ██▒ ▀█▒ ██ ▓██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒ ▓██░ ██▒▓██▒▓█ ▀ ▓██▒ ▒██▀ ██▌" - echo "▒██ ▀█▄ ░██ █▌▒██░▄▄▄░▓██ ▒██░▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░ ▓██▄ ▒██▀▀██░▒██▒▒███ ▒██░ ░██ █▌" - echo "░██▄▄▄▄██ ░▓█▄ ▌░▓█ ██▓▓▓█ ░██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌ ▒ ██▒░▓█ ░██ ░██░▒▓█ ▄ ▒██░ ░▓█▄ ▌" - echo " ▓█ ▓██▒░▒████▓ ░▒▓███▀▒▒▒█████▓ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▒██████▒▒░▓█▒░██▓░██░░▒████▒░██████▒░▒████▓ " - echo " ▒▒ ▓▒█░ ▒▒▓ ▒ ░▒ ▒ ░▒▓▒ ▒ ▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░ ▒░ ░░ ▒░▓ ░ ▒▒▓ ▒ " - echo " ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░▒░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒ ░ ░ ▒ ▒ " - echo " ░ ▒ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░ ░ " - echo " ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ " - echo " ░ ░ ░ " - echo -e "${NC}" - echo -e "${GREEN} Uninstaller${NC}" - echo -e "${BLUE} Autor: Patrick Asmus${NC}" - echo -e - echo -e "${BLUE} E-Mail: support@techniverse.net${NC}" - echo -e "${BLUE} Web: https://www.patrick-asmus.de${NC}" - echo "" - echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}" - echo "" - echo -e "${BLUE} Repo: https://git.techniverse.net/scriptos/adguard-shield${NC}" - echo "" - echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" - echo "" -} - -check_root() { - if [[ $EUID -ne 0 ]]; then - echo -e "${RED}Dieses Script muss als root ausgeführt werden!${NC}" >&2 - echo "Bitte mit 'sudo $0' ausführen." - exit 1 - fi -} - -do_uninstall() { - check_root - - # Prüfen ob installiert - if [[ ! -d "$INSTALL_DIR" ]]; then - echo -e "${RED}AdGuard Shield ist nicht installiert (Verzeichnis nicht gefunden: $INSTALL_DIR)!${NC}" - exit 1 - fi - - echo -e "${YELLOW}Deinstalliere AdGuard Shield aus: ${BOLD}$INSTALL_DIR${NC}" - echo "" - - # Sicherheitsabfrage - read -rep " Wirklich deinstallieren? [j/N]: " confirm - if [[ "${confirm,,}" != "j" ]]; then - echo -e "${GREEN}Deinstallation abgebrochen.${NC}" - exit 0 - fi - echo "" - - # Watchdog-Timer stoppen und deaktivieren - if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then - systemctl stop adguard-shield-watchdog.timer - echo " ✅ Watchdog-Timer gestoppt" - fi - if systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then - systemctl disable adguard-shield-watchdog.timer - echo " ✅ Watchdog-Timer deaktiviert" - fi - - # Service stoppen und deaktivieren - if systemctl is-active adguard-shield &>/dev/null; then - systemctl stop adguard-shield - echo " ✅ Service gestoppt" - fi - if systemctl is-enabled adguard-shield &>/dev/null; then - systemctl disable adguard-shield - echo " ✅ Service deaktiviert" - fi - if [[ -f "$SERVICE_FILE" ]]; then - rm -f "$SERVICE_FILE" - echo " ✅ Service-Datei entfernt" - fi - rm -f "$WATCHDOG_SERVICE_FILE" "$WATCHDOG_TIMER_FILE" - if [[ -f "$WATCHDOG_SERVICE_FILE" ]] || [[ -f "$WATCHDOG_TIMER_FILE" ]]; then - echo " ✅ Watchdog-Dateien entfernt" - fi - systemctl daemon-reload - - # iptables Chain aufräumen - if [[ -f "$INSTALL_DIR/iptables-helper.sh" ]]; then - bash "$INSTALL_DIR/iptables-helper.sh" remove || true - fi - - # Dateien entfernen - read -rep " Konfiguration und Logs behalten? [j/N]: " keep - if [[ "${keep,,}" == "j" ]]; then - rm -f "$INSTALL_DIR/adguard-shield.sh" - rm -f "$INSTALL_DIR/iptables-helper.sh" - rm -f "$INSTALL_DIR/unban-expired.sh" - rm -f "$INSTALL_DIR/external-blocklist-worker.sh" - rm -f "$INSTALL_DIR/external-whitelist-worker.sh" - rm -f "$INSTALL_DIR/offense-cleanup-worker.sh" - rm -f "$INSTALL_DIR/report-generator.sh" - rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh" - rm -f "$INSTALL_DIR/geoip-worker.sh" - rm -f "$INSTALL_DIR/db.sh" - rm -f "$INSTALL_DIR/uninstall.sh" - rm -rf "$INSTALL_DIR/templates" - rm -rf "$INSTALL_DIR/geoip" - echo " ✅ Scripts entfernt (Konfiguration und Logs behalten)" - echo "" - echo -e "${YELLOW} Konfiguration verbleibt in: $INSTALL_DIR/adguard-shield.conf${NC}" - echo -e "${YELLOW} Logs verbleiben in: /var/log/adguard-shield*.log${NC}" - else - rm -rf "$INSTALL_DIR" - rm -rf /var/lib/adguard-shield - rm -f /var/log/adguard-shield.log* - rm -f /var/log/adguard-shield-bans.log - echo " ✅ Alles entfernt" - fi - - echo "" - echo -e "${GREEN}Deinstallation abgeschlossen.${NC}" -} - -print_header -do_uninstall