Merge pull request #122 from BlessedRebuS/feat/manageable-ip-banlist

Feat/manageable ip banlist
This commit is contained in:
Patrick Di Fazio
2026-03-08 13:12:01 +01:00
committed by GitHub
19 changed files with 775 additions and 48 deletions

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: krawl-chart
description: A Helm chart for Krawl honeypot server
type: application
version: 1.1.4
appVersion: 1.1.4
version: 1.1.5
appVersion: 1.1.5
keywords:
- honeypot
- security

View File

@@ -2231,6 +2231,103 @@ class DatabaseManager:
finally:
self.close_session()
# ── Ban Override Management ──────────────────────────────────────────
def set_ban_override(self, ip: str, override: Optional[bool]) -> bool:
"""
Set ban override for an IP.
override=True: force into banlist
override=False: force remove from banlist
override=None: reset to automatic (category-based)
Returns True if the IP exists and was updated.
"""
session = self.session
sanitized_ip = sanitize_ip(ip)
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first()
if not ip_stats:
return False
ip_stats.ban_override = override
try:
session.commit()
return True
except Exception as e:
session.rollback()
applogger.error(f"Error setting ban override for {sanitized_ip}: {e}")
return False
def force_ban_ip(self, ip: str) -> bool:
"""
Force-ban an IP that may not exist in ip_stats yet.
Creates a minimal entry if needed.
"""
session = self.session
sanitized_ip = sanitize_ip(ip)
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first()
if not ip_stats:
ip_stats = IpStats(
ip=sanitized_ip,
total_requests=0,
first_seen=datetime.now(),
last_seen=datetime.now(),
)
session.add(ip_stats)
ip_stats.ban_override = True
try:
session.commit()
return True
except Exception as e:
session.rollback()
applogger.error(f"Error force-banning {sanitized_ip}: {e}")
return False
def get_ban_overrides_paginated(
self,
page: int = 1,
page_size: int = 25,
) -> Dict[str, Any]:
"""Get all IPs with a non-null ban_override, paginated."""
session = self.session
try:
base_query = session.query(IpStats).filter(IpStats.ban_override.isnot(None))
total = base_query.count()
total_pages = max(1, (total + page_size - 1) // page_size)
results = (
base_query.order_by(IpStats.last_seen.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
overrides = []
for r in results:
overrides.append(
{
"ip": r.ip,
"ban_override": r.ban_override,
"category": r.category,
"total_requests": r.total_requests,
"country_code": r.country_code,
"city": r.city,
"last_seen": r.last_seen.isoformat() if r.last_seen else None,
}
)
return {
"overrides": overrides,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages,
},
}
finally:
self.close_session()
# Module-level singleton instance
_db_manager = DatabaseManager()

View File

@@ -84,6 +84,14 @@ def _migrate_performance_indexes(cursor) -> List[str]:
return added
def _migrate_ban_override_column(cursor) -> bool:
"""Add ban_override column to ip_stats if missing."""
if _column_exists(cursor, "ip_stats", "ban_override"):
return False
cursor.execute("ALTER TABLE ip_stats ADD COLUMN ban_override BOOLEAN DEFAULT NULL")
return True
def run_migrations(database_path: str) -> None:
"""
Check the database schema and apply any pending migrations.
@@ -110,6 +118,9 @@ def run_migrations(database_path: str) -> None:
for col in ban_cols:
applied.append(f"add {col} column to ip_stats")
if _migrate_ban_override_column(cursor):
applied.append("add ban_override column to ip_stats")
idx_added = _migrate_performance_indexes(cursor)
for idx in idx_added:
applied.append(f"add index {idx}")

View File

@@ -210,6 +210,11 @@ class IpStats(Base):
total_violations: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
ban_multiplier: Mapped[int] = mapped_column(Integer, default=1, nullable=True)
# Admin ban override: True = force ban, False = force unban, None = automatic
ban_override: Mapped[Optional[bool]] = mapped_column(
Boolean, nullable=True, default=None
)
def __repr__(self) -> str:
return f"<IpStats(ip='{self.ip}', total_requests={self.total_requests})>"

View File

@@ -137,6 +137,40 @@ async def auth_check(request: Request):
return JSONResponse(content={"authenticated": False}, status_code=401)
# ── Protected Ban Management API ─────────────────────────────────────
class BanOverrideRequest(BaseModel):
ip: str
action: str # "ban", "unban", or "reset"
@router.post("/api/ban-override")
async def ban_override(request: Request, body: BanOverrideRequest):
if not verify_auth(request):
return JSONResponse(content={"error": "Unauthorized"}, status_code=401)
db = get_db()
action_map = {"ban": True, "unban": False, "reset": None}
if body.action not in action_map:
return JSONResponse(
content={"error": "Invalid action. Use: ban, unban, reset"},
status_code=400,
)
if body.action == "ban":
success = db.force_ban_ip(body.ip)
else:
success = db.set_ban_override(body.ip, action_map[body.action])
if success:
get_app_logger().info(f"Ban override: {body.action} on IP {body.ip}")
return JSONResponse(
content={"success": True, "ip": body.ip, "action": body.action}
)
return JSONResponse(content={"error": "IP not found"}, status_code=404)
@router.get("/api/all-ip-stats")
async def all_ip_stats(request: Request):
db = get_db()

View File

@@ -412,11 +412,11 @@ async def htmx_search(
)
# ── Protected Admin Panel ───────────────────────────────────────────
# ── Protected Banlist Panel ───────────────────────────────────────────
@router.get("/htmx/admin")
async def htmx_admin(request: Request):
@router.get("/htmx/banlist")
async def htmx_banlist(request: Request):
if not verify_auth(request):
return HTMLResponse(
'<div class="table-container" style="text-align:center;padding:80px 20px;">'
@@ -428,9 +428,62 @@ async def htmx_admin(request: Request):
)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/admin_panel.html",
"dashboard/partials/banlist_panel.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
},
)
# ── Ban Management HTMX Endpoints ───────────────────────────────────
@router.get("/htmx/ban/attackers")
async def htmx_ban_attackers(
request: Request,
page: int = Query(1),
page_size: int = Query(25),
):
if not verify_auth(request):
return HTMLResponse(
"<p style='color:#f85149;'>Unauthorized</p>", status_code=200
)
db = get_db()
result = db.get_attackers_paginated(page=max(1, page), page_size=page_size)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/ban_attackers_table.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"items": result["attackers"],
"pagination": result["pagination"],
},
)
@router.get("/htmx/ban/overrides")
async def htmx_ban_overrides(
request: Request,
page: int = Query(1),
page_size: int = Query(25),
):
if not verify_auth(request):
return HTMLResponse(
"<p style='color:#f85149;'>Unauthorized</p>", status_code=200
)
db = get_db()
result = db.get_ban_overrides_paginated(page=max(1, page), page_size=page_size)
templates = get_templates()
return templates.TemplateResponse(
"dashboard/partials/ban_overrides_table.html",
{
"request": request,
"dashboard_path": _dashboard_path(request),
"items": result["overrides"],
"pagination": result["pagination"],
},
)

View File

@@ -45,9 +45,25 @@ def main():
session = db.session
# Query attacker IPs from IpStats (same as dashboard "Attackers by Total Requests")
attackers = (
# Also include IPs with ban_override=True (force-banned by admin)
# Exclude IPs with ban_override=False (force-unbanned by admin)
from sqlalchemy import or_, and_
banned_ips = (
session.query(IpStats)
.filter(IpStats.category == "attacker")
.filter(
or_(
# Automatic: attacker category without explicit unban
and_(
IpStats.category == "attacker",
or_(
IpStats.ban_override.is_(None), IpStats.ban_override == True
),
),
# Manual: force-banned by admin regardless of category
IpStats.ban_override == True,
)
)
.order_by(IpStats.total_requests.desc())
.all()
)
@@ -56,9 +72,7 @@ def main():
server_ip = config.get_server_ip()
public_ips = [
attacker.ip
for attacker in attackers
if is_valid_public_ip(attacker.ip, server_ip)
entry.ip for entry in banned_ips if is_valid_public_ip(entry.ip, server_ip)
]
# Ensure exports directory exists
@@ -81,7 +95,7 @@ def main():
app_logger.info(
f"[Background Task] {task_name} exported {len(public_ips)} in {fwname} public IPs"
f"(filtered {len(attackers) - len(public_ips)} local/private IPs) to {output_file}"
f"(filtered {len(banned_ips) - len(public_ips)} local/private IPs) to {output_file}"
)
except Exception as e:

View File

@@ -129,7 +129,7 @@
</head>
<body>
<div class="container">
<h1>Admin Panel</h1>
<h1>Krawl Login</h1>
<p class="subtitle">Please log in to continue</p>
<form action="/admin/login" method="post">

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="{{ dashboard_path }}/static/vendor/css/material-symbols.css" />
<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 === 'banlist' }" x-show="authenticated" x-cloak @click.prevent="switchToBanlist()" href="#banlist">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,9 +197,9 @@
</div>
</div>
{# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #}
<div x-show="tab === 'admin'" x-cloak>
<div id="admin-htmx-container"></div>
{# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #}
<div x-show="tab === 'banlist'" x-cloak>
<div id="banlist-htmx-container"></div>
</div>
{# Raw request modal - Alpine.js #}

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 +0,0 @@
<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>
</div>

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') | replace('_', '-') }}">{{ ip.category | default('unknown') | replace('_', ' ') | title }}</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="ipBanAction('{{ 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') | replace('_', '-') }}">{{ ip.category | default('unknown') | replace('_', ' ') | title }}</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="ipBanAction('{{ 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>

View File

@@ -0,0 +1,101 @@
{# 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'
});
}
},
}));
});
</script>

View File

@@ -454,6 +454,12 @@ tbody {
.ip-page-header {
margin-bottom: 20px;
}
.ip-page-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ip-page-header h1 {
display: flex;
align-items: center;
@@ -710,9 +716,8 @@ tbody {
min-width: 0;
}
.ip-attack-chart-wrapper {
flex: 1;
position: relative;
min-height: 180px;
height: 220px;
}
/* Radar chart */
@@ -1301,6 +1306,164 @@ tbody {
cursor: not-allowed;
}
/* Ban Management */
.ban-form-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
background: rgba(248, 81, 73, 0.15);
color: #f85149;
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.ban-form-btn:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.3);
border-color: rgba(248, 81, 73, 0.5);
}
.ban-form-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ban-form-btn .material-symbols-outlined {
font-size: 18px;
}
.ban-icon-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.ban-icon-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.ban-icon-btn .material-symbols-outlined {
font-size: 20px;
}
.ban-icon-unban {
color: #3fb950;
}
.ban-icon-unban:hover {
background: rgba(63, 185, 80, 0.15);
}
.ban-icon-reset {
color: #8b949e;
}
.ban-icon-reset:hover {
color: #c9d1d9;
background: rgba(139, 148, 158, 0.15);
}
.ban-icon-ban {
color: #f85149;
}
.ban-icon-ban:hover {
background: rgba(248, 81, 73, 0.15);
}
.ban-icon-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: #1c2128;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.ban-icon-btn:hover .ban-icon-tooltip {
opacity: 1;
}
/* Custom confirm/alert modal */
.krawl-modal-overlay {
position: fixed;
z-index: 1100;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.krawl-modal-box {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
width: 380px;
max-width: 90vw;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
animation: authModalIn 0.2s ease-out;
overflow: hidden;
}
.krawl-modal-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 24px 24px 0;
}
.krawl-modal-icon .material-symbols-outlined {
font-size: 40px;
}
.krawl-modal-icon.krawl-modal-icon-warn .material-symbols-outlined {
color: #d29922;
}
.krawl-modal-icon.krawl-modal-icon-success .material-symbols-outlined {
color: #3fb950;
}
.krawl-modal-icon.krawl-modal-icon-error .material-symbols-outlined {
color: #f85149;
}
.krawl-modal-message {
padding: 16px 24px 8px;
text-align: center;
color: #e6edf3;
font-size: 15px;
line-height: 1.5;
}
.krawl-modal-actions {
display: flex;
justify-content: center;
gap: 10px;
padding: 16px 24px 24px;
}
.ban-override-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ban-override-banned {
background: rgba(248, 81, 73, 0.15);
color: #f85149;
}
.ban-override-unbanned {
background: rgba(63, 185, 80, 0.15);
color: #3fb950;
}
/* Attack Types Cell Styling */
.attack-types-cell {
max-width: 280px;

View File

@@ -31,6 +31,10 @@ document.addEventListener('alpine:init', () => {
if (resp.ok) this.authenticated = true;
} catch {}
// Sync ban action button visibility with auth state
this.$watch('authenticated', (val) => updateBanActionVisibility(val));
updateBanActionVisibility(this.authenticated);
// Handle hash-based tab routing
const hash = window.location.hash.slice(1);
if (hash === 'ip-stats' || hash === 'attacks') {
@@ -42,8 +46,8 @@ document.addEventListener('alpine:init', () => {
const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks();
} else if (h === 'admin') {
if (this.authenticated) this.switchToAdmin();
} else if (h === 'banlist') {
if (this.authenticated) this.switchToBanlist();
} else if (h !== 'ip-insight') {
if (this.tab !== 'ip-insight') {
this.switchToOverview();
@@ -72,15 +76,15 @@ document.addEventListener('alpine:init', () => {
window.location.hash = '#overview';
},
switchToAdmin() {
switchToBanlist() {
if (!this.authenticated) return;
this.tab = 'admin';
window.location.hash = '#admin';
this.tab = 'banlist';
window.location.hash = '#banlist';
this.$nextTick(() => {
const container = document.getElementById('admin-htmx-container');
const container = document.getElementById('banlist-htmx-container');
if (container && typeof htmx !== 'undefined') {
htmx.ajax('GET', `${this.dashboardPath}/htmx/admin`, {
target: '#admin-htmx-container',
htmx.ajax('GET', `${this.dashboardPath}/htmx/banlist`, {
target: '#banlist-htmx-container',
swap: 'innerHTML'
});
}
@@ -95,7 +99,7 @@ document.addEventListener('alpine:init', () => {
});
} catch {}
this.authenticated = false;
if (this.tab === 'admin') this.switchToOverview();
if (this.tab === 'banlist') this.switchToOverview();
},
promptAuth() {
@@ -134,7 +138,7 @@ document.addEventListener('alpine:init', () => {
if (resp.ok) {
this.authenticated = true;
this.closeAuthModal();
this.switchToAdmin();
this.switchToBanlist();
} else {
const data = await resp.json().catch(() => ({}));
this.authModal.error = data.error || 'Invalid password';
@@ -248,19 +252,123 @@ document.addEventListener('alpine:init', () => {
}));
});
// Helper to access Alpine.js component data
function getAlpineData(selector) {
const container = document.querySelector(selector);
if (!container) return null;
return Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
}
// Global function for opening IP Insight (used by map popups)
window.openIpInsight = function(ip) {
// Find the Alpine component and call openIpInsight
const container = document.querySelector('[x-data="dashboardApp()"]');
if (container) {
// Try Alpine 3.x API first, then fall back to older API
const data = Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
if (data && typeof data.openIpInsight === 'function') {
data.openIpInsight(ip);
}
const data = getAlpineData('[x-data="dashboardApp()"]');
if (data && typeof data.openIpInsight === 'function') {
data.openIpInsight(ip);
}
};
// Escape HTML to prevent XSS when inserting into innerHTML
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Custom modal system (replaces native confirm/alert)
window.krawlModal = {
_create(icon, iconClass, message, buttons) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'krawl-modal-overlay';
overlay.innerHTML = `
<div class="krawl-modal-box">
<div class="krawl-modal-icon ${iconClass}">
<span class="material-symbols-outlined">${icon}</span>
</div>
<div class="krawl-modal-message">${message}</div>
<div class="krawl-modal-actions" id="krawl-modal-actions"></div>
</div>`;
const actions = overlay.querySelector('#krawl-modal-actions');
buttons.forEach(btn => {
const el = document.createElement('button');
el.className = `auth-modal-btn ${btn.cls}`;
el.textContent = btn.label;
el.onclick = () => { overlay.remove(); resolve(btn.value); };
actions.appendChild(el);
});
overlay.addEventListener('click', e => {
if (e.target === overlay) { overlay.remove(); resolve(false); }
});
document.body.appendChild(overlay);
});
},
confirm(message) {
return this._create('warning', 'krawl-modal-icon-warn', message, [
{ label: 'Cancel', cls: 'auth-modal-btn-cancel', value: false },
{ label: 'Confirm', cls: 'auth-modal-btn-submit', value: true },
]);
},
success(message) {
return this._create('check_circle', 'krawl-modal-icon-success', message, [
{ label: 'OK', cls: 'auth-modal-btn-submit', value: true },
]);
},
error(message) {
return this._create('error', 'krawl-modal-icon-error', message, [
{ label: 'OK', cls: 'auth-modal-btn-cancel', value: true },
]);
},
};
// Global ban action for IP insight page (auth-gated)
window.ipBanAction = async function(ip, action) {
// Check if authenticated
const data = getAlpineData('[x-data="dashboardApp()"]');
if (!data || !data.authenticated) {
if (data && typeof data.promptAuth === 'function') data.promptAuth();
return;
}
const safeIp = escapeHtml(ip);
const safeAction = escapeHtml(action);
const confirmed = await krawlModal.confirm(`Are you sure you want to ${safeAction} IP <strong>${safeIp}</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 }),
});
const result = await resp.json().catch(() => ({}));
if (resp.ok) {
krawlModal.success(escapeHtml(result.message || `${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 {
krawlModal.error(escapeHtml(result.error || `Failed to ${action} IP ${ip}`));
}
} catch {
krawlModal.error('Request failed');
}
};
// Show/hide ban action buttons based on auth state
function updateBanActionVisibility(authenticated) {
document.querySelectorAll('.ip-ban-actions').forEach(el => {
el.style.display = authenticated ? 'inline-flex' : 'none';
});
}
// Update visibility after HTMX swaps in new content
document.addEventListener('htmx:afterSwap', () => {
const data = getAlpineData('[x-data="dashboardApp()"]');
if (data) updateBanActionVisibility(data.authenticated);
});
// Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A';

View File

@@ -0,0 +1,22 @@
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
src: url(fonts/material-symbols-outlined.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}