Add script 'apache-ip-requests-analyze.sh'.

This commit is contained in:
2026-02-23 00:15:49 +01:00
parent 9b3adbdeda
commit cfbc16c041

772
apache-ip-requests-analyze.sh Executable file
View File

@@ -0,0 +1,772 @@
#!/usr/bin/env bash
###############################################################################
# apache-ip-requests-analyze.sh
#
# Zweck
# -----
# Dieses Skript analysiert eine zentrale Apache-Sammellogdatei, in die alle
# VirtualHosts zusätzlich protokollieren, z.B.:
#
# CustomLog /var/log/apache2/ip_requests.log base_requests
# LogFormat "%a %v %p %t %r %>s \"%{User-Agent}i\" %T" base_requests
#
# Beispielzeile:
# 62.138.6.15 www.example.tld 443 [21/Feb/2026:00:00:59 +0100] \
# GET /foo HTTP/1.1 404 "Mozilla/5.0 ..." 0
#
# Hintergrund / Nutzen
# --------------------
# - Erkennen, ob einzelne Sites auffällig viel Traffic bekommen (Site-Angriff).
# - Erkennen, ob einzelne IPs auffällig viel senden (Scanner/Bruteforce).
# - Erkennen von "Spikes" (BURST) pro Minute typisch bei DDoS/Scans.
# - Erkennen von WP-typischen Angriffspfaden (wp-login, xmlrpc, wp-json, …).
#
# ---------------------------------------------------------------------------
# WICHTIG: Automatische Logdatei-Auswahl bei --from/--to (NEU)
# ---------------------------------------------------------------------------
# Default ohne Parameter:
# -> Es wird NUR /var/log/apache2/ip_requests.log ausgewertet
#
# Sobald du aber --from und/oder --to angibst UND du NICHT explizit --log nutzt:
# -> Das Skript schaltet automatisch auf /var/log/apache2/ip_requests.log*
# (also inkl. ip_requests.log.1, .2.gz, .3.gz, ...)
#
# Grund: Bei Zeitraum-Filtern ist das "aktuelle" Log oft nicht mehr ausreichend.
# ---------------------------------------------------------------------------
#
# ---------------------------------------------------------------------------
# Features
# ---------------------------------------------------------------------------
# Input:
# - Standard: nur /var/log/apache2/ip_requests.log
# - Rotationen: per --log auch *.gz und *.1, *.2.gz, ... (z.B. logrotate daily)
# - Auto-Range: bei --from/--to automatisch ip_requests.log* (wenn --log fehlt)
#
# Filter:
# - Zeitraum: --from / --to (lokale Zeit, wie Apache loggt)
# - Request: --status (Regex), --method (Regex), --path-prefix (Prefix)
# - WordPress: --wp-suspects [preset] (typische WP-Endpunkte)
# - Site/VHost: --site (exakt), --site-regex (Regex), --exclude-site (Regex)
#
# Auswertungen (TYPES):
# - Requests pro Site (SITE)
# - Requests pro IP (IP)
# - Unique IPs pro Site (SITE_UNIQIP)
# - Requests pro Site+IP (PAIR) -> "IPs pro Site"
# - Burst pro IP/Minute (BURST_IP) -> "Spikes pro IP"
# - Top User-Agents (UA_G)
# - 404 Pfade pro Site (PATH_404)
# - 5xx Pfade pro Site (PATH_5XX)
#
# Output:
# - --out text (Default): stdout (mit --text-types steuerbar)
# - --out csv (Default Separator ';' für LibreOffice/Excel-DE)
# - --out tsv
# - --out json (JSON Lines)
#
# CSV/TSV Eigenschaften:
# - Blockweise pro TYPE: Headerzeile, dann Daten, Leerzeile zwischen Blöcken
# - Pro TYPE nach COUNT absteigend sortiert
# - --csv-top N begrenzt die Zeilen pro TYPE (Top-N pro Block)
# - --csv-gap / --csv-no-gap: optionale Leerspalten zwischen Spalten
#
# ---------------------------------------------------------------------------
# TYPE-Matrix (Bedeutung der TMP/CSV Records)
# ---------------------------------------------------------------------------
# Intern (TMP):
# TYPE<TAB>COUNT<TAB>KEY1<TAB>KEY2<TAB>KEY3
#
# TYPE | Bedeutung | key1 | key2 | key3
# ----------- | ------------------------------------------- | ---- | ------ | ----
# TOTAL | Gesamtrequests nach allen Filtern | - | - | -
# SITE | Requests pro Site/VHost | site | - | -
# IP | Requests pro IP | ip | - | -
# SITE_UNIQIP | Anzahl unterschiedlicher IPs pro Site | site | - | -
# PAIR | Requests pro (Site,IP) Kombination | site | ip | -
# BURST_IP | Requests pro IP innerhalb einer Minute | ip | minute | -
# UA_G | User-Agent global (alle Sites zusammen) | ua | - | -
# PATH_404 | 404-Pfade je Site (site + path) | site | path | -
# PATH_5XX | 5xx-Pfade je Site (site + path) | site | path | -
#
# Minute-Format:
# - Intern: minute_key = YYYYmmddHHMM
# - In CSV/TSV/Text: YYYY-mm-dd HH:MM
#
# Burst erklärt (IP vs BURST_IP):
# - IP: Gesamtrequests pro IP (über gesamten Zeitraum)
# - BURST_IP: Aktivität pro IP in EINER Minute (Spikes -> Scanner/DDoS)
#
###############################################################################
set -euo pipefail
PROG="$(basename "$0")"
# -----------------------------------------------------------------------------
# Defaults / Konfiguration
# -----------------------------------------------------------------------------
DEFAULT_LOG="/var/log/apache2/ip_requests.log"
# LOG_SPEC ist der "User-Level Input" (Datei/Glob/Liste). Wird später expandiert.
LOG_SPEC="$DEFAULT_LOG"
USER_LOG_SPECIFIED=0 # (NEU) Merker: hat User explizit --log gesetzt?
FROM_EPOCH=""
TO_EPOCH=""
OUT_FORMAT="text" # text|csv|tsv|json
# Text defaults
TOP_N=25
STATUS_TOP_SITES=25
BURST_TOP=25
UA_TOP=25
PATH_ERR_TOP=25
# Stdout selection: Welche Blöcke (TYPES) sollen in Text-Ausgabe erscheinen?
TEXT_TYPES="" # comma list
DEFAULT_TEXT_TYPES="SITE,IP,SITE_UNIQIP,BURST_IP,UA_G,PATH_404,PATH_5XX"
# CSV/TSV controls
CSV_TYPES="" # empty => all
CSV_TOP=0 # 0 => unlimited
CSV_GAP=1 # default: ON
CSV_SEP=";" # default: ';' (LibreOffice)
# Globale Request-Filter (wirken auf alle Auswertungen)
FILTER_STATUS="" # regex, e.g. 404 or '^5..$'
FILTER_METHOD="" # regex, e.g. POST or 'GET|POST'
FILTER_PATH_PREFIX="" # prefix, e.g. /wp-login.php
FILTER_PATH_REGEX="" # internal for WP preset all
# Site/VHost Filter
FILTER_SITE_EXACT="" # exact match
FILTER_SITE_REGEX="" # regex include
FILTER_EXCLUDE_SITE_REGEX="" # regex exclude
# WordPress Shortcuts
WP_SUSPECTS=0
WP_PRESET="all"
# Debug
DEBUG=0
# -----------------------------------------------------------------------------
# Helper-Funktionen
# -----------------------------------------------------------------------------
die() { echo "ERROR: $*" >&2; exit 2; }
# Parse "YYYY-mm-dd [HH:MM:SS]" -> epoch seconds
to_epoch() {
local s="$1"
date -d "$s" +%s 2>/dev/null || die "Kann Datum nicht parsen: '$s'"
}
# Datei lesen, optional gzip transparent
emit_file() {
local f="$1"
if [[ "$f" =~ \.gz$ ]]; then
zcat -- "$f"
else
cat -- "$f"
fi
}
# Expand --log SPEC:
# - kann glob sein (z.B. /var/log/apache2/ip_requests.log*)
# - kann mehrere Pfade in einem String enthalten (in Quotes)
expand_logs() {
local spec="$1"
local -a parts out
local -A seen=()
# shellcheck disable=SC2206
parts=( $spec )
for p in "${parts[@]}"; do
# glob expansion
# shellcheck disable=SC2206
local -a g=( $p )
# wenn kein glob-match und Datei existiert nicht -> skip
if [[ ${#g[@]} -eq 1 && "${g[0]}" == "$p" && ! -e "$p" ]]; then
continue
fi
for f in "${g[@]}"; do
[[ -e "$f" ]] || continue
if [[ -z "${seen[$f]+x}" ]]; then
seen["$f"]=1
out+=("$f")
fi
done
done
printf "%s\n" "${out[@]}"
}
# JSON escape for JSON Lines output
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
echo -n "$s"
}
# Parse comma-separated list into associative array (trim spaces)
# Usage: parse_list_to_map "A,B,C" MAPNAME
parse_list_to_map() {
local list="$1"
local -n _map="$2"
local IFS=,
local item
for item in $list; do
item="${item#"${item%%[![:space:]]*}"}"
item="${item%"${item##*[![:space:]]}"}"
[[ -n "$item" ]] && _map["$item"]=1
done
}
usage() {
cat <<EOF
Usage:
$PROG [OPTIONS]
Input / Logfiles:
Default (ohne Parameter):
-> Nur ${DEFAULT_LOG}
Auto-Range (NEU):
Wenn --from oder --to angegeben wird UND du NICHT --log setzt,
nutzt das Skript automatisch:
${DEFAULT_LOG}*
also inkl. Rotationen (.1, .2.gz, ...)
-h, --help
-l, --log SPEC Datei/Glob/Liste (in Quotes), z.B.:
-l ${DEFAULT_LOG}
-l "${DEFAULT_LOG}*"
-l "${DEFAULT_LOG} ${DEFAULT_LOG}.1"
Zeit:
--from "YYYY-mm-dd[ HH:MM:SS]" Start (inkl.)
--to "YYYY-mm-dd[ HH:MM:SS]" Ende (inkl.)
Request-Filter (wirken auf ALLES):
--status X Statuscode oder Regex, z.B. 404 oder '^5..$'
--method M Methode oder Regex, z.B. POST oder 'GET|POST'
--path-prefix P Pfadprefix, z.B. /wp-login.php
Site-Filter:
--site SITE Nur exakt diese Site (vhost)
--site-regex REGEX Nur Sites die REGEX matchen
--exclude-site REGEX Sites ausblenden, die REGEX matchen
WordPress Shortcuts:
--wp-suspects [PRESET] PRESET = login|xmlrpc|admin|api|cron|all (Default: all)
Matcht typische WP-Endpunkte:
/wp-login.php, /xmlrpc.php, /wp-admin, /wp-json, /wp-cron.php
Output:
--out text|csv|tsv|json Default: text
CSV default Delimiter: ';' (LibreOffice/Excel-DE)
Text-Ausgabe:
--top N Top N (Default: ${TOP_N})
--text-types LIST Welche TYPE-Blöcke in stdout (text) erscheinen sollen.
Beispiel:
--text-types "SITE,IP,BURST_IP,UA_G,PATH_404,PATH_5XX"
Default:
${DEFAULT_TEXT_TYPES}
CSV/TSV:
--csv-types LIST Nur bestimmte TYPE-Blöcke (Komma-Liste)
Beispiel: --csv-types "SITE,IP,BURST_IP"
--csv-top N Pro TYPE nur Top N Zeilen (0=unbegrenzt)
--csv-gap | --csv-no-gap Leerspalte zwischen Spalten (Default: gap ON)
Debug:
--debug TYPE-Counts aus TMP ausgeben
TYPE-Matrix (für CSV/TSV/JSON und --text-types):
TYPE | Bedeutung | key1 | key2 | key3
----------- | --------------------------------------- | ---- | ------ | ----
TOTAL | Gesamtrequests nach Filtern | - | - | -
SITE | Requests pro Site/VHost | site | - | -
IP | Requests pro IP | ip | - | -
SITE_UNIQIP | Unique IPs pro Site | site | - | -
PAIR | Requests pro (site,ip) | site | ip | -
BURST_IP | Requests pro IP in einer Minute | ip | minute | -
UA_G | User-Agent global | ua | - | -
PATH_404 | 404 Pfade je Site (site+path) | site | path | -
PATH_5XX | 5xx Pfade je Site (site+path) | site | path | -
Minute-Format:
minute wird als "YYYY-mm-dd HH:MM" ausgegeben.
Burst erklärt (IP vs BURST_IP):
- IP: Gesamtrequests pro IP im Zeitraum
- BURST_IP: Requests pro IP pro Minute (Spikes -> Scanner/Attacken)
Beispiele (Grundlagen):
# 1) Standard (Text, nur aktuelle Datei):
$PROG
# 2) Zeitraum: Auto-Range greift -> automatisch alle Rotationen:
$PROG --from "2026-02-21 00:00:00" --to "2026-02-21 23:59:59"
# 3) Zeitraum aber bewusst NUR aktuelle Datei (override):
$PROG -l ${DEFAULT_LOG} --from "2026-02-21 00:00:00" --to "2026-02-21 23:59:59"
# 4) Alle Rotationen explizit:
$PROG -l "${DEFAULT_LOG}*"
# 5) Sites die "aktions" enthalten, aber staging ausblenden:
$PROG --site-regex 'aktions' --exclude-site 'staging'
# 6) Nur 404s ansehen (z.B. für Scans):
$PROG --status 404
# 7) Nur POSTs ansehen (z.B. Login-Bruteforce):
$PROG --method POST
Beispiele (Angriff / DDoS / WordPress):
# A) Verdacht auf DDoS / Traffic-Spikes:
# Zeigt stärkste Minuten (gesamt). Hohe Werte = genereller Spike.
$PROG --out csv --csv-types "BURST_TOTAL" --csv-top 50 > ddos-burst-total.csv
# B) DDoS auf einzelne Site (Spikes pro Site/Minute):
$PROG --out csv --csv-types "BURST_SITE" --csv-top 100 > ddos-burst-site.csv
# C) DDoS/Spikes pro IP (Top 200):
$PROG --out csv --csv-types "BURST_IP" --csv-top 200 > burst-ip.csv
# D) "Wer greift welche Site an?" -> Top IPs pro Site (PAIR):
$PROG --out csv --csv-types "PAIR" --csv-top 300 > ips-pro-site.csv
# E) WordPress Suspects (Pfad/404/5xx + Burst + UA + IP):
$PROG --wp-suspects all --out csv --csv-types "PATH_404,PATH_5XX,BURST_IP,UA_G,IP" --csv-top 200 > wp-suspects.csv
# F) Bruteforce: POST auf wp-login.php (eine Site):
$PROG --site www.example.tld --method POST --path-prefix /wp-login.php --out csv --csv-types "IP,BURST_IP,UA_G" --csv-top 300 > wp-login-post.csv
# G) xmlrpc (häufig für Bruteforce/Amplification):
$PROG --path-prefix /xmlrpc.php --out csv --csv-types "SITE,IP,BURST_IP,UA_G,PATH_G" --csv-top 200 > xmlrpc.csv
# H) Viele 404-Scans:
$PROG --status 404 --out csv --csv-types "IP,BURST_IP,PATH_404,UA_G" --csv-top 300 > scan-404.csv
# I) Viele 5xx (Server unter Stress/Fehler):
$PROG --status '^5..$' --out csv --csv-types "SITE,PATH_5XX,IP,UA_G" --csv-top 200 > server-5xx.csv
# J) stdout nur Attack-Indikatoren:
$PROG --text-types "BURST_IP,UA_G,PATH_404,PATH_5XX"
EOF
}
# -----------------------------------------------------------------------------
# Argumente parsen
# -----------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-l|--log)
[[ $# -ge 2 ]] || die "Fehlender Wert nach $1"
LOG_SPEC="$2"
USER_LOG_SPECIFIED=1
shift 2
;;
--from) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FROM_EPOCH="$(to_epoch "$2")"; shift 2 ;;
--to) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; TO_EPOCH="$(to_epoch "$2")"; shift 2 ;;
--status) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_STATUS="$2"; shift 2 ;;
--method) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_METHOD="$2"; shift 2 ;;
--path-prefix) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_PATH_PREFIX="$2"; shift 2 ;;
--site) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_SITE_EXACT="$2"; shift 2 ;;
--site-regex) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_SITE_REGEX="$2"; shift 2 ;;
--exclude-site) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; FILTER_EXCLUDE_SITE_REGEX="$2"; shift 2 ;;
--wp-suspects)
WP_SUSPECTS=1
if [[ $# -ge 2 && ! "$2" =~ ^- ]]; then WP_PRESET="$2"; shift 2; else WP_PRESET="all"; shift; fi
;;
--out)
[[ $# -ge 2 ]] || die "Fehlender Wert nach $1"
case "$2" in text|csv|tsv|json) OUT_FORMAT="$2" ;; *) die "Ungültig: $2" ;; esac
shift 2
;;
--top) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; [[ "$2" =~ ^[0-9]+$ ]] || die "--top erwartet Zahl"; TOP_N="$2"; shift 2 ;;
--text-types)
[[ $# -ge 2 ]] || die "Fehlender Wert nach $1"
TEXT_TYPES="$2"
shift 2
;;
--csv-types) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; CSV_TYPES="$2"; shift 2 ;;
--csv-top) [[ $# -ge 2 ]] || die "Fehlender Wert nach $1"; [[ "$2" =~ ^[0-9]+$ ]] || die "--csv-top erwartet Zahl"; CSV_TOP="$2"; shift 2 ;;
--csv-gap) CSV_GAP=1; shift ;;
--csv-no-gap) CSV_GAP=0; shift ;;
--debug) DEBUG=1; shift ;;
*) die "Unbekannte Option: $1 (nutze --help)" ;;
esac
done
# -----------------------------------------------------------------------------
# Auto-Range Log Auswahl (NEU)
# - Wenn Zeitraum angegeben wurde (from/to) und der User NICHT explizit --log
# gesetzt hat, wechseln wir automatisch auf ip_requests.log*
# -----------------------------------------------------------------------------
if [[ "$USER_LOG_SPECIFIED" -eq 0 ]] && [[ -n "${FROM_EPOCH}${TO_EPOCH}" ]]; then
LOG_SPEC="${DEFAULT_LOG}*"
fi
# -----------------------------------------------------------------------------
# WordPress preset: setzt intern einen Regex-Filter auf typische WP-Endpunkte.
# Regex so formuliert, dass gawk keine Warnungen zu Escape-Sequenzen ausgibt.
# -----------------------------------------------------------------------------
if [[ "$WP_SUSPECTS" -eq 1 ]]; then
case "${WP_PRESET:-all}" in
login) FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX:-/wp-login.php}" ;;
xmlrpc) FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX:-/xmlrpc.php}" ;;
admin) FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX:-/wp-admin}" ;;
api) FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX:-/wp-json}" ;;
cron) FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX:-/wp-cron.php}" ;;
all|"") FILTER_PATH_REGEX="^/(wp-login[.]php|xmlrpc[.]php|wp-admin|wp-json|wp-cron[.]php)([?]|/|$)" ;;
*) die "Unbekanntes WP preset: ${WP_PRESET}. Erlaubt: login|xmlrpc|admin|api|cron|all" ;;
esac
fi
# -----------------------------------------------------------------------------
# Logdateien expandieren (Glob/Liste) und prüfen
# -----------------------------------------------------------------------------
mapfile -t REAL_FILES < <(expand_logs "$LOG_SPEC")
[[ ${#REAL_FILES[@]} -gt 0 ]] || die "Keine existierenden Logdateien aus --log: '$LOG_SPEC'"
# -----------------------------------------------------------------------------
# AWK Core: Parse + Aggregation
# -----------------------------------------------------------------------------
AWK_PROG='
function mon2num(m) {
if (m=="Jan") return 1; if (m=="Feb") return 2; if (m=="Mar") return 3; if (m=="Apr") return 4;
if (m=="May") return 5; if (m=="Jun") return 6; if (m=="Jul") return 7; if (m=="Aug") return 8;
if (m=="Sep") return 9; if (m=="Oct") return 10; if (m=="Nov") return 11; if (m=="Dec") return 12;
return 0
}
function parse_time(ts, a, d, mon, y, hh, mm, ss, mnum, epoch) {
split(ts, a, " "); ts = a[1]
split(ts, a, ":"); hh=a[2]; mm=a[3]; ss=a[4]
split(a[1], a, "/"); d=a[1]; mon=a[2]; y=a[3]
mnum=mon2num(mon); if (mnum==0) return -1
epoch=mktime(sprintf("%d %d %d %d %d %d", y, mnum, d, hh, mm, ss))
minute_key = sprintf("%04d%02d%02d%02d%02d", y, mnum, d, hh, mm)
return epoch
}
BEGIN {
from = (FROM_EPOCH=="" ? -1 : FROM_EPOCH+0)
to = (TO_EPOCH=="" ? -1 : TO_EPOCH+0)
f_status = FILTER_STATUS
f_method = FILTER_METHOD
f_prefix = FILTER_PATH_PREFIX
f_regex = FILTER_PATH_REGEX
site_exact = FILTER_SITE_EXACT
site_re = FILTER_SITE_REGEX
ex_site_re = FILTER_EXCLUDE_SITE_REGEX
}
{
ip = $1
site = $2
if (site_exact != "" && site != site_exact) next
if (site_re != "" && site !~ site_re) next
if (ex_site_re != "" && site ~ ex_site_re) next
ts = $4 " " $5
gsub(/^\[/, "", ts); gsub(/\]$/, "", ts)
epoch = parse_time(ts)
if (epoch < 0) next
if (from != -1 && epoch < from) next
if (to != -1 && epoch > to) next
method = $6
path = $7
proto = $8
status = $9
if (proto !~ /^HTTP\//) next
ua=""
q1=index($0, "\"")
if (q1>0) {
rest=substr($0, q1+1)
q2=index(rest, "\"")
if (q2>0) ua=substr(rest, 1, q2-1)
}
if (f_status != "" && status !~ f_status) next
if (f_method != "" && method !~ f_method) next
if (f_prefix != "" && index(path, f_prefix) != 1) next
if (f_regex != "" && path !~ f_regex) next
total++
sites[site]++
ips[ip]++
k = site SUBSEP ip
pair[k]++
if (!(k in seen_pair)) { seen_pair[k]=1; uniq_ip_count[site]++ }
burst_ip[ip SUBSEP minute_key]++
if (ua != "") ua_global[ua]++
if (status=="404") path404_site[site SUBSEP path]++
if (status ~ /^5../) path5xx_site[site SUBSEP path]++
}
END {
for (s in sites) print "SITE\t" sites[s] "\t" s
for (i in ips) print "IP\t" ips[i] "\t" i
for (k in pair) { split(k,a,SUBSEP); print "PAIR\t" pair[k] "\t" a[1] "\t" a[2] }
for (s in uniq_ip_count) print "SITE_UNIQIP\t" uniq_ip_count[s] "\t" s
for (k in burst_ip) { split(k,a,SUBSEP); print "BURST_IP\t" burst_ip[k] "\t" a[1] "\t" a[2] }
for (ua in ua_global) print "UA_G\t" ua_global[ua] "\t" ua
for (k in path404_site) { split(k,a,SUBSEP); print "PATH_404\t" path404_site[k] "\t" a[1] "\t" a[2] }
for (k in path5xx_site) { split(k,a,SUBSEP); print "PATH_5XX\t" path5xx_site[k] "\t" a[1] "\t" a[2] }
print "TOTAL\t" total "\t-"
}
'
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
{
for f in "${REAL_FILES[@]}"; do emit_file "$f"; done
} | gawk \
-v FROM_EPOCH="${FROM_EPOCH}" \
-v TO_EPOCH="${TO_EPOCH}" \
-v FILTER_STATUS="${FILTER_STATUS}" \
-v FILTER_METHOD="${FILTER_METHOD}" \
-v FILTER_PATH_PREFIX="${FILTER_PATH_PREFIX}" \
-v FILTER_PATH_REGEX="${FILTER_PATH_REGEX}" \
-v FILTER_SITE_EXACT="${FILTER_SITE_EXACT}" \
-v FILTER_SITE_REGEX="${FILTER_SITE_REGEX}" \
-v FILTER_EXCLUDE_SITE_REGEX="${FILTER_EXCLUDE_SITE_REGEX}" \
"$AWK_PROG" > "$TMP"
TOTAL="$(gawk -F'\t' '$1=="TOTAL"{print $2}' "$TMP")"
# -----------------------------------------------------------------------------
# CSV/TSV Export
# -----------------------------------------------------------------------------
emit_records_delim() {
local delim="$1"
LC_ALL=C sort -t $'\t' -k1,1 -k2,2nr "$TMP" \
| gawk -F'\t' -v TYPES="$CSV_TYPES" -v TOPN="$CSV_TOP" -v GAP="$CSV_GAP" -v SEP="$delim" '
function q(s){ gsub(/\t/," ",s); gsub(/\r/," ",s); gsub(/\n/," ",s); gsub(/"/,"\"\"",s); return "\"" s "\"" }
function fmt_min(k){
if (k ~ /^[0-9]{12}$/) return substr(k,1,4) "-" substr(k,5,2) "-" substr(k,7,2) " " substr(k,9,2) ":" substr(k,11,2)
return k
}
function init_types(n,i,a){
if (TYPES==""){ use_all=1; return }
use_all=0
n=split(TYPES,a,",")
for(i=1;i<=n;i++){ gsub(/^[ \t]+|[ \t]+$/,"",a[i]); if(a[i]!="") want[a[i]]=1 }
}
function allowed(t){ return (use_all || (t in want)) }
function emit3(a,b,c){ if(GAP==1) print a SEP "" SEP b SEP "" SEP c; else print a SEP b SEP c }
function emit4(a,b,c,d){ if(GAP==1) print a SEP "" SEP b SEP "" SEP c SEP "" SEP d; else print a SEP b SEP c SEP d }
function header(t){
if(did_any) print ""
if (t=="SITE") emit3("rank","count","site")
else if (t=="IP") emit3("rank","count","ip")
else if (t=="PAIR") emit4("rank","count","site","ip")
else if (t=="SITE_UNIQIP") emit3("rank","unique_ips","site")
else if (t=="BURST_IP") emit4("rank","count","ip","minute")
else if (t=="UA_G") emit3("rank","count","user_agent")
else if (t=="PATH_404") emit4("rank","count","site","path_404")
else if (t=="PATH_5XX") emit4("rank","count","site","path_5xx")
else if (t=="TOTAL") print "total_requests"
else emit4("rank","count","key1","key2")
did_any=1
}
BEGIN{ prev=""; rank=0; did_any=0; topn=(TOPN+0); init_types() }
{
t=$1; c=$2; k1=$3; k2=$4
if(!allowed(t)) next
if(t!=prev){ prev=t; rank=0; header(t) }
if(t=="TOTAL"){ print c; next }
rank++
if(topn>0 && rank>topn) next
if (t=="BURST_IP") k2=fmt_min(k2)
if (t=="SITE") emit3(rank,c,q(k1))
else if (t=="IP") emit3(rank,c,q(k1))
else if (t=="PAIR") emit4(rank,c,q(k1),q(k2))
else if (t=="SITE_UNIQIP") emit3(rank,c,q(k1))
else if (t=="BURST_IP") emit4(rank,c,q(k1),q(k2))
else if (t=="UA_G") emit3(rank,c,q(k1))
else if (t=="PATH_404") emit4(rank,c,q(k1),q(k2))
else if (t=="PATH_5XX") emit4(rank,c,q(k1),q(k2))
else emit4(rank,c,q(k1),q(k2))
}
'
}
emit_records_json() {
LC_ALL=C sort -t $'\t' -k1,1 -k2,2nr "$TMP" \
| while IFS=$'\t' read -r t c k1 k2 k3; do
if [[ -n "$CSV_TYPES" ]]; then
case ",$CSV_TYPES," in *",$t,"*) ;; *) continue ;; esac
fi
printf '{"type":"%s","count":%s,"key1":"%s","key2":"%s","key3":"%s"}\n' \
"$(json_escape "${t:-}")" "${c:-0}" \
"$(json_escape "${k1:-}")" "$(json_escape "${k2:-}")" "$(json_escape "${k3:-}")"
done
}
case "$OUT_FORMAT" in
csv) emit_records_delim "$CSV_SEP"; exit 0 ;;
tsv) emit_records_delim $'\t'; exit 0 ;;
json) emit_records_json; exit 0 ;;
text) : ;;
*) die "Unbekanntes --out: $OUT_FORMAT" ;;
esac
# -----------------------------------------------------------------------------
# Text-Ausgabe: Auswahl via --text-types
# -----------------------------------------------------------------------------
declare -A WANT_TEXT=()
if [[ -n "$TEXT_TYPES" ]]; then
parse_list_to_map "$TEXT_TYPES" WANT_TEXT
else
parse_list_to_map "$DEFAULT_TEXT_TYPES" WANT_TEXT
fi
text_wants() {
local t="$1"
[[ -n "${WANT_TEXT[$t]+x}" ]]
}
# -----------------------------------------------------------------------------
# Text-Ausgabe Header (Kontext)
# -----------------------------------------------------------------------------
echo ""
echo "== Apache ip_requests.log Analyse =="
echo "Dateien:"
for f in "${REAL_FILES[@]}"; do echo " - $f"; done
if [[ -n "${FROM_EPOCH}" || -n "${TO_EPOCH}" ]]; then
[[ -n "${FROM_EPOCH}" ]] && echo "Von: $(date -d "@${FROM_EPOCH}" "+%F %T %z")"
[[ -n "${TO_EPOCH}" ]] && echo "Bis: $(date -d "@${TO_EPOCH}" "+%F %T %z")"
fi
if [[ -n "${FILTER_STATUS}${FILTER_METHOD}${FILTER_PATH_PREFIX}${FILTER_PATH_REGEX}${FILTER_SITE_EXACT}${FILTER_SITE_REGEX}${FILTER_EXCLUDE_SITE_REGEX}" ]]; then
echo "Filter:"
[[ -n "${FILTER_STATUS}" ]] && echo " Status: ${FILTER_STATUS}"
[[ -n "${FILTER_METHOD}" ]] && echo " Methode: ${FILTER_METHOD}"
[[ -n "${FILTER_PATH_PREFIX}" ]] && echo " Path-Prefix: ${FILTER_PATH_PREFIX}"
[[ -n "${FILTER_PATH_REGEX}" ]] && echo " Path-Regex: ${FILTER_PATH_REGEX}"
[[ -n "${FILTER_SITE_EXACT}" ]] && echo " Site exact: ${FILTER_SITE_EXACT}"
[[ -n "${FILTER_SITE_REGEX}" ]] && echo " Site regex: ${FILTER_SITE_REGEX}"
[[ -n "${FILTER_EXCLUDE_SITE_REGEX}" ]] && echo " Exclude site: ${FILTER_EXCLUDE_SITE_REGEX}"
fi
echo "Gesamt (nach Filter): ${TOTAL}"
echo
if [[ "$DEBUG" -eq 1 ]]; then
echo "== DEBUG: Record-Counts pro TYPE (TMP) =="
gawk -F'\t' '{c[$1]++} END{for(t in c) printf "%-12s %d\n", t, c[t]}' "$TMP" | sort || true
echo
fi
# -----------------------------------------------------------------------------
# Text-Blöcke (je Type)
# -----------------------------------------------------------------------------
if text_wants "SITE"; then
echo "== Top ${TOP_N} Sites (Requests) =="
gawk -F'\t' '$1=="SITE"{print $2 "\t" $3}' "$TMP" | sort -nr -k1,1 | head -n "$TOP_N" \
| gawk -F'\t' 'BEGIN{printf "%-10s %s\n","COUNT","SITE"} {printf "%-10s %s\n",$1,$2}' || true
echo
fi
if text_wants "IP"; then
echo "== Top ${TOP_N} IPs (Requests gesamt) =="
gawk -F'\t' '$1=="IP"{print $2 "\t" $3}' "$TMP" | sort -nr -k1,1 | head -n "$TOP_N" \
| gawk -F'\t' 'BEGIN{printf "%-10s %s\n","COUNT","IP"} {printf "%-10s %s\n",$1,$2}' || true
echo
fi
if text_wants "SITE_UNIQIP"; then
echo "== Unique IPs pro Site (Top ${TOP_N}) =="
gawk -F'\t' '$1=="SITE_UNIQIP"{print $2 "\t" $3}' "$TMP" | sort -nr -k1,1 | head -n "$TOP_N" \
| gawk -F'\t' 'BEGIN{printf "%-10s %s\n","UNIQ_IPS","SITE"} {printf "%-10s %s\n",$1,$2}' || true
echo
fi
if text_wants "BURST_IP"; then
echo "== Top ${BURST_TOP} BURST_IP (Requests pro IP pro Minute) =="
echo "Hinweis: IP=gesamt; BURST_IP=Spikes pro Minute (oft Scanner/Attacke)."
gawk -F'\t' '
$1=="BURST_IP"{
c=$2; ip=$3; m=$4;
if (m ~ /^[0-9]{12}$/) mm=substr(m,1,4)"-"substr(m,5,2)"-"substr(m,7,2)" "substr(m,9,2)":"substr(m,11,2); else mm=m;
print c "\t" ip "\t" mm
}' "$TMP" | sort -nr -k1,1 | head -n "$BURST_TOP" \
| gawk -F'\t' 'BEGIN{printf "%-10s %-40s %s\n","COUNT","IP","MINUTE"} {printf "%-10s %-40s %s\n",$1,$2,$3}' || true
echo
fi
if text_wants "UA_G"; then
echo "== Top ${UA_TOP} User-Agents (global) =="
gawk -F'\t' '$1=="UA_G"{print $2 "\t" $3}' "$TMP" | sort -nr -k1,1 | head -n "$UA_TOP" \
| gawk -F'\t' 'BEGIN{printf "%-10s %s\n","COUNT","USER-AGENT"} {printf "%-10s %s\n",$1,$2}' || true
echo
fi
if text_wants "PATH_404"; then
echo "== Top ${PATH_ERR_TOP} PATH_404 (site + path) =="
gawk -F'\t' '$1=="PATH_404"{print $2 "\t" $3 "\t" $4}' "$TMP" | sort -nr -k1,1 | head -n "$PATH_ERR_TOP" \
| gawk -F'\t' 'BEGIN{printf "%-10s %-30s %s\n","COUNT","SITE","PATH_404"} {printf "%-10s %-30s %s\n",$1,$2,$3}' || true
echo
fi
if text_wants "PATH_5XX"; then
echo "== Top ${PATH_ERR_TOP} PATH_5XX (site + path) =="
gawk -F'\t' '$1=="PATH_5XX"{print $2 "\t" $3 "\t" $4}' "$TMP" | sort -nr -k1,1 | head -n "$PATH_ERR_TOP" \
| gawk -F'\t' 'BEGIN{printf "%-10s %-30s %s\n","COUNT","SITE","PATH_5XX"} {printf "%-10s %-30s %s\n",$1,$2,$3}' || true
echo
fi
echo "Tipp (LibreOffice):"
echo " $PROG --out csv --csv-top 100 > report.csv"
echo " (CSV Delimiter ';' default; --csv-no-gap ohne Leer-Spalten)"
echo ""
exit 0