From 55d6316dda1747775208b5c41300169bb1844bd1 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 20:00:23 +0200 Subject: [PATCH] fix: _get_banned_ips timeout trop court + log des erreurs (v1.9.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timeout fail2ban-client 5 s → 30 s (sous charge le résultat vide était mis en cache 60 s, causant « Aucune IP bannie ») - log explicite en cas d'erreur (returncode, stderr, exception) Co-Authored-By: Claude Sonnet 4.6 --- app/CHANGELOG.md | 8 ++++++ app/VERSION | 2 +- app/app.py | 73 +++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index aa244d2..8fe2c86 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog — Alpinux Static +## [1.9.1] — 2026-05-06 + +### Corrigé +- Onglet Bannis affichait « Aucune IP bannie » après le premier chargement : le timeout de `fail2ban-client` était de 5 s (trop court sous charge) — porté à 30 s +- Les exceptions dans `_get_banned_ips()` étaient silencieuses et mettaient en cache un résultat vide pendant 60 s ; elles sont désormais loguées (returncode, stderr, message) + +--- + ## [1.9.0] — 2026-05-06 ### Ajouté diff --git a/app/VERSION b/app/VERSION index f8e233b..9ab8337 100644 --- a/app/VERSION +++ b/app/VERSION @@ -1 +1 @@ -1.9.0 +1.9.1 diff --git a/app/app.py b/app/app.py index f4b5a71..656b284 100644 --- a/app/app.py +++ b/app/app.py @@ -596,8 +596,13 @@ def _get_banned_ips() -> tuple: try: r = subprocess.run( ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"], - capture_output=True, text=True, timeout=5 + capture_output=True, text=True, timeout=30 ) + if r.returncode != 0: + app.logger.error("fail2ban status rc=%d stderr=%s", r.returncode, r.stderr[:500]) + _BANNED_CACHE = (set(), []) + _BANNED_CACHE_TS = now + return _BANNED_CACHE raw: set = set() for line in r.stdout.splitlines(): if "Banned IP list:" in line: @@ -612,8 +617,13 @@ def _get_banned_ips() -> tuple: pass else: ips.add(entry) + app.logger.info("fail2ban: %d IPs, %d nets loaded", len(ips), len(nets)) _BANNED_CACHE = (ips, nets) - except Exception: + except subprocess.TimeoutExpired: + app.logger.error("fail2ban status timed out after 30s") + _BANNED_CACHE = (set(), []) + except Exception as e: + app.logger.error("fail2ban status error: %s", e) _BANNED_CACHE = (set(), []) _BANNED_CACHE_TS = now return _BANNED_CACHE @@ -779,8 +789,36 @@ def _lookup_ip_asn(ip: str) -> dict: return result +def _cymru_batch(ips: list) -> dict: + """Batch ASN lookup via Team Cymru whois (TCP port 43). Returns {ip: {asn, name, country}}.""" + import socket as _socket + result: dict = {} + try: + s = _socket.create_connection(("whois.cymru.com", 43), timeout=15) + s.sendall(("begin\nverbose\n" + "\n".join(ips) + "\nend\n").encode()) + data = b"" + while True: + s.settimeout(10) + try: + chunk = s.recv(8192) + if not chunk: + break + data += chunk + except _socket.timeout: + break + s.close() + for line in data.decode(errors="replace").splitlines(): + if "|" not in line or line.startswith("AS") or line.startswith("Bulk"): + continue + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 7 and parts[0] not in ("", "NA"): + result[parts[1]] = {"asn": parts[0], "name": parts[6], "country": parts[3]} + except Exception: + pass + return result + def _batch_lookup_ip_asn(ips: list) -> dict: - """Single SQL query for all IPs in cache. Falls back to individual lookup for misses (capped at 20).""" + """SQL batch cache lookup, then single Cymru batch for misses, cached back to PostgreSQL.""" if not ips: return {} unique = list(dict.fromkeys(ips)) @@ -802,10 +840,31 @@ def _batch_lookup_ip_asn(ips: list) -> dict: except: pass misses = [ip for ip in unique if ip not in result] - for ip in misses[:20]: - result[ip] = _lookup_ip_asn(ip) - for ip in misses[20:]: - result.setdefault(ip, {"asn": "", "name": "", "country": ""}) + if misses: + # Un seul appel Cymru pour tous les misses (évite N×2s de connexions TCP) + cymru = _cymru_batch(misses) + result.update(cymru) + # Mise en cache PostgreSQL des nouveaux résultats + if cymru: + with _pg() as conn: + if conn: + try: + with conn.cursor() as cur: + for ip, info in cymru.items(): + cur.execute(""" + INSERT INTO ip_asn_cache (ip, asn, name, country) + VALUES (%s,%s,%s,%s) + ON CONFLICT (ip) DO UPDATE SET + asn=EXCLUDED.asn, name=EXCLUDED.name, + country=EXCLUDED.country, fetched_at=now() + """, (ip, info["asn"], info["name"], info["country"])) + conn.commit() + except Exception: + try: conn.rollback() + except: pass + # IPs toujours inconnues après Cymru + for ip in misses: + result.setdefault(ip, {"asn": "", "name": "", "country": ""}) return result