From 8a651b00f94b618118f58bd9ae5b707638d724af Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:26:02 +0100 Subject: [PATCH 1/9] feat: add ban override management to IP statistics model and database manager --- src/database.py | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ src/models.py | 5 +++ 2 files changed, 102 insertions(+) diff --git a/src/database.py b/src/database.py index 803e7e7..f7b0a59 100644 --- a/src/database.py +++ b/src/database.py @@ -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.utcnow(), + last_seen=datetime.utcnow(), + ) + 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() diff --git a/src/models.py b/src/models.py index 8fb6e26..d759e52 100644 --- a/src/models.py +++ b/src/models.py @@ -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"" From a9aeb002798f648678688b8c11801fa0f99081cf Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:26:13 +0100 Subject: [PATCH 2/9] feat: add ban_override column to ip_stats and update attacker IP filtering logic --- src/migrations/runner.py | 11 +++++++++++ src/tasks/top_attacking_ips.py | 26 ++++++++++++++++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/migrations/runner.py b/src/migrations/runner.py index 7a74267..ebb5e0c 100644 --- a/src/migrations/runner.py +++ b/src/migrations/runner.py @@ -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}") diff --git a/src/tasks/top_attacking_ips.py b/src/tasks/top_attacking_ips.py index 69d417b..3e16134 100644 --- a/src/tasks/top_attacking_ips.py +++ b/src/tasks/top_attacking_ips.py @@ -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: From 2539713a1d6f328434c8b4c92796da8aca2692b2 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:26:57 +0100 Subject: [PATCH 3/9] feat: implement IP ban management with override functionality and UI updates --- src/routes/api.py | 35 ++++ src/routes/htmx.py | 53 ++++++ src/templates/jinja2/base.html | 1 + src/templates/jinja2/dashboard/index.html | 4 +- .../jinja2/dashboard/partials/_ip_detail.html | 30 +++- .../dashboard/partials/admin_panel.html | 130 +++++++++++++- .../partials/ban_attackers_table.html | 49 ++++++ .../partials/ban_overrides_table.html | 57 ++++++ src/templates/static/css/dashboard.css | 164 ++++++++++++++++++ src/templates/static/js/dashboard.js | 92 ++++++++++ 10 files changed, 603 insertions(+), 12 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/ban_attackers_table.html create mode 100644 src/templates/jinja2/dashboard/partials/ban_overrides_table.html diff --git a/src/routes/api.py b/src/routes/api.py index 9830af9..276537f 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -137,6 +137,41 @@ 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} + override_value = action_map.get(body.action) + 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, override_value) + + 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() diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 366c4a0..592b372 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -434,3 +434,56 @@ async def htmx_admin(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( + "

Unauthorized

", 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( + "

Unauthorized

