zektyc/scripts/ipwatch.sh
riricdev 8e7ec41f18 chore: checkpoint initial — Zektyc dataset, avant humanisation
- site vitrine + fingerprint test + zmap + z-panel
- remplacement duckdns -> riricdev.tail5ea5cd.ts.net (canonical/og/alternates/docs)
- suppression du token DuckDNS (plus utilisé)
2026-09-06 13:03:25 +02:00

187 lines
No EOL
7.2 KiB
Shell
Executable file

#!/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="https://discord.com/api/webhooks/***REMOVED***"
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