#!/usr/bin/env bash # Zektyc — moniteur d'IP suspectes (staff only) # # Toutes les 30 s, analyse deux sources pour détecter des IP anormales qui # tapent sur la stack, et envoie un rapport (embed) sur le webhook Discord staff. # # Source 1 — pare-feu netfilter : log kernel "UFW BLOCK" (vraies IP externes, # probes, scanners, SYN floods résiduels au niveau réseau). # # Source 2 — reverse-proxy nginx : refus HTTP 429/503 émis par les limites # par IP réelle (limit_req / limit_conn). Comme la terminaison TLS a # été déplacée vers nginx (mode funnel tcp brut + PROXY protocol), # c'est ICI qu'apparaissent les attaquants d'applicatif (bursts, # h2load, etc.) que le pare-feu réseau ne voit plus. On relit le log # à partir d'un offset pour ne traiter que les lignes nouvelles. # # Anti-re-spam : une IP déjà signalée n'est renvoyée que si son compteur de # tentatives a augmenté depuis le dernier rapport. set -o pipefail # Webhook Discord chargé depuis un fichier local gitignoré (jamais commité). source /home/riricdev/zektyc/scripts/.webhooks WEBHOOK="${IPWATCH_WEBHOOK:-}" INTERVAL=30 STATE="/home/riricdev/zektyc/scripts/.ipwatch-state" # IP:compteur déjà signalés NGINX_LOG="/var/log/nginx/access.log" # Taille (octets) du bloc de fin du log relue à chaque cycle puis filtrée au # timestamp : couvre INTERVAL s même au pic (h2load). Rotation-safe, pas d'offset. TL_BACK=16777216 # 16 Mo # IP publique ? 0=oui(suspecte possible) 1=non. On exclut aussi le CGNAT # Tailscale (100.64.0.0/10) pour ne pas signaler le VPN légitime / les tests. is_public() { local ip="$1" a b c d IFS='.' read -r a b c d <<<"$ip" [[ "$a" == "127" ]] && return 1 [[ "$a" == "10" ]] && return 1 [[ "$a" == "192" && "$b" == "168" ]] && return 1 [[ "$a" == "172" && "$b" -ge 16 && "$b" -le 31 ]] && return 1 [[ "$a" == "100" && "$b" -ge 64 && "$b" -le 127 ]] && return 1 [[ "$a" == "169" && "$b" == "254" ]] && return 1 [[ "$a" -ge 224 && "$a" -le 239 ]] && return 1 [[ "$a" == "0" || "$a" == "255" ]] && return 1 return 0 } # Envoie l'embed. Retour 0 si envoyé. send_report() { local report_txt="$1" now now=$(date +"%Y-%m-%d %H:%M:%S") local json json=$(jq -n \ --arg title "🚨 IP suspectes détectées" \ --arg desc "$report_txt" \ --arg time "$now" \ --argjson color 15548997 \ '{embeds:[{title:$title,description:$desc,color:$color,footer:{text:("Zektyc Sécu — "+$time)}}]}') curl -s -m 10 -H "Content-Type: application/json" -X POST "$WEBHOOK" -d "$json" >/dev/null 2>&1 } # Charge l'état des IP déjà signalées (format "IP compteur" par ligne) load_state() { declare -gA last=() if [[ -f "$STATE" ]]; then while read -r k v; do last["$k"]="$v"; done <"$STATE" fi } # Sauvegarde l'état save_state() { : >"$STATE" for k in "${!last[@]}"; do printf '%s %s\n' "$k" "${last[$k]}" >>"$STATE"; done } # --- Source 2 : relit la FIN du log nginx et garde les refus des 30 dernières s --- # Pas d'offset : on lit un bloc fixe de la fin du fichier (TL_BACK, dimensionné # pour couvrir INTERVAL s même au pic de trafic) puis on ne garde QUE les lignes # dont l'horodatage est dans la fenêtre [ début, date_actuelle ]. Robuste face # aux rotations/troncatures, aucun offset à resynchroniser manuellement. # Tout le filtrage se fait dans UN SEUL awk (pas de fork par ligne). read_nginx_refus() { local awkprog ip c ts uals from from="$(date -d "@$(( now_s - INTERVAL ))" +%d/%b/%Y:%H:%M:%S)" awkprog=' function ispub(ip, a,b,c,d,p,n) { n=split(ip,p,"."); if (n!=4) return 0 a=p[1]+0; b=p[2]+0; c=p[3]+0; d=p[4]+0 if (a==127||a==10||(a==192&&b==168)||(a==172&&b>=16&&b<=31)\ ||(a==100&&b>=64&&b<=127)||(a==169&&b==254)) return 0 if (a>=224&&a<=239||a==0||a==255) return 0 return 1 } { # Gardes de structure : le buffer tail -c commence en plein milieu d une # ligne -> son 1er enregistrement est un fragment casse. On n accepte que # les lignes completes : IP v4 en tete + timestamp nginx complet en $3. if ($1 !~ /^[0-9]{1,3}(\.[0-9]{1,3}){3}$/) next ts=$3; sub(/^\[/,"",ts) if (ts !~ /^[0-9]{2}\/[A-Za-z]{3}\/[0-9]{4}:[0-9]{2}:[0-9]{2}:[0-9]{2}/) next # nginx logue en local time (+0200) : on n accepte que les lignes >= debut fenetre if (ts < from) next code="" for (i=1;i<=NF;i++) if ($i ~ /^[0-9]{3}$/ && (i+1)<=NF && $(i+1) ~ /^[0-9]+$/) code=$i if (code!="429" && code!="503") next ip=$1 if (!ispub(ip)) next cnt[ip]++ n=split($0,q,"\"") ua=(n>=2 ? substr(q[n-1],1,40) : "") if (ua!="" && !((ip SUBSEP ua) in seen)) { seen[ip SUBSEP ua]=1; ual[ip]=(ual[ip]==""?ua:ual[ip]","ua) } last[ip]=ts } END { for (ip in cnt) print ip "|" cnt[ip] "|" last[ip] "|" ual[ip] } ' while IFS='|' read -r ip c ts uals; do cnt["$ip"]=$(( ${cnt["$ip"]:-0} + c )) [[ "${protos["$ip"]:-}" != *"http"* ]] && protos["$ip"]="${protos["$ip"]:-}http," [[ "${dstports["$ip"]:-}" != *"443"* ]] && dstports["$ip"]="${dstports["$ip"]:-}443," lastts["$ip"]="$ts" [[ -n "$uals" ]] && uas["$ip"]="${uals}," done < <(tail -c "$TL_BACK" "$NGINX_LOG" | awk -v "from=$from" "$awkprog") } load_state echo "IP-watch démarré — fenêtre ${INTERVAL}s/signalement (ufw + nginx), webhook staff (actif)." while true; do now_s=$(date +%s) since=$(( now_s - INTERVAL )) declare -A cnt=() declare -A protos=() declare -A dstports=() declare -A lastts=() declare -A uas=() # --- source 1 : blocs netfilter (log kernel UFW BLOCK) --- while IFS= read -r ln; do [[ "$ln" != *"UFW BLOCK"* ]] && continue if [[ "$ln" =~ \ SRC=([0-9.]+)\ ]]; then ip="${BASH_REMATCH[1]}" is_public "$ip" || continue cnt["$ip"]=$(( ${cnt["$ip"]:-0} + 1 )) if [[ "$ln" =~ \ PROTO=([A-Za-z0-9]+)\ ]]; then p="${BASH_REMATCH[1]}" [[ "${protos["$ip"]:-}" != *"$p"* ]] && protos["$ip"]="${protos["$ip"]:-}$p," fi if [[ "$ln" =~ \ DPT=([0-9]+) ]]; then d="${BASH_REMATCH[1]}" [[ "${dstports["$ip"]:-}" != *"$d"* ]] && dstports["$ip"]="${dstports["$ip"]:-}$d," fi if [[ "$ln" =~ ^[A-Z][a-z]{2}\ [0-9]{2}\ [0-9]{2}\:[0-9]{2}\:[0-9]{2} ]]; then lastts["$ip"]="${BASH_REMATCH[0]}" fi fi done < <(sudo journalctl -k --no-pager --since "@${since}" 2>/dev/null | grep "UFW BLOCK") # --- source 2 : refus HTTP nginx (429/503 par IP réelle) --- read_nginx_refus # --- filtrer : ne signaler que les IP nouvelles ou dont le compteur a augmenté --- newfound=0 report="" for ip in "${!cnt[@]}"; do c="${cnt[$ip]}" prev="${last["$ip"]:-0}" if (( c > prev )); then proto="${protos[$ip]%,}" dports="${dstports[$ip]%,}" ua="${uas[$ip]%,}" report+=$':shield: **`'"${ip}"$'`** — **'"${c}"$'** tentative(s) bloquée(s)\n' report+=$'└ Sources: `'"${proto:-?}"$'` | Ports cibles: `'"${dports:-?}"$'`\n' if [[ -n "$ua" ]]; then report+=$'└ User-Agent: `'"${ua}"$'`\n' fi report+=$'└ Dernière tentative: `'"${lastts[$ip]:-?}"$'`\n\n' last["$ip"]="$c" ((newfound++)) fi done if (( newfound > 0 )); then send_report "$report" fi save_state sleep "$INTERVAL" done