10 Commits

Author SHA1 Message Date
0970218f9b Release: Version v0.6.2 2026-03-24 11:28:32 +01:00
db128f3076 Ignore .ki-workspace 2026-03-24 11:24:38 +01:00
6f14219445 Report: Aktivster Tag über konfigurierbaren Zeitraum statt Berichtsperiode 2026-03-24 10:50:58 +01:00
cb31aa48eb Merge pull request 'v0.6.1' (#10) from v0.6.1 into main
Reviewed-on: #10
2026-03-13 13:28:01 +00:00
1e8b7557e7 Release v0.6.1 2026-03-13 14:27:41 +01:00
4d1870cc85 Notifications optimiert. 2026-03-13 14:27:07 +01:00
ebcd70ce8b Merge pull request 'v0.6.0' (#9) from v0.6.0 into main
Reviewed-on: #9
2026-03-06 21:15:58 +00:00
ba342dd571 Release v0.6.0 2026-03-06 22:15:16 +01:00
ac1af85810 Performance stark optimiert. 2026-03-06 22:14:30 +01:00
54b6c877e5 Merge pull request 'v0.5.4' (#8) from v0.5.4 into main
Reviewed-on: #8
2026-03-06 20:23:43 +00:00
12 changed files with 456 additions and 152 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ki-workspace

View File

@@ -2,6 +2,7 @@
"files.eol": "\n",
"chat.tools.terminal.autoApprove": {
"Rename-Item": true,
"ForEach-Object": true
"ForEach-Object": true,
"&": true
}
}

View File

@@ -128,6 +128,12 @@ REPORT_FORMAT="html"
# Mail-Befehl (z.B. "msmtp", "sendmail", "mail")
REPORT_MAIL_CMD="msmtp"
# Zeitraum für "Aktivster Tag" im Report (in Tagen)
# Bestimmt, über wie viele Tage zurück der aktivste Tag ermittelt wird.
# 30 = Aktivster Tag der letzten 30 Tage (empfohlen)
# 0 = Nur innerhalb des Berichtszeitraums (altes Verhalten)
REPORT_BUSIEST_DAY_RANGE=30
# --- Externe Blocklist (optional) ---
# Aktiviert den externen Blocklist-Worker
EXTERNAL_BLOCKLIST_ENABLED=false

View File

@@ -8,7 +8,7 @@
# Lizenz: MIT
###############################################################################
VERSION="v0.5.4"
VERSION="v0.6.2"
set -euo pipefail
@@ -230,6 +230,30 @@ format_protocol() {
esac
}
# ─── Hostname-Auflösung ──────────────────────────────────────────────────────
# Versucht den Hostnamen einer IP per Reverse-DNS aufzulösen
resolve_hostname() {
local ip="$1"
local hostname=""
# Versuche Reverse-DNS-Auflösung via dig
if command -v dig &>/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() {
@@ -451,7 +475,7 @@ EOF
# Benachrichtigung senden
if [[ "$NOTIFY_ENABLED" == "true" ]]; then
send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" "$reason" "$window" "$protocol"
send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" "$reason" "$window" "$protocol" "$is_permanent"
fi
# AbuseIPDB Report (nur bei permanenter Sperre)
@@ -489,7 +513,7 @@ unban_client() {
log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason" "-" "$protocol"
if [[ "$NOTIFY_ENABLED" == "true" ]]; then
send_notification "unban" "$client_ip" "" ""
send_notification "unban" "$client_ip" "$domain" ""
fi
}
@@ -531,6 +555,7 @@ send_notification() {
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
@@ -540,46 +565,92 @@ send_notification() {
local reason_label="Rate-Limit"
[[ "$reason" == "subdomain-flood" ]] && reason_label="Subdomain-Flood"
local title
local message
local my_hostname
my_hostname=$(hostname)
if [[ "$action" == "ban" ]]; then
if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -n "$offense_level" ]]; then
message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s via **$protocol**, ${reason_label}). Sperre für **${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]."
else
local simple_dur
simple_dur=$(format_duration "${BAN_DURATION}")
message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s via **$protocol**, ${reason_label}). Sperre für ${simple_dur}."
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
local dur_line
if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -n "$offense_level" ]]; then
dur_line="**${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]"
else
dur_line=$(format_duration "${BAN_DURATION}")
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
message="🟢 AdGuard Shield ${VERSION} wurde gestartet."
title=" AdGuard Shield"
message="🟢 AdGuard Shield ${VERSION} wurde auf ${my_hostname} gestartet."
elif [[ "$action" == "service_stop" ]]; then
message="🔴 AdGuard Shield ${VERSION} wurde gestoppt."
else
message="✅ AdGuard Shield: Client **$client_ip** wurde entsperrt."
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 "{\"content\": \"$message\"}" \
-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 "{\"text\": \"$message\"}" \
-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=AdGuard Shield" \
-F "message=$message" \
-F "title=${title}" \
-F "message=${clean_message}" \
-F "priority=5" &>/dev/null &
;;
ntfy)
send_ntfy_notification "$action" "$message"
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 "{\"message\": \"$message\", \"action\": \"$action\", \"client\": \"${client_ip:-}\", \"domain\": \"${domain:-}\"}" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
esac
@@ -588,7 +659,8 @@ send_notification() {
# ─── Ntfy Benachrichtigung ───────────────────────────────────────────────────
send_ntfy_notification() {
local action="$1"
local message="$2"
local title="$2"
local message="$3"
if [[ -z "${NTFY_TOPIC:-}" ]]; then
log "WARN" "Ntfy: Kein Topic konfiguriert (NTFY_TOPIC ist leer)"
@@ -597,7 +669,6 @@ send_ntfy_notification() {
local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}"
local priority="${NTFY_PRIORITY:-4}"
local title="AdGuard Shield"
local tags
if [[ "$action" == "ban" ]]; then
@@ -614,11 +685,18 @@ send_ntfy_notification() {
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: ${title}"
-H "Title: ${ntfy_title}"
-H "Priority: ${priority}"
-H "Tags: ${tags}"
-d "${clean_message}"

View File

@@ -94,7 +94,7 @@ Sendet einen POST mit JSON-Body:
```json
{
"message": "🚫 AdGuard Shield: Client 192.168.1.50 gesperrt ...",
"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",
"client": "192.168.1.50",
"domain": "microsoft.com"
@@ -113,14 +113,78 @@ Bei Sperren aus der **externen Blocklist** werden Benachrichtigungen separat üb
## Beispiel-Nachrichten
**Service gestartet:**
> 🟢 AdGuard Shield v0.4.0 wurde gestartet.
### Service gestartet
**Überschrift:** ✅ AdGuard Shield
**Service gestoppt:**
> 🔴 AdGuard Shield v0.4.0 wurde gestoppt.
> 🟢 AdGuard Shield v0.6.2 wurde auf dns1 gestartet.
**Sperre:**
> 🚫 AdGuard Shield: Client **192.168.1.50** gesperrt (45x microsoft.com in 60s). Sperre für 3600s.
### Service gestoppt
**Überschrift:** 🚨 🛡️ AdGuard Shield
**Entsperrung:**
> ✅ AdGuard Shield: Client **192.168.1.50** wurde entsperrt.
> 🔴 AdGuard Shield v0.6.2 wurde auf dns1 gestoppt.
### Sperre (Ban)
**Überschrift:** 🚨 🛡️ AdGuard Shield
> 🚫 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
Bei permanenter Sperre mit aktiviertem AbuseIPDB-Reporting erscheint zusätzlich:
> 🚫 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
### Entsperrung (Unban)
**Überschrift:** ✅ AdGuard Shield
> ✅ 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
### Externe Blocklist Sperre
**Überschrift:** 🚨 🛡️ AdGuard Shield
> 🚫 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
### Externe Blocklist Entsperrung
**Überschrift:** ✅ AdGuard Shield
> ✅ 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
### Hinweise
- 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.

View File

@@ -139,6 +139,7 @@ Regelmäßige Statistik-Reports per E-Mail. Voraussetzung ist ein funktionierend
| `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.

View File

@@ -42,6 +42,7 @@ sudo /opt/adguard-shield/report-generator.sh install
| `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
@@ -86,7 +87,7 @@ Die übrigen Zeiträume laufen vom Starttag 00:00 Uhr bis zum Zeitpunkt der Repo
- Rate-Limit Sperren
- Subdomain-Flood Sperren
- Externe Blocklist Sperren
- Aktivster Tag im Berichtszeitraum
- 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)
@@ -175,7 +176,8 @@ Die Templates verwenden Platzhalter (z.B. `{{TOTAL_BANS}}`, `{{TOP10_IPS_TABLE}}
| `{{RATELIMIT_BANS}}` | Rate-Limit Sperren |
| `{{SUBDOMAIN_FLOOD_BANS}}` | Subdomain-Flood Sperren |
| `{{EXTERNAL_BLOCKLIST_BANS}}` | Externe Blocklist Sperren |
| `{{BUSIEST_DAY}}` | Aktivster Tag |
| `{{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) |

View File

@@ -203,6 +203,27 @@ 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"
@@ -213,45 +234,79 @@ send_notification() {
return
fi
local title
local message
local my_hostname
my_hostname=$(hostname)
local client_hostname
client_hostname=$(resolve_hostname "$ip")
if [[ "$action" == "ban" ]]; then
message="🚫 Externe Blocklist: IP **$ip** gesperrt."
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
message="✅ Externe Blocklist: IP **$ip** entsperrt (aus Liste entfernt)."
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 "{\"content\": \"$message\"}" \
-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 "{\"text\": \"$message\"}" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
gotify)
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
-F "title=AdGuard Shield - Externe Blocklist" \
-F "message=$message" \
-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: AdGuard Shield - Externe Blocklist"
-H "Title: ${ntfy_title}"
-H "Priority: ${NTFY_PRIORITY:-3}"
-H "Tags: rotating_light,blocklist"
-d "$(echo "$message" | sed 's/\*\*//g')"
-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 "{\"message\": \"$message\", \"action\": \"$action\", \"client\": \"$ip\", \"source\": \"external-blocklist\"}" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
esac

View File

@@ -6,7 +6,7 @@
# Lizenz: MIT
###############################################################################
VERSION="v0.5.4"
VERSION="v0.6.2"
set -euo pipefail

View File

@@ -41,6 +41,7 @@ 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}"
@@ -178,40 +179,58 @@ 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
# 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"
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
return
fi
[[ ! -f "$BAN_HISTORY_FILE" ]] && return
_load_history_cache
[[ -z "$HISTORY_CACHE" ]] && return
while IFS= read -r line; do
# Kommentare und leere Zeilen überspringen
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
# Zeitstempel extrahieren (erstes Feld, Format: YYYY-MM-DD HH:MM:SS)
local timestamp
timestamp=$(echo "$line" | awk -F'|' '{print $1}' | xargs)
if [[ -z "$timestamp" ]]; then
continue
fi
# Timestamp in Epoch umwandeln
local line_epoch
line_epoch=$(date -d "$timestamp" '+%s' 2>/dev/null || date -j -f '%Y-%m-%d %H:%M:%S' "$timestamp" '+%s' 2>/dev/null || echo "0")
if [[ "$line_epoch" -ge "$start_epoch" && "$line_epoch" -le "$end_epoch" ]]; then
echo "$line"
fi
done < "$BAN_HISTORY_FILE"
# 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)
# 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
@@ -221,29 +240,30 @@ cleanup_ban_history() {
[[ -z "$cutoff_epoch" ]] && return
local tmp_file="${BAN_HISTORY_FILE}.tmp"
local removed=0
local lines_before lines_after
lines_before=$(wc -l < "$BAN_HISTORY_FILE")
while IFS= read -r line; do
# Header-Zeilen immer beibehalten
if [[ "$line" =~ ^#.*$ || -z "$line" ]]; then
echo "$line"
continue
fi
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"
local timestamp
timestamp=$(echo "$line" | awk -F'|' '{print $1}' | xargs)
local line_epoch
line_epoch=$(date -d "$timestamp" '+%s' 2>/dev/null || echo "0")
if [[ "$line_epoch" -ge "$cutoff_epoch" ]]; then
echo "$line"
else
((removed++)) || true
fi
done < "$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
log "INFO" "Ban-History bereinigt: $removed Einträge älter als ${BAN_HISTORY_RETENTION_DAYS} Tage entfernt"
else
rm -f "$tmp_file"
@@ -251,26 +271,38 @@ cleanup_ban_history() {
}
# ─── Statistiken für beliebigen Zeitraum berechnen ──────────────────────────
# Gibt "bans|unbans|unique_ips|permanent" für einen Epochen-Bereich zurück
# 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"
local filtered
filtered=$(filter_history_by_period "$start_epoch" "$end_epoch")
local bans=0 unbans=0 unique_ips=0 permanent=0
if [[ -n "$filtered" ]]; then
bans=$(echo "$filtered" | grep -c '| BAN ' || echo "0")
unbans=$(echo "$filtered" | grep -c '| UNBAN ' || echo "0")
unique_ips=$(echo "$filtered" | grep '| BAN ' | awk -F'|' '{print $3}' | xargs -I{} echo {} | sort -u | wc -l | xargs || echo "0")
permanent=$(echo "$filtered" | grep '| BAN ' | awk -F'|' '{print $6}' | grep -ic 'permanent' || echo "0")
_load_history_cache
if [[ -z "$HISTORY_CACHE" ]]; then
echo "0|0|0|0"
return
fi
echo "${bans}|${unbans}|${unique_ips}|${permanent}"
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)
}
'
}
# ─── 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
@@ -280,11 +312,10 @@ calculate_stats() {
local end_epoch
end_epoch=$(get_period_end_epoch)
local filtered_data
filtered_data=$(filter_history_by_period "$start_epoch" "$end_epoch")
_load_history_cache
# Wenn keine Daten vorhanden, Standardwerte
if [[ -z "$filtered_data" ]]; then
# Wenn keine History-Datei vorhanden, Standardwerte setzen
if [[ -z "$HISTORY_CACHE" ]]; then
TOTAL_BANS=0
TOTAL_UNBANS=0
UNIQUE_IPS=0
@@ -295,6 +326,7 @@ calculate_stats() {
SUBDOMAIN_FLOOD_BANS=0
EXTERNAL_BLOCKLIST_BANS=0
BUSIEST_DAY=""
BUSIEST_DAY_LABEL="Aktivster Tag"
TOP10_IPS=""
TOP10_DOMAINS=""
PROTOCOL_STATS=""
@@ -302,17 +334,105 @@ calculate_stats() {
return
fi
# Gesamtzahl Sperren
TOTAL_BANS=$(echo "$filtered_data" | grep -c '| BAN ' || echo "0")
# Einen einzigen awk-Pass über den Cache: alle Statistiken auf einmal
# Busiest-Day-Bereich berechnen (konfigurierbar, Standard: 30 Tage)
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
# Gesamtzahl Entsperrungen
TOTAL_UNBANS=$(echo "$filtered_data" | grep -c '| UNBAN ' || echo "0")
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]
}
}
')
# Eindeutige IPs (nur BAN-Einträge)
UNIQUE_IPS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $3}' | xargs -I{} echo {} | sort -u | wc -l | xargs)
# 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}')
# Permanente Sperren (Dauer enthält "PERMANENT" oder "permanent")
PERMANENT_BANS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $6}' | grep -ic 'permanent' || echo "0")
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_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
# 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}')
# Aktuell aktive Sperren (aus State-Dateien)
ACTIVE_BANS=0
@@ -322,48 +442,21 @@ calculate_stats() {
done
fi
# AbuseIPDB Reports zeitraum-gefiltert aus der Logdatei
# AbuseIPDB Reports zeitraum-gefiltert aus der Logdatei via awk+mktime
ABUSEIPDB_REPORTS=0
if [[ -f "$LOG_FILE" ]]; then
while IFS= read -r line; do
local ts
ts=$(echo "$line" | sed 's/^\[\([0-9-]* [0-9:]*\)\].*/\1/')
[[ -z "$ts" || "$ts" == "$line" ]] && continue
local le
le=$(date -d "$ts" '+%s' 2>/dev/null || echo "0")
if [[ "$le" -ge "$start_epoch" && "$le" -le "$end_epoch" ]]; then
ABUSEIPDB_REPORTS=$((ABUSEIPDB_REPORTS + 1))
fi
done < <(grep "AbuseIPDB:.*erfolgreich gemeldet" "$LOG_FILE" 2>/dev/null || true)
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
# Angriffsarten
RATELIMIT_BANS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $8}' | grep -ic 'rate-limit' || echo "0")
SUBDOMAIN_FLOOD_BANS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $8}' | grep -ic 'subdomain-flood' || echo "0")
EXTERNAL_BLOCKLIST_BANS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $8}' | grep -ic 'external-blocklist' || echo "0")
# Aktivster Tag
BUSIEST_DAY=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $1}' | xargs -I{} echo {} | awk '{print $1}' | sort | uniq -c | sort -rn | head -1 | awk '{print $2}' || echo "")
if [[ -z "$BUSIEST_DAY" ]]; then
BUSIEST_DAY=""
else
# Datum in DE-Format umwandeln
local busiest_formatted
busiest_formatted=$(date -d "$BUSIEST_DAY" '+%d.%m.%Y' 2>/dev/null || echo "$BUSIEST_DAY")
BUSIEST_DAY="$busiest_formatted"
fi
# Top 10 IPs (nach Häufigkeit der Sperren)
TOP10_IPS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $3}' | xargs -I{} echo {} | sort | uniq -c | sort -rn | head -10)
# Top 10 Domains (nach Häufigkeit)
TOP10_DOMAINS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $4}' | xargs -I{} echo {} | sed 's/^-$//' | grep -v '^$' | sort | uniq -c | sort -rn | head -10)
# Protokoll-Verteilung
PROTOCOL_STATS=$(echo "$filtered_data" | grep '| BAN ' | awk -F'|' '{print $7}' | xargs -I{} echo {} | sed 's/^-$/unbekannt/' | grep -v '^$' | sort | uniq -c | sort -rn)
# Letzte 10 Sperren
RECENT_BANS=$(echo "$filtered_data" | grep '| BAN ' | tail -10 | tac)
}
# ─── HTML-Tabellen generieren ─────────────────────────────────────────────────
@@ -720,6 +813,7 @@ generate_report() {
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}"
@@ -766,6 +860,7 @@ generate_report() {
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}"
@@ -917,6 +1012,7 @@ show_cron_status() {
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

View File

@@ -324,7 +324,7 @@
</div>
<div class="stat-card success">
<div class="stat-value">{{BUSIEST_DAY}}</div>
<div class="stat-label">Aktivster Tag</div>
<div class="stat-label">{{BUSIEST_DAY_LABEL}}</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@
Rate-Limit Sperren: {{RATELIMIT_BANS}}
Subdomain-Flood Sperren: {{SUBDOMAIN_FLOOD_BANS}}
Externe Blocklist: {{EXTERNAL_BLOCKLIST_BANS}}
Aktivster Tag: {{BUSIEST_DAY}}
{{BUSIEST_DAY_LABEL}}: {{BUSIEST_DAY}}
───────────────────────────────────────────────────────────────
🏴‍☠️ TOP 10 AUFFÄLLIGSTE IPs