feat: page Erreurs 404 — logs, détail IP/date/referer, ignore, ban fail2ban

- Onglet "Erreurs" dans la navigation
- Analyse des logs Apache des 7 derniers jours (.gz inclus)
- Tableau trié par nombre de requêtes avec badge statut (résolu/actif)
- Détail AJAX par chemin : IPs, compteurs, referers
- Vérification live au clic sur le point de statut
- Ignorer une IP (persisté dans ignored_ips.json, cache invalidé)
- Bannir une IP via fail2ban-client (global-blacklist)
- Section IPs ignorées avec suppression depuis la page

Closes #37 #38 #39 #40 #41

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-06 12:57:13 +02:00
parent 11a3ae72ee
commit 0513afdbb4
4 changed files with 458 additions and 1 deletions

View file

@ -1,3 +1,4 @@
import gzip
import io import io
import json import json
import os import os
@ -5,6 +6,7 @@ import re
import shutil import shutil
import subprocess import subprocess
import threading import threading
import time
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -50,7 +52,16 @@ try:
except Exception: except Exception:
_APP_VERSION = "" _APP_VERSION = ""
_CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md" _CHANGELOG_FILE = Path(__file__).parent / "CHANGELOG.md"
_IGNORED_IPS_FILE = Path(__file__).parent / "ignored_ips.json"
_LOG_RE = re.compile(
r'(\S+) \S+ \S+ \[(\d{2}/\w+/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] '
r'"[A-Z-]+ ([^\s"]+)[^"]*" (\d{3}) \d+ "([^"]*)"'
)
_404_CACHE: dict = {}
_404_CACHE_TS: float = 0
_404_CACHE_TTL = 300 # 5 min
_HIDDEN = frozenset({ _HIDDEN = frozenset({
@ -480,6 +491,79 @@ def _parse_changelog():
return sections return sections
# ── Erreurs 404 — helpers ─────────────────────────────────────────────
def _load_ignored_ips() -> set:
try:
return set(json.loads(_IGNORED_IPS_FILE.read_text()).get("ips", []))
except Exception:
return set()
def _save_ignored_ips(ips: set) -> None:
_IGNORED_IPS_FILE.write_text(json.dumps({"ips": sorted(ips)}, indent=2))
def _log_files_404(days: int = 7) -> list:
if not STATS_LOG_FILE:
return []
log_dir = Path(STATS_LOG_FILE).parent
files = []
for f in sorted(log_dir.glob("*-access.log*"), reverse=True)[:days]:
files.append((f, f.name.endswith(".gz")))
return files
def _parse_404s(days: int = 7) -> dict:
global _404_CACHE, _404_CACHE_TS
now = time.time()
if now - _404_CACHE_TS < _404_CACHE_TTL and _404_CACHE:
return _404_CACHE
ignored = _load_ignored_ips()
results: dict = {}
for fpath, is_gz in _log_files_404(days):
try:
opener = gzip.open(fpath, "rt", errors="replace") if is_gz \
else open(fpath, errors="replace")
with opener as f:
for line in f:
m = _LOG_RE.match(line)
if not m:
continue
ip, dt_str, path, status, referer = m.groups()
if status != "404":
continue
if ip in ignored:
continue
path = path.split("?")[0]
try:
dt = datetime.strptime(dt_str, "%d/%b/%Y:%H:%M:%S %z")
except ValueError:
continue
if path not in results:
results[path] = {"count": 0, "last_seen": dt, "ips": {}}
r = results[path]
r["count"] += 1
if dt > r["last_seen"]:
r["last_seen"] = dt
if ip not in r["ips"]:
r["ips"][ip] = []
r["ips"][ip].append({"dt": dt, "referer": referer if referer != "-" else ""})
except Exception:
continue
_404_CACHE = results
_404_CACHE_TS = now
return results
def _is_still_404(path: str) -> bool:
clean = path.lstrip("/")
return not (ASSETS_ROOT / clean).exists()
def _invalidate_404_cache():
global _404_CACHE_TS
_404_CACHE_TS = 0
# ── Navigateur de fichiers ──────────────────────────────────────────── # ── Navigateur de fichiers ────────────────────────────────────────────
@app.route("/browse/") @app.route("/browse/")
@ -733,6 +817,94 @@ def delete_file():
return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse")) return redirect(url_for("browse", subpath=parent) if parent != "." else url_for("browse"))
# ── Erreurs 404 ───────────────────────────────────────────────────────
@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)
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()),
log_configured=bool(STATS_LOG_FILE),
)
@app.route("/errors/detail")
def errors_detail():
redir = _require_admin()
if redir:
return redir
path = request.args.get("path", "")
data = _parse_404s()
entry = data.get(path)
if not entry:
return jsonify({"error": "introuvable"}), 404
ignored = _load_ignored_ips()
ip_list = []
for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True):
last = max(h["dt"] for h in hits)
ip_list.append({
"ip": ip,
"count": len(hits),
"last_seen": last.strftime("%d/%m/%Y %H:%M"),
"ignored": ip in ignored,
"hits": [
{"dt": h["dt"].strftime("%d/%m/%Y %H:%M"), "referer": h["referer"]}
for h in sorted(hits, key=lambda x: x["dt"], reverse=True)[:30]
],
})
return jsonify({
"path": path,
"count": entry["count"],
"still_404": _is_still_404(path),
"last_seen": entry["last_seen"].strftime("%d/%m/%Y %H:%M"),
"ips": ip_list,
})
@app.route("/errors/ignore", methods=["POST"])
def errors_ignore():
redir = _require_admin()
if redir:
return redir
ip = request.form.get("ip", "").strip()
action = request.form.get("action", "add")
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
return jsonify({"error": "IP invalide"}), 400
ips = _load_ignored_ips()
ips.add(ip) if action == "add" else ips.discard(ip)
_save_ignored_ips(ips)
_invalidate_404_cache()
return jsonify({"ok": True, "action": action, "ip": ip})
@app.route("/errors/ban", methods=["POST"])
def errors_ban():
redir = _require_admin()
if redir:
return redir
ip = request.form.get("ip", "").strip()
jail = request.form.get("jail", "global-blacklist")
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
return jsonify({"error": "IP invalide"}), 400
try:
r = subprocess.run(
["sudo", "fail2ban-client", "set", jail, "banip", ip],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0:
return jsonify({"ok": True, "ip": ip, "jail": jail})
return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
# ── Corbeille ──────────────────────────────────────────────────────── # ── Corbeille ────────────────────────────────────────────────────────
@app.route("/trash") @app.route("/trash")

View file

@ -320,3 +320,51 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top:
.btn-del--perm { color: #b91c1c; } .btn-del--perm { color: #b91c1c; }
.btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; } .btn-danger { background: #b91c1c; color: #fff; border: none; border-radius: 6px; padding: .45rem .9rem; font-size: .88rem; cursor: pointer; font-weight: 600; transition: background .15s; }
.btn-danger:hover { background: #991b1b; } .btn-danger:hover { background: #991b1b; }
/* ── Erreurs 404 ────────────────────────────────────────────────────── */
.err-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; margin-bottom: .5rem; }
.err-header h2 { margin: 0; }
.err-total-badge { font-size: .75rem; font-weight: 400; color: var(--muted); margin-left: .5rem; }
.btn-sm { font-size: .8rem; padding: .3rem .7rem; background: var(--blue-light); color: var(--blue); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; }
.col-err-status { width: 1.5rem; text-align: center; }
.col-err-count { width: 6rem; text-align: right; }
.col-err-ips { width: 4rem; text-align: right; }
.err-status-dot { font-size: 1rem; cursor: pointer; transition: color .2s; }
.err-status-dot--active { color: #ef4444; }
.err-status-dot--ok { color: #22c55e; }
.err-status-dot--unk { color: #d1d5db; }
.err-detail-cell { padding: 0 !important; background: #f8fafd; }
.err-detail-content { padding: .75rem 1.2rem; }
.err-detail-meta { display: flex; align-items: center; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
.err-detail-info { font-size: .82rem; color: var(--muted); }
.err-badge { font-size: .72rem; font-weight: 700; padding: .15rem .55rem; border-radius: 20px; }
.err-badge--active { background: #fee2e2; color: #991b1b; }
.err-badge--ok { background: #dcfce7; color: #166534; }
.err-ip-table { width: 100%; border-collapse: collapse; font-size: .85rem; }
.err-ip-table th { text-align: left; padding: .3rem .5rem; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); border-bottom: 1px solid var(--border); }
.err-ip-table td { padding: .4rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.err-ip-table tr:last-child td { border-bottom: none; }
.err-ip-ignored td { opacity: .45; }
.err-ip { font-size: .82rem; }
.err-ignored-tag { font-size: .68rem; background: #e5e7eb; color: #6b7280; padding: .1rem .35rem; border-radius: 3px; margin-left: .3rem; }
.err-referers { display: flex; flex-wrap: wrap; gap: .3rem; max-width: 28rem; }
.err-referer { font-size: .75rem; background: var(--blue-light); color: var(--blue); border-radius: 3px; padding: .1rem .4rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 18rem; }
.btn-ignore-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; }
.btn-ignore-ip:hover, .btn-ignore-ip--active { opacity: 1; }
.btn-ban-ip { background: none; border: none; cursor: pointer; font-size: .95rem; opacity: .5; padding: .15rem; transition: opacity .15s; }
.btn-ban-ip:hover { opacity: 1; }
.err-loading { color: var(--muted); font-size: .85rem; font-style: italic; }
.err-ignored-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
.err-ignored-chip { display: inline-flex; align-items: center; gap: .35rem; background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 20px; padding: .2rem .6rem .2rem .8rem; font-size: .82rem; font-family: monospace; }
.err-ignored-remove { background: none; border: none; cursor: pointer; font-size: .75rem; color: #9ca3af; line-height: 1; padding: 0; transition: color .15s; }
.err-ignored-remove:hover { color: #ef4444; }

View file

@ -23,6 +23,8 @@
{% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a> {% if request.endpoint == 'browse' %}class="active"{% endif %}>Parcourir</a>
<a href="{{ url_for('stats') }}" <a href="{{ url_for('stats') }}"
{% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a> {% if request.endpoint in ('stats', 'stats_report') %}class="active"{% endif %}>Statistiques</a>
<a href="{{ url_for('errors_404') }}"
{% if request.endpoint == 'errors_404' %}class="active"{% endif %}>Erreurs</a>
<a href="{{ url_for('trash_list') }}" <a href="{{ url_for('trash_list') }}"
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a> class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
</nav> </nav>

View file

@ -0,0 +1,235 @@
{% extends "base.html" %}
{% block title %}Erreurs 404{% endblock %}
{% block content %}
{% if not log_configured %}
<section class="card">
<p style="color:var(--muted)">STATS_LOG_FILE non configuré — impossible d'analyser les logs.</p>
</section>
{% else %}
<section class="card">
<div class="err-header">
<h2>Erreurs 404 <span class="err-total-badge">{{ total }} requêtes</span></h2>
<button type="button" class="btn btn-sm" id="refresh-btn" onclick="location.reload()">↺ Actualiser</button>
</div>
{% if not entries %}
<p style="color:var(--muted); margin-top:.5rem">Aucune erreur 404 dans les logs récents.</p>
{% else %}
<table class="file-table err-table">
<thead>
<tr>
<th class="col-err-status"></th>
<th>Chemin</th>
<th class="col-err-count">Requêtes</th>
<th class="col-date">Dernière vue</th>
<th class="col-err-ips">IPs</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
{% for path, info in entries %}
<tr class="err-row" data-path="{{ path }}">
<td class="col-err-status">
<span class="err-status-dot err-status-dot--{% if not info.last_seen %}unk{% elif (info.ips | length) > 0 %}active{% else %}ok{% endif %}"
data-path="{{ path }}"
title="Cliquer pour vérifier">●</span>
</td>
<td class="err-path" title="{{ path }}"><code>{{ path }}</code></td>
<td class="col-err-count"><span class="hits-badge hits-active">{{ info.count }}</span></td>
<td class="col-date">{{ info.last_seen.strftime('%d/%m/%Y %H:%M') }}</td>
<td class="col-err-ips">{{ info.ips | length }}</td>
<td class="col-actions">
<button type="button" class="btn-detail" data-path="{{ path }}" title="Voir le détail"></button>
</td>
</tr>
<tr class="err-detail-row" id="detail-{{ loop.index }}" style="display:none">
<td colspan="6" class="err-detail-cell">
<div class="err-detail-content" id="detail-content-{{ loop.index }}">
<span class="err-loading">Chargement…</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% if ignored_ips %}
<section class="card">
<h2>IPs ignorées</h2>
<div class="err-ignored-list">
{% for ip in ignored_ips %}
<span class="err-ignored-chip">
{{ ip }}
<button type="button" class="err-ignored-remove" data-ip="{{ ip }}" title="Retirer"></button>
</span>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
<script>
(function () {
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
const BAN_URL = {{ url_for('errors_ban') | tojson }};
/* ── Expand/collapse detail ── */
let openRow = null;
document.querySelectorAll('.btn-detail').forEach(btn => {
btn.addEventListener('click', () => {
const path = btn.dataset.path;
const row = btn.closest('tr');
const idx = row.querySelector('.err-status-dot').dataset.path === path
? [...document.querySelectorAll('.err-row')].indexOf(row) + 1
: null;
const detailRow = row.nextElementSibling;
const detailContent = detailRow.querySelector('.err-detail-content');
const open = detailRow.style.display !== 'none';
if (openRow && openRow !== detailRow) {
openRow.style.display = 'none';
openRow.previousElementSibling.querySelector('.btn-detail').textContent = '▼';
}
if (open) {
detailRow.style.display = 'none';
btn.textContent = '▼';
openRow = null;
return;
}
detailRow.style.display = '';
btn.textContent = '▲';
openRow = detailRow;
loadDetail(path, detailContent);
});
});
async function loadDetail(path, container) {
container.innerHTML = '<span class="err-loading">Chargement…</span>';
try {
const d = await fetch(DETAIL_URL + '?path=' + encodeURIComponent(path)).then(r => r.json());
renderDetail(d, container);
} catch (_) {
container.innerHTML = '<span style="color:#b91c1c">Erreur réseau.</span>';
}
}
function renderDetail(d, container) {
const statusBadge = d.still_404
? '<span class="err-badge err-badge--active">✗ Toujours actif</span>'
: '<span class="err-badge err-badge--ok">✓ Résolu</span>';
let html = `<div class="err-detail-meta">${statusBadge}
<span class="err-detail-info">${d.count} requête(s) · dernière le ${d.last_seen}</span>
</div>`;
html += '<table class="err-ip-table"><thead><tr>'
+ '<th>IP</th><th>Requêtes</th><th>Dernière vue</th><th>Referers</th><th></th>'
+ '</tr></thead><tbody>';
for (const ip of d.ips) {
const referers = [...new Set(ip.hits.map(h => h.referer).filter(Boolean))];
const refHtml = referers.length
? referers.slice(0,3).map(r => `<span class="err-referer" title="${escHtml(r)}">${escHtml(trimUrl(r))}</span>`).join(' ')
: '<span style="color:var(--muted)"></span>';
html += `<tr class="${ip.ignored ? 'err-ip-ignored' : ''}">
<td><code class="err-ip">${ip.ip}</code>${ip.ignored ? ' <span class="err-ignored-tag">ignorée</span>' : ''}</td>
<td><span class="hits-badge ${ip.count > 10 ? 'hits-active' : 'hits-zero'}">${ip.count}</span></td>
<td class="col-date">${ip.last_seen}</td>
<td class="err-referers">${refHtml}</td>
<td class="col-actions">
<div class="row-actions">
<button type="button" class="btn-ignore-ip ${ip.ignored ? 'btn-ignore-ip--active' : ''}"
data-ip="${ip.ip}" data-action="${ip.ignored ? 'remove' : 'add'}"
title="${ip.ignored ? 'Retirer de la liste d\'ignorées' : 'Ignorer cette IP'}">
${ip.ignored ? '👁' : '🙈'}
</button>
<button type="button" class="btn-ban-ip" data-ip="${ip.ip}" title="Bannir dans fail2ban">🔨</button>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
/* Ignore buttons */
container.querySelectorAll('.btn-ignore-ip').forEach(b => {
b.addEventListener('click', async () => {
const fd = new FormData();
fd.append('ip', b.dataset.ip);
fd.append('action', b.dataset.action);
const r = await fetch(IGNORE_URL, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) loadDetail(d.path, container);
});
});
/* Ban buttons */
container.querySelectorAll('.btn-ban-ip').forEach(b => {
b.addEventListener('click', async () => {
if (!confirm(`Bannir ${b.dataset.ip} dans fail2ban ?`)) return;
b.disabled = true;
b.textContent = '⏳';
const fd = new FormData();
fd.append('ip', b.dataset.ip);
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
b.textContent = '✓';
b.style.color = '#16a34a';
} else {
b.textContent = '✗';
b.title = j.error || 'Erreur';
b.style.color = '#dc2626';
b.disabled = false;
alert('Erreur : ' + (j.error || 'voir console'));
}
});
});
}
/* ── Remove from ignored list (top section) ── */
document.querySelectorAll('.err-ignored-remove').forEach(b => {
b.addEventListener('click', async () => {
const fd = new FormData();
fd.append('ip', b.dataset.ip);
fd.append('action', 'remove');
await fetch(IGNORE_URL, { method: 'POST', body: fd });
location.reload();
});
});
/* ── Check status dots ── */
document.querySelectorAll('.err-status-dot').forEach(dot => {
dot.style.cursor = 'pointer';
dot.addEventListener('click', async () => {
dot.textContent = '○';
dot.className = 'err-status-dot';
const r = await fetch({{ url_for('errors_detail') | tojson }} + '?path=' + encodeURIComponent(dot.dataset.path))
.then(r => r.json());
dot.textContent = '●';
dot.className = 'err-status-dot err-status-dot--' + (r.still_404 ? 'active' : 'ok');
dot.title = r.still_404 ? 'Toujours actif' : 'Résolu';
});
});
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function trimUrl(u) {
try { const p = new URL(u); return p.hostname + (p.pathname !== '/' ? p.pathname : ''); }
catch { return u.length > 40 ? u.slice(0,40)+'…' : u; }
}
})();
</script>
{% endblock %}