", 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"], + }, + ) diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html index 22105c4..8359e42 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -8,6 +8,7 @@ + diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 9a551dc..e3cc6c6 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,7 +59,7 @@ IP Insight - Admin + IP Banlist {# Lock icon (not authenticated) #} @@ -197,7 +197,7 @@ - {# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #} + {# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #}
diff --git a/src/templates/jinja2/dashboard/partials/_ip_detail.html b/src/templates/jinja2/dashboard/partials/_ip_detail.html index 1812b1d..dbdf879 100644 --- a/src/templates/jinja2/dashboard/partials/_ip_detail.html +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -3,14 +3,30 @@ {# Page header #}
-

- {{ ip_address }} - {% if stats.category %} - - {{ stats.category | replace('_', ' ') | title }} +
+

+ {{ ip_address }} + {% if stats.category %} + + {{ stats.category | replace('_', ' ') | title }} + + {% endif %} +

+ {# Ban/Unban actions — visible only when authenticated #} + - {% endif %} -

+
{% if stats.city or stats.country %}

{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }} diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/admin_panel.html index f70bb65..5737820 100644 --- a/src/templates/jinja2/dashboard/partials/admin_panel.html +++ b/src/templates/jinja2/dashboard/partials/admin_panel.html @@ -1,4 +1,128 @@ -

-

Admin Panel

-

This is a protected panel. More features coming soon.

+{# Ban management panel #} +
+ + {# Force ban IP form #} +
+

IP Banlist

+

+ Force-ban a new IP or manage existing ban overrides. Changes take effect on the next banlist export cycle (every 5 minutes). +

+
+
+ + +
+ +
+

+
+ + {# Attackers list with unban option #} +
+

Detected Attackers

+
+
Loading...
+
+
+ + {# Active overrides #} +
+

Active Ban Overrides

+
+
Loading...
+
+
+ + diff --git a/src/templates/jinja2/dashboard/partials/ban_attackers_table.html b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html new file mode 100644 index 0000000..db38878 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html @@ -0,0 +1,49 @@ +{# HTMX fragment: Attackers with unban action #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} attackers +
+ + +
+
+ + + + + + + + + + + + + + {% for ip in items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressTotal RequestsCategoryLocationLast Seen
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ ip.ip | e }}{{ ip.total_requests }}{{ ip.category | default('unknown') | e }}{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}{{ ip.last_seen | format_ts }} + +
No attackers found
diff --git a/src/templates/jinja2/dashboard/partials/ban_overrides_table.html b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html new file mode 100644 index 0000000..88a1a50 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html @@ -0,0 +1,57 @@ +{# HTMX fragment: Active ban overrides #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} overrides +
+ + +
+
+ + + + + + + + + + + + + + + {% for ip in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressOverrideCategoryTotal RequestsLocationLast Seen
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ ip.ip | e }} + {% if ip.ban_override == true %} + Force Banned + {% elif ip.ban_override == false %} + Force Unbanned + {% endif %} + {{ ip.category | default('unknown') | e }}{{ ip.total_requests }}{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}{{ ip.last_seen | format_ts }} + +
No active overrides
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 3b3c53b..dcc76bb 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -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; @@ -1301,6 +1307,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; diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 66e3eac..db5ef36 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -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') { @@ -261,6 +265,94 @@ window.openIpInsight = function(ip) { } }; +// 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 = ` +
+
+ ${icon} +
+
${message}
+
+
`; + 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 container = document.querySelector('[x-data="dashboardApp()"]'); + const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0])); + if (!data || !data.authenticated) { + if (data && typeof data.promptAuth === 'function') data.promptAuth(); + return; + } + const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP ${ip}?`); + 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(result.message || `${action} successful for ${ip}`); + } else { + krawlModal.error(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 container = document.querySelector('[x-data="dashboardApp()"]'); + const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0])); + if (data) updateBanActionVisibility(data.authenticated); +}); + // Utility function for formatting timestamps (used by map popups) function formatTimestamp(isoTimestamp) { if (!isoTimestamp) return 'N/A'; From 4df6c1f2ec8d9ec1e6a903c8ef715de53d7da529 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:31:37 +0100 Subject: [PATCH 4/9] feat: enhance category display in ban tables and adjust chart height --- .../jinja2/dashboard/partials/ban_attackers_table.html | 2 +- .../jinja2/dashboard/partials/ban_overrides_table.html | 2 +- src/templates/static/css/dashboard.css | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/templates/jinja2/dashboard/partials/ban_attackers_table.html b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html index db38878..6206e3a 100644 --- a/src/templates/jinja2/dashboard/partials/ban_attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html @@ -32,7 +32,7 @@ {{ loop.index + (pagination.page - 1) * pagination.page_size }} {{ ip.ip | e }} {{ ip.total_requests }} - {{ ip.category | default('unknown') | e }} + {{ ip.category | default('unknown') | replace('_', ' ') | title }} {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} {{ ip.last_seen | format_ts }} diff --git a/src/templates/jinja2/dashboard/partials/ban_overrides_table.html b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html index 88a1a50..71cd437 100644 --- a/src/templates/jinja2/dashboard/partials/ban_overrides_table.html +++ b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html @@ -39,7 +39,7 @@ Force Unbanned {% endif %} - {{ ip.category | default('unknown') | e }} + {{ ip.category | default('unknown') | replace('_', ' ') | title }} {{ ip.total_requests }} {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} {{ ip.last_seen | format_ts }} diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index dcc76bb..45a3fab 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -716,9 +716,8 @@ tbody { min-width: 0; } .ip-attack-chart-wrapper { - flex: 1; position: relative; - min-height: 180px; + height: 220px; } /* Radar chart */ From 347d9187893d95ac9f16aa625b12125ab92d5f8b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:38:33 +0100 Subject: [PATCH 5/9] code layout improvement --- .../dashboard/partials/admin_panel.html | 27 ------------------- .../partials/ban_attackers_table.html | 2 +- .../partials/ban_overrides_table.html | 2 +- src/templates/static/js/dashboard.js | 7 +++++ 4 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/admin_panel.html index 5737820..22bcedc 100644 --- a/src/templates/jinja2/dashboard/partials/admin_panel.html +++ b/src/templates/jinja2/dashboard/partials/admin_panel.html @@ -98,31 +98,4 @@ document.addEventListener('alpine:init', () => { })); }); -async function banAction(ip, action) { - const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP ${ip}?`); - 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'); - } -} diff --git a/src/templates/jinja2/dashboard/partials/ban_attackers_table.html b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html index 6206e3a..4569578 100644 --- a/src/templates/jinja2/dashboard/partials/ban_attackers_table.html +++ b/src/templates/jinja2/dashboard/partials/ban_attackers_table.html @@ -36,7 +36,7 @@ {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} {{ ip.last_seen | format_ts }} - diff --git a/src/templates/jinja2/dashboard/partials/ban_overrides_table.html b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html index 71cd437..b9b9786 100644 --- a/src/templates/jinja2/dashboard/partials/ban_overrides_table.html +++ b/src/templates/jinja2/dashboard/partials/ban_overrides_table.html @@ -44,7 +44,7 @@ {{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }} {{ ip.last_seen | format_ts }} - diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index db5ef36..41749cb 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -332,6 +332,13 @@ window.ipBanAction = async function(ip, action) { const result = await resp.json().catch(() => ({})); if (resp.ok) { krawlModal.success(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(result.error || `Failed to ${action} IP ${ip}`); } From c3fa0c11e6c870fc43bc1e6650ec64a394fff00b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:43:32 +0100 Subject: [PATCH 6/9] feat: update IP stats timestamps to use local time and improve dashboard JavaScript for safer data handling --- src/database.py | 4 +-- src/routes/api.py | 3 +-- src/templates/static/js/dashboard.js | 39 +++++++++++++++++----------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/database.py b/src/database.py index f7b0a59..84eff37 100644 --- a/src/database.py +++ b/src/database.py @@ -2269,8 +2269,8 @@ class DatabaseManager: ip_stats = IpStats( ip=sanitized_ip, total_requests=0, - first_seen=datetime.utcnow(), - last_seen=datetime.utcnow(), + first_seen=datetime.now(), + last_seen=datetime.now(), ) session.add(ip_stats) diff --git a/src/routes/api.py b/src/routes/api.py index 276537f..2338390 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -152,7 +152,6 @@ async def ban_override(request: Request, body: BanOverrideRequest): db = get_db() action_map = {"ban": True, "unban": False, "reset": None} - override_value = action_map.get(body.action) if body.action not in action_map: return JSONResponse( content={"error": "Invalid action. Use: ban, unban, reset"}, @@ -162,7 +161,7 @@ async def ban_override(request: Request, body: BanOverrideRequest): if body.action == "ban": success = db.force_ban_ip(body.ip) else: - success = db.set_ban_override(body.ip, override_value) + 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}") diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 41749cb..39e7df9 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -252,19 +252,28 @@ 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) { @@ -314,13 +323,14 @@ window.krawlModal = { // Global ban action for IP insight page (auth-gated) window.ipBanAction = async function(ip, action) { // Check if authenticated - const container = document.querySelector('[x-data="dashboardApp()"]'); - const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0])); + const data = getAlpineData('[x-data="dashboardApp()"]'); if (!data || !data.authenticated) { if (data && typeof data.promptAuth === 'function') data.promptAuth(); return; } - const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP ${ip}?`); + const safeIp = escapeHtml(ip); + const safeAction = escapeHtml(action); + const confirmed = await krawlModal.confirm(`Are you sure you want to ${safeAction} IP ${safeIp}?`); if (!confirmed) return; try { const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, { @@ -331,7 +341,7 @@ window.ipBanAction = async function(ip, action) { }); const result = await resp.json().catch(() => ({})); if (resp.ok) { - krawlModal.success(result.message || `${action} successful for ${ip}`); + 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`, { @@ -340,7 +350,7 @@ window.ipBanAction = async function(ip, action) { }); } } else { - krawlModal.error(result.error || `Failed to ${action} IP ${ip}`); + krawlModal.error(escapeHtml(result.error || `Failed to ${action} IP ${ip}`)); } } catch { krawlModal.error('Request failed'); @@ -355,8 +365,7 @@ function updateBanActionVisibility(authenticated) { } // Update visibility after HTMX swaps in new content document.addEventListener('htmx:afterSwap', () => { - const container = document.querySelector('[x-data="dashboardApp()"]'); - const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0])); + const data = getAlpineData('[x-data="dashboardApp()"]'); if (data) updateBanActionVisibility(data.authenticated); }); From 522bd7fb67a60ebdcaea9719667745ba9456e423 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:43:42 +0100 Subject: [PATCH 7/9] feat: replace external Material Symbols font with local version and add CSS for styling --- src/templates/jinja2/base.html | 2 +- .../css/fonts/material-symbols-outlined.woff2 | Bin 0 -> 1980 bytes .../static/vendor/css/material-symbols.css | 22 ++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/templates/static/vendor/css/fonts/material-symbols-outlined.woff2 create mode 100644 src/templates/static/vendor/css/material-symbols.css diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html index 8359e42..20f1991 100644 --- a/src/templates/jinja2/base.html +++ b/src/templates/jinja2/base.html @@ -8,7 +8,7 @@ - + diff --git a/src/templates/static/vendor/css/fonts/material-symbols-outlined.woff2 b/src/templates/static/vendor/css/fonts/material-symbols-outlined.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c2470a344e63042162c0a84ae648e7b4b4a4f2c2 GIT binary patch literal 1980 zcmV;t2SfOGPew8T0RR9100+DP4FCWD01k)%00(6N0RR9100000000000000000000 z0000Sf5h5vNRK)GspuIXKdvSRP2mlt-5eh0kF)bTvtEI^f zH5k~5x5A(XYqbbv6O9A|1;9gJ$2OEeJ#iGE1VJ=t1VaGNMl=*qAOKpZbquNuGC2w? z{6|fc2tWWvC)*cME)6efp*cZoPc>-C<6#A1}_4Yn39&Z%oUR|W8w(`anv<- z5DHYQSk*-6D$X}0MtEM7)f&&LmHY0yBp9^LTjWErME@tnXpZK0hOHz z0Sx32Pz(#IVTA)tXhr)+lyCS;>z7x)Qh&AJ$JIYR`16J&?K!LxS%zBJ(SVj}@G8>h z(jDoXbXGbeot92YC#2)Rq@dAzM`w)sW0YIdqlr_bosY%htpX?06sY}`M3SI6COU2w zTa-De$t|>EHOd5Bc1KcXlu~D>jwG=`TrQy+7`nHwJdo#=Od!wGw34BD)-o}Z6wGXv z<=L6D>*Z>sINUc_$&TU~PN9*P=I2$*a&lC8B%c?>%xse(casMSae5VQ^RR**_o=4M zUgGqyo80Ib&UZQqe_US-ItMoO_4J!K!WNq7n`^7>nwqx_Uu+~q+uj$ zW-A3N+-|pIfkXx z6h~Ato@l`{D$JVD$kjnpX|B+O5j~J==BCOxhHYV|P?k*|;xKMUxQRM9xzTl}OtbkM z4rFm0U#zmnCxLl#yuhvMbp2Vdv$t%N5&SdG&JHOFPRim*riw zl%RLk*s-lXUwwV6&zZF|vbHvIXI56^&f40Yky%+gBWp)>Gt<}IzP--hwl?aqDE~OR za;m=e7-E>C%)cZ-zUZ_pU?80 zpIxJh1 zhaoJrZFE~|YHHi)wp7>#^G>Fm*zErGhV5=`(tPOv9JfpCFY93&EDDKXB5BiLu`ZIC zEa4+{-!ErwXm9*-rmy#xbJw)jemQq;<(j+Q+qK*8zPM)Ho$ejl9e2B68`Ku=u!uEV zOG<8zF1Z<*9ks?79&xj{#5b+j|Jz0QRDJDFW1~Dz9@Q5%aAs)n!Wm-dI-z?+nYTT_ zI!CXMGF&No`{!P}NuDo{*|TKe^!5eI#;u%PJEY8g$XeYkO}2z-O-wE|-3l z&eE^bj{wL)0RRZV2+|3Fc1gcVztXSMu5ZIpgm&R&7+}C`oWP06bTn)URHiVK!<5jm z3_)6sfdaa3Pv1CFhN)l5MdMBtPN9ME<7^3O8~6x9NurN}nk@6t$i}C62C-&78OC9Q zSB^-e67qM#V--RR3yBeg!WalDLxcZXYcR1kCFZCSKqwZW4o!Fg4m7|93#>4s0@V;v zg(jpgNLQn5rYa!Ir~?&9LIW%)S2$5&D~Xk`po!8As9tSi7TRG$DI9@lL^_%oTQSj& z3Y233GIfWD=0IA|0Fx1J Date: Sun, 8 Mar 2026 12:45:51 +0100 Subject: [PATCH 8/9] feat: bump version to 1.1.5 in Chart.yaml --- helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 99fce39..6d4d5ab 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -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 From 4e34b4b80af31311941c70ea91d6e1a11e977db7 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:49:55 +0100 Subject: [PATCH 9/9] feat: refactor admin panel to banlist panel and update related routes and templates --- src/routes/htmx.py | 8 ++++---- src/templates/html/login_form.html | 2 +- src/templates/jinja2/dashboard/index.html | 6 +++--- .../{admin_panel.html => banlist_panel.html} | 0 src/templates/static/js/dashboard.js | 20 +++++++++---------- 5 files changed, 18 insertions(+), 18 deletions(-) rename src/templates/jinja2/dashboard/partials/{admin_panel.html => banlist_panel.html} (100%) diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 592b372..1b5ece9 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -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( '
' @@ -428,7 +428,7 @@ 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), diff --git a/src/templates/html/login_form.html b/src/templates/html/login_form.html index 247355e..6a99bfa 100644 --- a/src/templates/html/login_form.html +++ b/src/templates/html/login_form.html @@ -129,7 +129,7 @@ {# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #} -
-
+
+
{# Raw request modal - Alpine.js #} diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/banlist_panel.html similarity index 100% rename from src/templates/jinja2/dashboard/partials/admin_panel.html rename to src/templates/jinja2/dashboard/partials/banlist_panel.html diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 39e7df9..a1e104e 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -46,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(); @@ -76,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' }); } @@ -99,7 +99,7 @@ document.addEventListener('alpine:init', () => { }); } catch {} this.authenticated = false; - if (this.tab === 'admin') this.switchToOverview(); + if (this.tab === 'banlist') this.switchToOverview(); }, promptAuth() { @@ -138,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';