feat: page Bannis — liste et déblocage fail2ban par IP ou AS entier
- /errors/banned/ : bannissements groupés par AS (nom, pays, nb entrées) - Déblocage IP seule ou AS entier via POST /errors/unban - Filtre dynamique par IP/CIDR/nom AS, mise à jour DOM sans rechargement - Nav header + sudoers unbanip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d4fe8614c2
commit
5ff97d50b5
6 changed files with 241 additions and 1 deletions
|
|
@ -1,5 +1,16 @@
|
|||
# Changelog — Alpinux Static
|
||||
|
||||
## [1.7.0] — 2026-05-06
|
||||
|
||||
### Ajouté
|
||||
- Page **Bannis** (`/errors/banned/`) : liste tous les bannissements fail2ban (`global-blacklist`) groupés par AS, avec nom de l'opérateur et pays
|
||||
- Débloquer une IP/CIDR individuelle ou tout un AS d'un seul clic
|
||||
- Filtre de recherche dynamique par IP, CIDR ou nom d'AS
|
||||
- Mise à jour en temps réel des lignes après déblocage (pas de rechargement)
|
||||
- Sudoers `static-cdn` : ajout de la permission `unbanip`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] — 2026-05-06
|
||||
|
||||
### Modifié
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.6.1
|
||||
1.7.0
|
||||
|
|
|
|||
56
app/app.py
56
app/app.py
|
|
@ -1138,6 +1138,62 @@ def errors_ban():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/errors/banned/")
|
||||
def errors_banned():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
banned_ips, banned_nets = _get_banned_ips()
|
||||
all_entries = sorted(banned_ips) + sorted(str(n) for n in banned_nets)
|
||||
|
||||
by_asn: dict = {}
|
||||
for entry in all_entries:
|
||||
rep_ip = entry.split("/")[0]
|
||||
info = _lookup_ip_asn(rep_ip)
|
||||
key = info.get("asn") or "?"
|
||||
if key not in by_asn:
|
||||
by_asn[key] = {
|
||||
"asn": info.get("asn", ""),
|
||||
"name": info.get("name", ""),
|
||||
"country": info.get("country", ""),
|
||||
"entries": [],
|
||||
}
|
||||
by_asn[key]["entries"].append(entry)
|
||||
|
||||
groups = sorted(by_asn.values(), key=lambda g: len(g["entries"]), reverse=True)
|
||||
return render_template("banned.html", groups=groups, total=len(all_entries))
|
||||
|
||||
|
||||
@app.route("/errors/unban", methods=["POST"])
|
||||
def errors_unban():
|
||||
global _BANNED_CACHE_TS
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
entries = request.form.getlist("entries") or [request.form.get("entry", "").strip()]
|
||||
entries = [e.strip() for e in entries if e.strip()]
|
||||
if not entries:
|
||||
return jsonify({"error": "Aucune cible spécifiée"}), 400
|
||||
for e in entries:
|
||||
if not re.match(r"^[\d\.a-fA-F:/]+$", e):
|
||||
return jsonify({"error": f"Entrée invalide : {e}"}), 400
|
||||
jail = "global-blacklist"
|
||||
unbanned, errors = [], []
|
||||
for entry in entries:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "unbanip", entry],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
(unbanned if r.returncode == 0 else errors).append(entry)
|
||||
except Exception as ex:
|
||||
errors.append(f"{entry}: {ex}")
|
||||
_BANNED_CACHE_TS = 0
|
||||
if errors and not unbanned:
|
||||
return jsonify({"error": f"Erreur sur : {', '.join(errors)}"}), 500
|
||||
return jsonify({"ok": True, "unbanned": unbanned, "errors": errors, "count": len(unbanned)})
|
||||
|
||||
|
||||
# ── Corbeille ────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/trash")
|
||||
|
|
|
|||
|
|
@ -381,3 +381,13 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top:
|
|||
.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; }
|
||||
.banned-table .col-asn { width: 7rem; }
|
||||
.banned-table .col-country { width: 4rem; text-align: center; }
|
||||
.banned-as-row td { background: var(--blue-light); border-top: 2px solid var(--border); }
|
||||
.banned-as-cell { padding: .5rem .75rem; }
|
||||
.banned-as-label { font-size: .9rem; }
|
||||
.banned-country { color: var(--muted); font-size: .8rem; }
|
||||
.banned-entry-row td { padding-left: 2rem; }
|
||||
.btn-unban { font-size: .75rem; padding: .2rem .5rem; background: #f0fdf4; color: #15803d; border: 1px solid #86efac; border-radius: 5px; cursor: pointer; }
|
||||
.btn-unban:hover:not(:disabled) { filter: brightness(.92); }
|
||||
.btn-unban-as { background: #fefce8; color: #854d0e; border-color: #fde68a; }
|
||||
|
|
|
|||
161
app/templates/banned.html
Normal file
161
app/templates/banned.html
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Bannissements{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="err-header">
|
||||
<h2>Bannissements <span class="err-total-badge">{{ total }} entrée{{ 's' if total != 1 }}</span></h2>
|
||||
<button type="button" class="btn btn-sm" onclick="location.reload()">↺ Actualiser</button>
|
||||
</div>
|
||||
|
||||
{% if not groups %}
|
||||
<p style="color:var(--muted);margin-top:.5rem">Aucune IP bannie dans global-blacklist.</p>
|
||||
{% else %}
|
||||
|
||||
<div class="err-search-wrap" style="margin-top:.75rem">
|
||||
<input type="search" id="ban-search" class="err-search" placeholder="Filtrer par IP, CIDR ou nom d'AS…" autocomplete="off">
|
||||
<span class="err-search-count" id="ban-search-count"></span>
|
||||
</div>
|
||||
|
||||
<table class="file-table banned-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP / CIDR</th>
|
||||
<th class="col-asn">AS</th>
|
||||
<th>Opérateur</th>
|
||||
<th class="col-country">Pays</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ban-tbody">
|
||||
{% for group in groups %}
|
||||
<tr class="banned-as-row" data-asn="{{ group.asn }}" data-name="{{ group.name | lower }}">
|
||||
<td colspan="4" class="banned-as-cell">
|
||||
<span class="banned-as-label">
|
||||
{% if group.asn %}<strong>AS{{ group.asn }}</strong>{% else %}<strong>AS inconnu</strong>{% endif %}
|
||||
{% if group.name %} · {{ group.name }}{% endif %}
|
||||
{% if group.country %} <span class="banned-country">[{{ group.country }}]</span>{% endif %}
|
||||
</span>
|
||||
<span class="err-total-badge" style="margin-left:.5rem">{{ group.entries|length }} entrée{{ 's' if group.entries|length != 1 }}</span>
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<button type="button" class="btn-unban-as btn btn-sm"
|
||||
data-asn="{{ group.asn }}"
|
||||
data-entries="{{ group.entries | tojson | e }}"
|
||||
title="Débloquer tout l'AS ({{ group.entries|length }} entrées)">
|
||||
🔓 AS
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% for entry in group.entries %}
|
||||
<tr class="banned-entry-row" data-asn="{{ group.asn }}" data-entry="{{ entry }}">
|
||||
<td colspan="4"><code class="err-ip">{{ entry }}</code></td>
|
||||
<td class="col-actions">
|
||||
<button type="button" class="btn-unban btn btn-sm"
|
||||
data-entry="{{ entry }}"
|
||||
title="Débloquer {{ entry }}">
|
||||
🔓
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const UNBAN_URL = {{ url_for('errors_unban') | tojson }};
|
||||
|
||||
/* ── Recherche ── */
|
||||
const searchInput = document.getElementById('ban-search');
|
||||
const searchCount = document.getElementById('ban-search-count');
|
||||
|
||||
function applyFilter() {
|
||||
const q = (searchInput?.value || '').trim().toLowerCase();
|
||||
const asRows = [...document.querySelectorAll('.banned-as-row')];
|
||||
let visible = 0;
|
||||
asRows.forEach(asRow => {
|
||||
const asn = (asRow.dataset.asn || '').toLowerCase();
|
||||
const name = (asRow.dataset.name || '').toLowerCase();
|
||||
const entryRows = [...document.querySelectorAll(`.banned-entry-row[data-asn="${asRow.dataset.asn}"]`)];
|
||||
let anyMatch = false;
|
||||
entryRows.forEach(er => {
|
||||
const entry = (er.dataset.entry || '').toLowerCase();
|
||||
const match = !q || entry.includes(q) || asn.includes(q) || name.includes(q);
|
||||
er.style.display = match ? '' : 'none';
|
||||
if (match) anyMatch = true;
|
||||
});
|
||||
asRow.style.display = (!q || anyMatch) ? '' : 'none';
|
||||
if (!q || anyMatch) visible += entryRows.filter(r => r.style.display !== 'none').length;
|
||||
});
|
||||
if (searchCount) searchCount.textContent = q ? `${visible} / {{ total }}` : '';
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', applyFilter);
|
||||
|
||||
/* ── Débloquer une entrée ── */
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-unban:not(.btn-unban-as)');
|
||||
if (!btn) return;
|
||||
const entry = btn.dataset.entry;
|
||||
if (!confirm(`Débloquer ${entry} ?`)) return;
|
||||
await doUnban(btn, [entry]);
|
||||
});
|
||||
|
||||
/* ── Débloquer tout un AS ── */
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.btn-unban-as');
|
||||
if (!btn) return;
|
||||
let entries;
|
||||
try { entries = JSON.parse(btn.dataset.entries); } catch { return; }
|
||||
if (!confirm(`Débloquer les ${entries.length} entrée(s) de cet AS ?`)) return;
|
||||
await doUnban(btn, entries, btn.dataset.asn);
|
||||
});
|
||||
|
||||
async function doUnban(btn, entries, asn) {
|
||||
btn.disabled = true; btn.textContent = '⏳';
|
||||
const fd = new FormData();
|
||||
entries.forEach(e => fd.append('entries', e));
|
||||
try {
|
||||
const r = await fetch(UNBAN_URL, { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (j.ok) {
|
||||
if (asn !== undefined) {
|
||||
document.querySelectorAll(`[data-asn="${asn}"]`).forEach(row => row.remove());
|
||||
} else {
|
||||
entries.forEach(entry => {
|
||||
document.querySelector(`.banned-entry-row[data-entry="${CSS.escape(entry)}"]`)?.remove();
|
||||
});
|
||||
checkEmptyAs();
|
||||
}
|
||||
updateTotal();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = asn !== undefined ? '🔓 AS' : '🔓';
|
||||
btn.title = j.error || 'Erreur';
|
||||
alert('Erreur : ' + (j.error || 'voir console'));
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
btn.textContent = asn !== undefined ? '🔓 AS' : '🔓';
|
||||
}
|
||||
}
|
||||
|
||||
function checkEmptyAs() {
|
||||
document.querySelectorAll('.banned-as-row').forEach(asRow => {
|
||||
const remaining = document.querySelectorAll(`.banned-entry-row[data-asn="${asRow.dataset.asn}"]`);
|
||||
if (!remaining.length) asRow.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
const remaining = document.querySelectorAll('.banned-entry-row').length;
|
||||
document.querySelector('.err-total-badge').textContent =
|
||||
`${remaining} entrée${remaining !== 1 ? 's' : ''}`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
{% 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('errors_banned') }}"
|
||||
{% if request.endpoint == 'errors_banned' %}class="active"{% endif %}>Bannis</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue