From 5d2a4ab43087bf34b92ee432189716f5f00502a5 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Wed, 6 May 2026 13:31:35 +0200 Subject: [PATCH] feat(erreurs): ban AS entier + masquer IPs bannies (#43, #44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clic πŸ”¨ : lookup AS via ip-api.com, propose πŸ”¨ IP ou πŸ”¨ AS (N prΓ©fixes) - Ban AS : rΓ©cupΓ¨re les CIDRs via RIPE Stat, cache 30 j dans as_cache/ - IPs dΓ©jΓ  bannies (global-blacklist) masquΓ©es du tableau et du dΓ©tail AJAX - ignoreip fail2ban : 82.65.88.34 protΓ©gΓ©e sur toutes les jails - Sudoers : permission status global-blacklist pour static-cdn Co-Authored-By: Claude Sonnet 4.6 --- app/CHANGELOG.md | 13 +++ app/VERSION | 2 +- app/app.py | 148 ++++++++++++++++++++++++++++++++-- app/static/app.css | 9 +++ app/templates/errors_404.html | 73 ++++++++++++----- scripts/pull-assets.sh | 14 ++-- scripts/push-assets.sh | 36 ++++++--- 7 files changed, 251 insertions(+), 44 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index eb6e0a0..4e99fa9 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog β€” Alpinux Static +## [1.6.0] β€” 2026-05-06 + +### AjoutΓ© +- Erreurs 404 : clic sur πŸ”¨ affiche l'AS de l'IP (via ip-api.com) avec le nom, le pays et le nombre de prΓ©fixes IPv4 +- Bannir l'IP seule **ou** bannir tout l'AS d'un coup (tous ses prΓ©fixes CIDR via RIPE Stat, cache 30 jours) +- Erreurs 404 : les IPs dΓ©jΓ  bannies dans fail2ban (`global-blacklist`) sont masquΓ©es de la liste et du dΓ©tail + +### ModifiΓ© +- fail2ban `ignoreip` : ton IP publique (`82.65.88.34`) protΓ©gΓ©e sur toutes les jails +- Sudoers `static-cdn` : ajout de la permission `fail2ban-client status global-blacklist` + +--- + ## [1.5.2] β€” 2026-05-06 ### AjoutΓ© diff --git a/app/VERSION b/app/VERSION index 4cda8f1..dc1e644 100644 --- a/app/VERSION +++ b/app/VERSION @@ -1 +1 @@ -1.5.2 +1.6.0 diff --git a/app/app.py b/app/app.py index 11c2b49..a1e9415 100644 --- a/app/app.py +++ b/app/app.py @@ -1,5 +1,6 @@ import gzip import io +import ipaddress import json import os import re @@ -7,6 +8,7 @@ import shutil import subprocess import threading import time +import urllib.request from pathlib import Path from datetime import datetime, timedelta @@ -63,6 +65,12 @@ _404_CACHE: dict = {} _404_CACHE_TS: float = 0 _404_CACHE_TTL = 300 # 5 min +_AS_CACHE_DIR = Path(__file__).parent / "as_cache" +_AS_CACHE_TTL = 30 * 24 * 3600 # 30 jours +_BANNED_CACHE: tuple = (set(), []) +_BANNED_CACHE_TS: float = 0 +_BANNED_CACHE_TTL = 60 # 1 min + _HIDDEN = frozenset({ ".git", "scripts", "app", @@ -565,6 +573,88 @@ def _invalidate_404_cache(): _404_CACHE_TS = 0 +# ── Bannissement fail2ban β€” helpers ─────────────────────────────────── + +def _get_banned_ips() -> tuple: + """Returns (set_of_ips, list_of_networks) from fail2ban global-blacklist.""" + global _BANNED_CACHE, _BANNED_CACHE_TS + now = time.time() + if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL: + return _BANNED_CACHE + try: + r = subprocess.run( + ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"], + capture_output=True, text=True, timeout=5 + ) + raw: set = set() + for line in r.stdout.splitlines(): + if "Banned IP list:" in line: + raw = set(line.split("Banned IP list:")[1].split()) + break + ips, nets = set(), [] + for entry in raw: + if "/" in entry: + try: + nets.append(ipaddress.ip_network(entry, strict=False)) + except ValueError: + pass + else: + ips.add(entry) + _BANNED_CACHE = (ips, nets) + except Exception: + _BANNED_CACHE = (set(), []) + _BANNED_CACHE_TS = now + return _BANNED_CACHE + +def _ip_is_banned(ip: str) -> bool: + banned_ips, banned_nets = _get_banned_ips() + if ip in banned_ips: + return True + try: + addr = ipaddress.ip_address(ip) + return any(addr in net for net in banned_nets) + except ValueError: + return False + +def _lookup_ip_asn(ip: str) -> dict: + """Returns {asn, name, country} via ip-api.com.""" + url = f"http://ip-api.com/json/{ip}?fields=as,org,countryCode" + try: + req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + d = json.loads(resp.read()) + as_field = d.get("as", "") # "AS12345 Some Name" + asn, name = "", as_field + if as_field.startswith("AS"): + parts = as_field.split(" ", 1) + asn = parts[0][2:] + name = parts[1] if len(parts) > 1 else "" + return {"asn": asn, "name": name, "country": d.get("countryCode", "")} + except Exception as e: + return {"asn": "", "name": "", "country": "", "error": str(e)} + +def _lookup_as_prefixes(asn: str) -> list: + """Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days.""" + _AS_CACHE_DIR.mkdir(exist_ok=True) + cache_file = _AS_CACHE_DIR / f"AS{asn}.json" + now = time.time() + if cache_file.exists() and now - cache_file.stat().st_mtime < _AS_CACHE_TTL: + return json.loads(cache_file.read_text()).get("prefixes", []) + url = f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"}) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + prefixes = [ + p["prefix"] for p in data.get("data", {}).get("prefixes", []) + if ":" not in p.get("prefix", "") + ] + cache_file.write_text(json.dumps({"asn": asn, "prefixes": prefixes})) + return prefixes + except Exception: + return [] + + # ── Navigateur de fichiers ──────────────────────────────────────────── @app.route("/browse/") @@ -820,18 +910,33 @@ def delete_file(): # ── Erreurs 404 ─────────────────────────────────────────────────────── +def _filter_banned_from_entry(info: dict) -> dict | None: + """Remove banned IPs from an entry dict. Returns None if no IPs remain.""" + ips_ok = {ip: hits for ip, hits in info["ips"].items() if not _ip_is_banned(ip)} + if not ips_ok: + return None + count = sum(len(h) for h in ips_ok.values()) + last_seen = max(max(h["dt"] for h in hits) for hits in ips_ok.values()) + return {"count": count, "last_seen": last_seen, "ips": ips_ok} + + @app.route("/errors/") def errors_404(): redir = _require_admin() if redir: return redir data = _parse_404s() - entries = sorted(data.items(), key=lambda x: x[1]["count"], reverse=True) + filtered = {} + for path, info in data.items(): + entry = _filter_banned_from_entry(info) + if entry: + filtered[path] = entry + entries = sorted(filtered.items(), key=lambda x: x[1]["count"], reverse=True) ignored = _load_ignored_ips() return render_template("errors_404.html", entries=entries, ignored_ips=sorted(ignored), - total=sum(v["count"] for v in data.values()), + total=sum(v["count"] for v in filtered.values()), log_configured=bool(STATS_LOG_FILE), ) @@ -843,9 +948,10 @@ def errors_detail(): return redir path = request.args.get("path", "") data = _parse_404s() - entry = data.get(path) - if not entry: + raw_entry = data.get(path) + if not raw_entry: return jsonify({"error": "introuvable"}), 404 + entry = _filter_banned_from_entry(raw_entry) or {"count": 0, "last_seen": raw_entry["last_seen"], "ips": {}} ignored = _load_ignored_ips() ip_list = [] for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True): @@ -869,6 +975,22 @@ def errors_detail(): }) +@app.route("/errors/asinfo") +def errors_asinfo(): + redir = _require_admin() + if redir: + return redir + ip = request.args.get("ip", "").strip() + if not re.match(r"^[\d\.]+$", ip): + return jsonify({"error": "IP invalide"}), 400 + info = _lookup_ip_asn(ip) + if info.get("asn"): + info["prefix_count"] = len(_lookup_as_prefixes(info["asn"])) + else: + info["prefix_count"] = 0 + return jsonify(info) + + @app.route("/errors/ignore", methods=["POST"]) def errors_ignore(): redir = _require_admin() @@ -887,20 +1009,32 @@ def errors_ignore(): @app.route("/errors/ban", methods=["POST"]) def errors_ban(): + global _BANNED_CACHE_TS redir = _require_admin() if redir: return redir ip = request.form.get("ip", "").strip() jail = request.form.get("jail", "global-blacklist") + ban_as = request.form.get("ban_as") == "1" if not re.match(r"^[\d\.a-fA-F:]+$", ip): return jsonify({"error": "IP invalide"}), 400 try: + if ban_as: + info = _lookup_ip_asn(ip) + if not info.get("asn"): + return jsonify({"error": "ASN introuvable pour cette IP"}), 400 + targets = _lookup_as_prefixes(info["asn"]) + if not targets: + return jsonify({"error": "Aucun prΓ©fixe IPv4 trouvΓ© pour cet AS"}), 400 + else: + targets = [ip] r = subprocess.run( - ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip", ip], - capture_output=True, text=True, timeout=10 + ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip"] + targets, + capture_output=True, text=True, timeout=60 ) if r.returncode == 0: - return jsonify({"ok": True, "ip": ip, "jail": jail}) + _BANNED_CACHE_TS = 0 # invalide le cache pour ce worker + return jsonify({"ok": True, "ip": ip, "jail": jail, "count": len(targets)}) return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500 except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/app/static/app.css b/app/static/app.css index dab19ef..6346a1b 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -330,6 +330,15 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top: .err-search { flex: 1; max-width: 380px; padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 8px; font-size: .9rem; background: var(--bg); color: var(--text); } .err-search:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-light); } .err-search-count { font-size: .8rem; color: var(--muted); } +.ban-panel { display: flex; flex-direction: column; gap: .35rem; min-width: 200px; } +.ban-panel-as { font-size: .75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 240px; } +.ban-panel-btns { display: flex; gap: .35rem; flex-wrap: wrap; } +.btn-do-ban { font-size: .75rem; padding: .2rem .5rem; background: var(--red-light,#fee2e2); color: #b91c1c; border: 1px solid #fca5a5; border-radius: 5px; cursor: pointer; white-space: nowrap; } +.btn-do-ban--as { background: #fff7ed; color: #c2410c; border-color: #fdba74; } +.btn-do-ban:hover:not(:disabled) { filter: brightness(.92); } +.btn-ban-cancel { font-size: .75rem; padding: .2rem .45rem; background: none; border: 1px solid var(--border); border-radius: 5px; cursor: pointer; color: var(--muted); } +.ban-ok { font-size: .82rem; color: #16a34a; } +.ban-err { font-size: .82rem; color: #dc2626; } .err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; } .col-err-status { width: 1.5rem; text-align: center; } diff --git a/app/templates/errors_404.html b/app/templates/errors_404.html index 7fc2cc2..63428a8 100644 --- a/app/templates/errors_404.html +++ b/app/templates/errors_404.html @@ -80,9 +80,10 @@