feat: implement IP ban management with override functionality and UI updates

This commit is contained in:
Lorenzo Venerandi
2026-03-08 12:26:57 +01:00
parent a9aeb00279
commit 2539713a1d
10 changed files with 603 additions and 12 deletions

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/leaflet.min.css" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.css" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.Default.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=check_circle,error,gavel,health_and_safety,warning" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.min.js" defer></script>
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.markercluster.js" defer></script>

View File

@@ -59,7 +59,7 @@
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
</a>
<a class="tab-button tab-right" :class="{ active: tab === 'admin' }" x-show="authenticated" x-cloak @click.prevent="switchToAdmin()" href="#admin">Admin</a>
<a class="tab-button tab-right" :class="{ active: tab === 'admin' }" x-show="authenticated" x-cloak @click.prevent="switchToAdmin()" href="#admin">IP Banlist</a>
{# Lock icon (not authenticated) #}
<a class="tab-button tab-lock-btn" :class="{ 'tab-right': !authenticated }" @click.prevent="promptAuth()" x-show="!authenticated" href="#" title="Unlock protected panels">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
@@ -197,7 +197,7 @@
</div>
</div>
{# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #}
{# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #}
<div x-show="tab === 'admin'" x-cloak>
<div id="admin-htmx-container"></div>
</div>

View File

@@ -3,14 +3,30 @@
{# Page header #}
<div class="ip-page-header">
<h1>
<span class="ip-address-title">{{ ip_address }}</span>
{% if stats.category %}
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
{{ stats.category | replace('_', ' ') | title }}
<div class="ip-page-header-row">
<h1>
<span class="ip-address-title">{{ ip_address }}</span>
{% if stats.category %}
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
{{ stats.category | replace('_', ' ') | title }}
</span>
{% endif %}
</h1>
{# Ban/Unban actions — visible only when authenticated #}
<span class="ip-ban-actions" style="display: none; gap: 4px;">
{% if stats.category and stats.category | lower == 'attacker' %}
<button class="ban-icon-btn ban-icon-unban" onclick="ipBanAction('{{ ip_address | e }}', 'unban')" title="Unban">
<span class="material-symbols-outlined">health_and_safety</span>
<span class="ban-icon-tooltip">Unban</span>
</button>
{% else %}
<button class="ban-icon-btn ban-icon-ban" onclick="ipBanAction('{{ ip_address | e }}', 'ban')" title="Add to banlist">
<span class="material-symbols-outlined">gavel</span>
<span class="ban-icon-tooltip">Add to banlist</span>
</button>
{% endif %}
</span>
{% endif %}
</h1>
</div>
{% if stats.city or stats.country %}
<p class="ip-location-subtitle">
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}

View File

@@ -1,4 +1,128 @@
<div class="table-container" style="text-align: center; padding: 60px 20px;">
<h2 style="color: #58a6ff;">Admin Panel</h2>
<p style="color: #8b949e; font-size: 16px;">This is a protected panel. More features coming soon.</p>
{# Ban management panel #}
<div x-data="banManagement()" x-init="init()">
{# Force ban IP form #}
<div class="table-container" style="margin-bottom: 20px;">
<h2>IP Banlist</h2>
<p style="color: #8b949e; font-size: 14px; margin-bottom: 16px;">
Force-ban a new IP or manage existing ban overrides. Changes take effect on the next banlist export cycle (every 5 minutes).
</p>
<form @submit.prevent="forceBan()" style="display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; color: #8b949e; font-size: 12px; margin-bottom: 4px;">IP Address</label>
<input type="text"
x-model="newBanIp"
placeholder="e.g. 192.168.1.100"
class="auth-modal-input"
style="width: 100%;" />
</div>
<button type="submit" class="ban-form-btn" :disabled="!newBanIp || banLoading">
<span class="material-symbols-outlined">gavel</span>
<span x-text="banLoading ? 'Banning...' : 'Force Ban IP'"></span>
</button>
</form>
<p x-show="banMessage" x-text="banMessage" :style="{ color: banSuccess ? '#3fb950' : '#f85149' }" style="margin-top: 10px; font-size: 13px;" x-cloak></p>
</div>
{# Attackers list with unban option #}
<div class="table-container" style="margin-bottom: 20px;">
<h2>Detected Attackers</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Active overrides #}
<div class="table-container">
<h2>Active Ban Overrides</h2>
<div id="overrides-container"
class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('banManagement', () => ({
newBanIp: '',
banLoading: false,
banMessage: '',
banSuccess: false,
init() {},
async forceBan() {
if (!this.newBanIp) return;
this.banLoading = true;
this.banMessage = '';
try {
const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ ip: this.newBanIp, action: 'ban' }),
});
const data = await resp.json();
if (resp.ok) {
this.banSuccess = true;
this.banMessage = `IP ${this.newBanIp} added to banlist`;
this.newBanIp = '';
this.refreshOverrides();
} else {
this.banSuccess = false;
this.banMessage = data.error || 'Failed to ban IP';
}
} catch {
this.banSuccess = false;
this.banMessage = 'Request failed';
}
this.banLoading = false;
},
refreshOverrides() {
const container = document.getElementById('overrides-container');
if (container && typeof htmx !== 'undefined') {
htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/ban/overrides?page=1`, {
target: '#overrides-container',
swap: 'innerHTML'
});
}
},
}));
});
async function banAction(ip, action) {
const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP <strong>${ip}</strong>?`);
if (!confirmed) return;
try {
const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ ip, action }),
});
if (resp.ok) {
krawlModal.success(`${action} successful for ${ip}`);
const overrides = document.getElementById('overrides-container');
if (overrides) {
htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/ban/overrides?page=1`, {
target: '#overrides-container',
swap: 'innerHTML'
});
}
} else {
const result = await resp.json().catch(() => ({}));
krawlModal.error(result.error || `Failed to ${action} IP ${ip}`);
}
} catch {
krawlModal.error('Request failed');
}
}
</script>

View File

@@ -0,0 +1,49 @@
{# HTMX fragment: Attackers with unban action #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} attackers</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page={{ pagination.page - 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page={{ pagination.page + 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table class="ip-stats-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Total Requests</th>
<th>Category</th>
<th>Location</th>
<th>Last Seen</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
{% for ip in items %}
<tr>
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>{{ ip.ip | e }}</td>
<td>{{ ip.total_requests }}</td>
<td><span class="category-badge category-{{ ip.category | default('unknown') }}">{{ ip.category | default('unknown') | e }}</span></td>
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
<td>{{ ip.last_seen | format_ts }}</td>
<td>
<button class="ban-icon-btn ban-icon-unban" onclick="banAction('{{ ip.ip | e }}', 'unban')" title="Unban">
<span class="material-symbols-outlined">health_and_safety</span>
<span class="ban-icon-tooltip">Unban</span>
</button>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty-state">No attackers found</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,57 @@
{# HTMX fragment: Active ban overrides #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} overrides</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page={{ pagination.page - 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page={{ pagination.page + 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table class="ip-stats-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Override</th>
<th>Category</th>
<th>Total Requests</th>
<th>Location</th>
<th>Last Seen</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
{% for ip in items %}
<tr>
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>{{ ip.ip | e }}</td>
<td>
{% if ip.ban_override == true %}
<span class="ban-override-badge ban-override-banned">Force Banned</span>
{% elif ip.ban_override == false %}
<span class="ban-override-badge ban-override-unbanned">Force Unbanned</span>
{% endif %}
</td>
<td><span class="category-badge category-{{ ip.category | default('unknown') }}">{{ ip.category | default('unknown') | e }}</span></td>
<td>{{ ip.total_requests }}</td>
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
<td>{{ ip.last_seen | format_ts }}</td>
<td>
<button class="ban-icon-btn ban-icon-reset" onclick="banAction('{{ ip.ip | e }}', 'reset')" title="Reset to automatic">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
<span class="ban-icon-tooltip">Reset</span>
</button>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty-state">No active overrides</td></tr>
{% endfor %}
</tbody>
</table>