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';