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 json
import os
@ -5,6 +6,7 @@ import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
from datetime import datetime, timedelta
@ -51,6 +53,15 @@ except Exception:
_APP_VERSION = ""
_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({
@ -480,6 +491,79 @@ def _parse_changelog():
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 ────────────────────────────────────────────
@app.route("/browse/")
@ -733,6 +817,94 @@ def delete_file():
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 ────────────────────────────────────────────────────────
@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-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; }
/* ── 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>
<a href="{{ url_for('stats') }}"
{% 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') }}"
class="nav-trash{% if request.endpoint == 'trash_list' %} active{% endif %}">Corbeille{% if trash_count %}<span class="trash-badge">{{ trash_count }}</span>{% endif %}</a>
</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 %}