From 755de7f231e1daabd3ae74219f3a2b1fbbe15112 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 18:29:12 +0100 Subject: [PATCH 01/40] feat: add password configuration for dashboard access --- config.yaml | 5 +++++ src/config.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/config.yaml b/config.yaml index dd61720..cef8e52 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,11 @@ dashboard: # secret_path: super-secret-dashboard-path secret_path: null + # Password for accessing protected dashboard panels. + # If null, a random password will be generated and printed in the logs. + # Can also be set via KRAWL_DASHBOARD_PASSWORD env var. + password: null + backups: path: "backups" cron: "*/30 * * * *" diff --git a/src/config.py b/src/config.py index cb46bf6..0d6d648 100644 --- a/src/config.py +++ b/src/config.py @@ -28,6 +28,7 @@ class Config: canary_token_url: Optional[str] = None canary_token_tries: int = 10 dashboard_secret_path: str = None + dashboard_password: Optional[str] = None probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers @@ -176,6 +177,11 @@ class Config: if dashboard_path[:1] != "/": dashboard_path = f"/{dashboard_path}" + # Handle dashboard_password - auto-generate if null/not set + dashboard_password = dashboard.get("password") + if dashboard_password is None: + dashboard_password = os.urandom(25).hex() + return cls( port=server.get("port", 5000), delay=server.get("delay", 100), @@ -196,6 +202,7 @@ class Config: canary_token_url=canary.get("token_url"), canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, + dashboard_password=dashboard_password, probability_error_codes=behavior.get("probability_error_codes", 0), exports_path=exports.get("path", "exports"), backups_path=backups.get("path", "backups"), From 18536f0706876450b4c79ad74af847e5b0d20c5f Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:19:59 +0100 Subject: [PATCH 02/40] feat: implement FastAPI authentication with password protection for admin panel --- src/app.py | 6 +- src/config.py | 4 ++ src/routes/api.py | 56 ++++++++++++++++- src/routes/htmx.py | 26 ++++++++ src/templates/jinja2/dashboard/index.html | 18 ++++++ .../dashboard/partials/admin_panel.html | 4 ++ src/templates/static/css/dashboard.css | 17 ++++++ src/templates/static/js/dashboard.js | 61 ++++++++++++++++++- 8 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/admin_panel.html diff --git a/src/app.py b/src/app.py index 2b2df92..87904b8 100644 --- a/src/app.py +++ b/src/app.py @@ -72,11 +72,15 @@ async def lifespan(app: FastAPI): tasks_master = get_tasksmaster() tasks_master.run_scheduled_tasks() + password_line = "" + if config.dashboard_password_generated: + password_line = f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + banner = f""" ============================================================ DASHBOARD AVAILABLE AT -{config.dashboard_secret_path} +{config.dashboard_secret_path}{password_line} ============================================================ """ app_logger.info(banner) diff --git a/src/config.py b/src/config.py index 0d6d648..f43b390 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,7 @@ class Config: canary_token_tries: int = 10 dashboard_secret_path: str = None dashboard_password: Optional[str] = None + dashboard_password_generated: bool = False probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers @@ -179,8 +180,10 @@ class Config: # Handle dashboard_password - auto-generate if null/not set dashboard_password = dashboard.get("password") + dashboard_password_generated = False if dashboard_password is None: dashboard_password = os.urandom(25).hex() + dashboard_password_generated = True return cls( port=server.get("port", 5000), @@ -203,6 +206,7 @@ class Config: canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, dashboard_password=dashboard_password, + dashboard_password_generated=dashboard_password_generated, probability_error_codes=behavior.get("probability_error_codes", 0), exports_path=exports.get("path", "exports"), backups_path=backups.get("path", "backups"), diff --git a/src/routes/api.py b/src/routes/api.py index d94b3b6..9ef8f0f 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -7,13 +7,19 @@ All endpoints are prefixed with the secret dashboard path. """ import os +import secrets +import hmac -from fastapi import APIRouter, Request, Response, Query +from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse +from pydantic import BaseModel from dependencies import get_db from logger import get_app_logger +# Server-side session token store (valid tokens for authenticated sessions) +_auth_tokens: set = set() + router = APIRouter() @@ -26,6 +32,54 @@ def _no_cache_headers() -> dict: } +class AuthRequest(BaseModel): + password: str + + +def verify_auth(request: Request) -> bool: + """Check if the request has a valid auth session cookie.""" + token = request.cookies.get("krawl_auth") + return token is not None and token in _auth_tokens + + +@router.post("/api/auth") +async def authenticate(request: Request, body: AuthRequest): + config = request.app.state.config + if hmac.compare_digest(body.password, config.dashboard_password): + token = secrets.token_hex(32) + _auth_tokens.add(token) + response = JSONResponse(content={"authenticated": True}) + response.set_cookie( + key="krawl_auth", + value=token, + httponly=True, + samesite="strict", + ) + return response + return JSONResponse( + content={"authenticated": False, "error": "Invalid password"}, + status_code=401, + ) + + +@router.post("/api/auth/logout") +async def logout(request: Request): + token = request.cookies.get("krawl_auth") + if token and token in _auth_tokens: + _auth_tokens.discard(token) + response = JSONResponse(content={"authenticated": False}) + response.delete_cookie(key="krawl_auth") + return response + + +@router.get("/api/auth/check") +async def auth_check(request: Request): + """Check if the current session is authenticated.""" + if verify_auth(request): + return JSONResponse(content={"authenticated": True}) + return JSONResponse(content={"authenticated": False}, status_code=401) + + @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 549f044..a793c87 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -6,8 +6,10 @@ Server-rendered HTML partials for table pagination, sorting, IP details, and sea """ from fastapi import APIRouter, Request, Response, Query +from fastapi.responses import HTMLResponse from dependencies import get_db, get_templates +from routes.api import verify_auth router = APIRouter() @@ -408,3 +410,27 @@ async def htmx_search( "pagination": result["pagination"], }, ) + + +# ── Protected Admin Panel ──────────────────────────────────────────── + + +@router.get("/htmx/admin") +async def htmx_admin(request: Request): + if not verify_auth(request): + return HTMLResponse( + '
' + '

Nice try bozo

' + '
' + 'Diddy' + '
', + status_code=200, + ) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/admin_panel.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + }, + ) diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index fef46c6..8fba3e7 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,6 +59,19 @@ IP Insight + Admin + {# Lock icon (not authenticated) #} + + + + + + {# Logout icon (authenticated) #} + + + + + {# ==================== OVERVIEW TAB ==================== #} @@ -184,6 +197,11 @@ + {# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #} +
+
+
+ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/admin_panel.html new file mode 100644 index 0000000..f70bb65 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/admin_panel.html @@ -0,0 +1,4 @@ +
+

Admin Panel

+

This is a protected panel. More features coming soon.

+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 7297e89..98d1472 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -854,6 +854,23 @@ tbody { color: #484f58; background: transparent; } +.tab-right { + margin-left: auto; +} +.tab-lock-btn { + display: flex; + align-items: center; + padding: 12px 16px; + color: #8b949e; +} +.tab-lock-btn:hover { + color: #f0883e; + background: #1c2128; +} +.tab-lock-btn svg { + width: 16px; + height: 16px; +} .tab-content { display: none; } diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 329e1e4..c1b12d5 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -20,7 +20,16 @@ document.addEventListener('alpine:init', () => { // IP Insight state insightIp: null, - init() { + // Auth state (UI only — actual security enforced server-side via cookie) + authenticated: false, + + async init() { + // Check if already authenticated (cookie-based) + try { + const resp = await fetch(`${this.dashboardPath}/api/auth/check`, { credentials: 'same-origin' }); + if (resp.ok) this.authenticated = true; + } catch {} + // Handle hash-based tab routing const hash = window.location.hash.slice(1); if (hash === 'ip-stats' || hash === 'attacks') { @@ -32,8 +41,9 @@ 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 !== 'ip-insight') { - // Don't switch away from ip-insight via hash if already there if (this.tab !== 'ip-insight') { this.switchToOverview(); } @@ -61,6 +71,53 @@ document.addEventListener('alpine:init', () => { window.location.hash = '#overview'; }, + switchToAdmin() { + if (!this.authenticated) return; + this.tab = 'admin'; + window.location.hash = '#admin'; + this.$nextTick(() => { + const container = document.getElementById('admin-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/admin`, { + target: '#admin-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + + async logout() { + try { + await fetch(`${this.dashboardPath}/api/auth/logout`, { + method: 'POST', + credentials: 'same-origin', + }); + } catch {} + this.authenticated = false; + if (this.tab === 'admin') this.switchToOverview(); + }, + + async promptAuth() { + const password = prompt('Enter dashboard password:'); + if (!password) return; + try { + const resp = await fetch(`${this.dashboardPath}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + if (resp.ok) { + this.authenticated = true; + this.switchToAdmin(); + } else { + alert('Invalid password'); + } + } catch { + alert('Authentication failed'); + } + }, + switchToIpInsight() { // Only allow switching if an IP is selected if (!this.insightIp) return; From 5553559ea631826ebf35b358129715cb5631b198 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:40:16 +0100 Subject: [PATCH 03/40] feat: add dashboard password configuration to README and docker-compose --- README.md | 7 ++++++- docker-compose.yaml | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6bc2ba5..87a9af9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ docker run -d \ -e KRAWL_PORT=5000 \ -e KRAWL_DELAY=100 \ -e KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" \ + -e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \ -v krawl-data:/app/data \ --name krawl \ ghcr.io/blessedrebus/krawl:latest @@ -138,6 +139,7 @@ services: environment: - CONFIG_LOCATION=config.yaml - TZ=Europe/Rome + # - KRAWL_DASHBOARD_PASSWORD=my-secret-password volumes: - ./config.yaml:/app/config.yaml:ro # bind mount for firewall exporters @@ -197,6 +199,7 @@ You can use the [config.yaml](config.yaml) file for advanced configurations, suc | `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | | `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | +| `KRAWL_DASHBOARD_PASSWORD` | Password for protected dashboard panels | Auto-generated | | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | | `KRAWL_EXPORTS_PATH` | Path where firewalls rule sets are exported | `exports` | @@ -228,8 +231,9 @@ export KRAWL_LINKS_PER_PAGE_RANGE="5,25" export KRAWL_HTTP_RISKY_METHODS_THRESHOLD="0.2" export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15" -# Set custom dashboard path +# Set custom dashboard path and password export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" +export KRAWL_DASHBOARD_PASSWORD="my-secret-password" ``` Example of a Docker run with env variables: @@ -239,6 +243,7 @@ docker run -d \ -p 5000:5000 \ -e KRAWL_PORT=5000 \ -e KRAWL_DELAY=100 \ + -e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \ -e KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" \ --name krawl \ ghcr.io/blessedrebus/krawl:latest diff --git a/docker-compose.yaml b/docker-compose.yaml index 17680de..feab0f5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,8 @@ services: - "5000:5000" environment: - CONFIG_LOCATION=config.yaml + # Uncomment to set a custom dashboard password (auto-generated if not set) + # - KRAWL_DASHBOARD_PASSWORD=your-secret-password # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment # - TZ=${TZ} volumes: From 28a62f9ea13c2b88214a14a2a23abc0be34d2274 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:40:27 +0100 Subject: [PATCH 04/40] feat: add instructions for setting a dashboard password and create secret.yaml template --- kubernetes/README.md | 6 +++++- kubernetes/manifests/deployment.yaml | 6 ++++++ kubernetes/manifests/secret.yaml | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 kubernetes/manifests/secret.yaml diff --git a/kubernetes/README.md b/kubernetes/README.md index d803496..8e9bd01 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -22,13 +22,17 @@ Once the EXTERNAL-IP is assigned, access your deception server at `http:// Date: Fri, 6 Mar 2026 22:41:02 +0100 Subject: [PATCH 05/40] feat: update Helm chart for FastAPI authentication with dashboard password support --- helm/Chart.yaml | 4 ++-- helm/README.md | 1 + helm/templates/deployment.yaml | 7 +++++++ helm/templates/secret.yaml | 11 +++++++++++ helm/values.yaml | 4 ++++ 5 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 helm/templates/secret.yaml diff --git a/helm/Chart.yaml b/helm/Chart.yaml index e4e1cee..99fce39 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.3 -appVersion: 1.1.3 +version: 1.1.4 +appVersion: 1.1.4 keywords: - honeypot - security diff --git a/helm/README.md b/helm/README.md index 268ca00..efdfeb1 100644 --- a/helm/README.md +++ b/helm/README.md @@ -96,6 +96,7 @@ The following table lists the main configuration parameters of the Krawl chart a | Parameter | Description | Default | |-----------|-------------|---------| | `config.dashboard.secret_path` | Secret dashboard path (auto-generated if null) | `null` | +| `dashboardPassword` | Password for protected panels (injected via Secret as `KRAWL_DASHBOARD_PASSWORD` env, auto-generated if empty) | `""` | ### API Configuration diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 730f774..2758e6a 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -47,6 +47,13 @@ spec: - name: TZ value: {{ .Values.timezone | quote }} {{- end }} + {{- if .Values.dashboardPassword }} + - name: KRAWL_DASHBOARD_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "krawl.fullname" . }}-dashboard + key: dashboard-password + {{- end }} volumeMounts: - name: config mountPath: /app/config.yaml diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml new file mode 100644 index 0000000..9357ea6 --- /dev/null +++ b/helm/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.dashboardPassword }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "krawl.fullname" . }}-dashboard + labels: + {{- include "krawl.labels" . | nindent 4 }} +type: Opaque +stringData: + dashboard-password: {{ .Values.dashboardPassword | quote }} +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 3bdebd9..0c2e529 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -60,6 +60,10 @@ tolerations: [] affinity: {} +# Dashboard password for protected panels +# If empty, a random password will be auto-generated and printed in the logs +dashboardPassword: "" + # Application configuration (config.yaml structure) config: server: From 4fd5832fdc9f9151025eb11104d7c28cbc0b8d8c Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:06:29 +0100 Subject: [PATCH 06/40] feat: implement authentication modal and update dashboard password handling --- src/routes/api.py | 8 +- src/templates/jinja2/dashboard/index.html | 3 + .../jinja2/dashboard/partials/auth_modal.html | 39 +++++ src/templates/static/css/dashboard.css | 133 ++++++++++++++++++ src/templates/static/js/dashboard.js | 39 ++++- 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/auth_modal.html diff --git a/src/routes/api.py b/src/routes/api.py index 9ef8f0f..e830423 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -6,9 +6,10 @@ Migrated from handler.py dashboard API endpoints. All endpoints are prefixed with the secret dashboard path. """ +import hashlib +import hmac import os import secrets -import hmac from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse @@ -33,7 +34,7 @@ def _no_cache_headers() -> dict: class AuthRequest(BaseModel): - password: str + fingerprint: str def verify_auth(request: Request) -> bool: @@ -45,7 +46,8 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): config = request.app.state.config - if hmac.compare_digest(body.password, config.dashboard_password): + expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest() + if hmac.compare_digest(body.fingerprint, expected): token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 8fba3e7..9a551dc 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -205,5 +205,8 @@ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} + {# Auth modal - Alpine.js #} + {% include "dashboard/partials/auth_modal.html" %} + {% endblock %} diff --git a/src/templates/jinja2/dashboard/partials/auth_modal.html b/src/templates/jinja2/dashboard/partials/auth_modal.html new file mode 100644 index 0000000..af7ed8c --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/auth_modal.html @@ -0,0 +1,39 @@ +{# Authentication modal - Alpine.js controlled #} +
+
+
+
+ + + +

Authentication Required

+
+ × +
+
+

Enter the dashboard password to access protected panels.

+
+ +

+
+ +
+
+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 98d1472..3b3c53b 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -1168,6 +1168,139 @@ tbody { opacity: 1; } +/* Auth Modal */ +.auth-modal { + position: fixed; + z-index: 1000; + 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); +} +.auth-modal-content { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + width: 400px; + max-width: 90vw; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + animation: authModalIn 0.2s ease-out; +} +@keyframes authModalIn { + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.auth-modal-header { + padding: 20px 24px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #30363d; +} +.auth-modal-title { + display: flex; + align-items: center; + gap: 10px; + color: #58a6ff; +} +.auth-modal-title h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #e6edf3; +} +.auth-modal-close { + color: #8b949e; + font-size: 24px; + font-weight: bold; + cursor: pointer; + line-height: 20px; + transition: color 0.2s; +} +.auth-modal-close:hover { + color: #c9d1d9; +} +.auth-modal-body { + padding: 24px; +} +.auth-modal-description { + margin: 0 0 20px; + color: #8b949e; + font-size: 14px; + line-height: 1.5; +} +.auth-modal-input-group { + margin-bottom: 20px; +} +.auth-modal-input { + width: 100%; + padding: 10px 14px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #e6edf3; + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} +.auth-modal-input:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); +} +.auth-modal-input-error { + border-color: #f85149; +} +.auth-modal-input-error:focus { + border-color: #f85149; + box-shadow: 0 0 0 3px rgba(248, 81, 73, 0.15); +} +.auth-modal-error { + margin: 8px 0 0; + color: #f85149; + font-size: 13px; +} +.auth-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.auth-modal-btn { + padding: 8px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; +} +.auth-modal-btn-cancel { + background: transparent; + border-color: #30363d; + color: #8b949e; +} +.auth-modal-btn-cancel:hover { + background: #21262d; + color: #c9d1d9; +} +.auth-modal-btn-submit { + background: #238636; + color: #fff; + border-color: rgba(240, 246, 252, 0.1); +} +.auth-modal-btn-submit:hover { + background: #2ea043; +} +.auth-modal-btn-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* 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 c1b12d5..719d9dd 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -22,6 +22,7 @@ document.addEventListener('alpine:init', () => { // Auth state (UI only — actual security enforced server-side via cookie) authenticated: false, + authModal: { show: false, password: '', error: '', loading: false }, async init() { // Check if already authenticated (cookie-based) @@ -97,24 +98,50 @@ document.addEventListener('alpine:init', () => { if (this.tab === 'admin') this.switchToOverview(); }, - async promptAuth() { - const password = prompt('Enter dashboard password:'); - if (!password) return; + promptAuth() { + this.authModal = { show: true, password: '', error: '', loading: false }; + this.$nextTick(() => { + if (this.$refs.authPasswordInput) this.$refs.authPasswordInput.focus(); + }); + }, + + closeAuthModal() { + this.authModal.show = false; + this.authModal.password = ''; + this.authModal.error = ''; + this.authModal.loading = false; + }, + + async submitAuth() { + const password = this.authModal.password; + if (!password) { + this.authModal.error = 'Please enter a password'; + return; + } + this.authModal.error = ''; + this.authModal.loading = true; try { + const msgBuf = new TextEncoder().encode(password); + const hashBuf = await crypto.subtle.digest('SHA-256', msgBuf); + const fingerprint = Array.from(new Uint8Array(hashBuf)) + .map(b => b.toString(16).padStart(2, '0')).join(''); const resp = await fetch(`${this.dashboardPath}/api/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ password }), + body: JSON.stringify({ fingerprint }), }); if (resp.ok) { this.authenticated = true; + this.closeAuthModal(); this.switchToAdmin(); } else { - alert('Invalid password'); + this.authModal.error = 'Invalid password'; + this.authModal.loading = false; } } catch { - alert('Authentication failed'); + this.authModal.error = 'Authentication failed'; + this.authModal.loading = false; } }, From e1ec7ede4513c88bfbe93daec490d449a02ac26b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:09:22 +0100 Subject: [PATCH 07/40] feat: implement brute force protection and error handling for authentication --- src/routes/api.py | 57 +++++++++++++++++++++++++++- src/templates/static/js/dashboard.js | 16 +++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/routes/api.py b/src/routes/api.py index e830423..9830af9 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -10,6 +10,7 @@ import hashlib import hmac import os import secrets +import time from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse @@ -21,6 +22,12 @@ from logger import get_app_logger # Server-side session token store (valid tokens for authenticated sessions) _auth_tokens: set = set() +# Bruteforce protection: tracks failed attempts per IP +# { ip: { "attempts": int, "locked_until": float } } +_auth_attempts: dict = {} +_AUTH_MAX_ATTEMPTS = 5 +_AUTH_BASE_LOCKOUT = 30 # seconds, doubles on each lockout + router = APIRouter() @@ -45,9 +52,27 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): + ip = request.client.host + + # Check if IP is currently locked out + record = _auth_attempts.get(ip) + if record and record["locked_until"] > time.time(): + remaining = int(record["locked_until"] - time.time()) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Try again in {remaining}s", + "locked": True, + "retry_after": remaining, + }, + status_code=429, + ) + config = request.app.state.config expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest() if hmac.compare_digest(body.fingerprint, expected): + # Success — clear failed attempts + _auth_attempts.pop(ip, None) token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) @@ -58,8 +83,38 @@ async def authenticate(request: Request, body: AuthRequest): samesite="strict", ) return response + + # Failed attempt — track and possibly lock out + if not record: + record = {"attempts": 0, "locked_until": 0, "lockouts": 0} + _auth_attempts[ip] = record + record["attempts"] += 1 + + if record["attempts"] >= _AUTH_MAX_ATTEMPTS: + lockout = _AUTH_BASE_LOCKOUT * (2 ** record["lockouts"]) + record["locked_until"] = time.time() + lockout + record["lockouts"] += 1 + record["attempts"] = 0 + get_app_logger().warning( + f"Auth bruteforce: IP {ip} locked out for {lockout}s " + f"(lockout #{record['lockouts']})" + ) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Locked for {lockout}s", + "locked": True, + "retry_after": lockout, + }, + status_code=429, + ) + + remaining_attempts = _AUTH_MAX_ATTEMPTS - record["attempts"] return JSONResponse( - content={"authenticated": False, "error": "Invalid password"}, + content={ + "authenticated": False, + "error": f"Invalid password. {remaining_attempts} attempt{'s' if remaining_attempts != 1 else ''} remaining", + }, status_code=401, ) diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 719d9dd..66e3eac 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -136,8 +136,22 @@ document.addEventListener('alpine:init', () => { this.closeAuthModal(); this.switchToAdmin(); } else { - this.authModal.error = 'Invalid password'; + const data = await resp.json().catch(() => ({})); + this.authModal.error = data.error || 'Invalid password'; + this.authModal.password = ''; this.authModal.loading = false; + if (data.locked && data.retry_after) { + let remaining = data.retry_after; + const interval = setInterval(() => { + remaining--; + if (remaining <= 0) { + clearInterval(interval); + this.authModal.error = ''; + } else { + this.authModal.error = `Too many attempts. Try again in ${remaining}s`; + } + }, 1000); + } } } catch { this.authModal.error = 'Authentication failed'; From f391da202549b4b698c5ab30ba055379b71f246b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:14:07 +0100 Subject: [PATCH 08/40] code linted --- src/app.py | 4 +++- src/routes/htmx.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app.py b/src/app.py index 87904b8..7d313bb 100644 --- a/src/app.py +++ b/src/app.py @@ -74,7 +74,9 @@ async def lifespan(app: FastAPI): password_line = "" if config.dashboard_password_generated: - password_line = f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + password_line = ( + f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + ) banner = f""" diff --git a/src/routes/htmx.py b/src/routes/htmx.py index a793c87..366c4a0 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -421,9 +421,9 @@ async def htmx_admin(request: Request): return HTMLResponse( '
' '

Nice try bozo

' - '
' + "
" 'Diddy' - '
', + "", status_code=200, ) templates = get_templates() From 8a651b00f94b618118f58bd9ae5b707638d724af Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sun, 8 Mar 2026 12:26:02 +0100 Subject: [PATCH 09/40] 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 10/40] 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 11/40] 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 12/40] 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 13/40] 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 14/40] 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 15/40] 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 16/40] 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 17/40] 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'; From e6eed2f6472d8ca691fccfa805d658b56a8f1ec3 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:19 +0100 Subject: [PATCH 18/40] feat: implement IP tracking functionality with database integration --- src/database.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++ src/models.py | 26 +++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/database.py b/src/database.py index 84eff37..3998831 100644 --- a/src/database.py +++ b/src/database.py @@ -34,6 +34,7 @@ from models import ( AttackDetection, IpStats, CategoryHistory, + TrackedIp, ) from sanitizer import ( sanitize_ip, @@ -2328,6 +2329,104 @@ class DatabaseManager: finally: self.close_session() + # ── IP Tracking ────────────────────────────────────────────────── + + def track_ip(self, ip: str) -> bool: + """Add an IP to the tracked list with a snapshot of its current stats.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + existing = session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + if existing: + return True # already tracked + + # Snapshot essential data from ip_stats + stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + tracked = TrackedIp( + ip=sanitized_ip, + tracked_since=datetime.now(), + category=stats.category if stats else None, + total_requests=stats.total_requests if stats else 0, + country_code=stats.country_code if stats else None, + city=stats.city if stats else None, + last_seen=stats.last_seen if stats else None, + ) + session.add(tracked) + try: + session.commit() + return True + except Exception as e: + session.rollback() + applogger.error(f"Error tracking IP {sanitized_ip}: {e}") + return False + + def untrack_ip(self, ip: str) -> bool: + """Remove an IP from the tracked list.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + tracked = session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + if not tracked: + return False + session.delete(tracked) + try: + session.commit() + return True + except Exception as e: + session.rollback() + applogger.error(f"Error untracking IP {sanitized_ip}: {e}") + return False + + def is_ip_tracked(self, ip: str) -> bool: + """Check if an IP is currently tracked.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + try: + return session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() is not None + finally: + self.close_session() + + def get_tracked_ips_paginated( + self, + page: int = 1, + page_size: int = 25, + ) -> Dict[str, Any]: + """Get all tracked IPs, paginated. Reads only from tracked_ips table.""" + session = self.session + try: + total = session.query(TrackedIp).count() + total_pages = max(1, (total + page_size - 1) // page_size) + + tracked_rows = ( + session.query(TrackedIp) + .order_by(TrackedIp.tracked_since.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + items = [] + for t in tracked_rows: + items.append({ + "ip": t.ip, + "tracked_since": t.tracked_since.isoformat() if t.tracked_since else None, + "category": t.category, + "total_requests": t.total_requests or 0, + "country_code": t.country_code, + "city": t.city, + "last_seen": t.last_seen.isoformat() if t.last_seen else None, + }) + + return { + "tracked_ips": items, + "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 d759e52..727d70a 100644 --- a/src/models.py +++ b/src/models.py @@ -244,6 +244,32 @@ class CategoryHistory(Base): return f" {self.new_category})>" +class TrackedIp(Base): + """ + Manually tracked IP addresses for monitoring. + + Stores a snapshot of essential ip_stats data at tracking time + so the tracked IPs panel never needs to query the large ip_stats table. + """ + + __tablename__ = "tracked_ips" + + ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), primary_key=True) + tracked_since: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.utcnow + ) + + # Snapshot from ip_stats at tracking time + category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + total_requests: Mapped[int] = mapped_column(Integer, default=0, nullable=True) + country_code: Mapped[Optional[str]] = mapped_column(String(2), nullable=True) + city: Mapped[Optional[str]] = mapped_column(String(MAX_CITY_LENGTH), nullable=True) + last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + def __repr__(self) -> str: + return f"" + + # class IpLog(Base): # """ # Records all IPs that have accessed the honeypot, along with aggregated stats and inferred user category. From 4e024b785e2b02d6c8e492ca8490061faade63ee Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:25 +0100 Subject: [PATCH 19/40] feat: add IP tracking functionality with track and untrack actions --- src/routes/api.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/routes/api.py b/src/routes/api.py index 2338390..661b3cc 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -171,6 +171,38 @@ async def ban_override(request: Request, body: BanOverrideRequest): return JSONResponse(content={"error": "IP not found"}, status_code=404) +# ── Protected IP Tracking API ──────────────────────────────────────── + + +class TrackIpRequest(BaseModel): + ip: str + action: str # "track" or "untrack" + + +@router.post("/api/track-ip") +async def track_ip(request: Request, body: TrackIpRequest): + if not verify_auth(request): + return JSONResponse(content={"error": "Unauthorized"}, status_code=401) + + db = get_db() + if body.action == "track": + success = db.track_ip(body.ip) + elif body.action == "untrack": + success = db.untrack_ip(body.ip) + else: + return JSONResponse( + content={"error": "Invalid action. Use: track, untrack"}, + status_code=400, + ) + + if success: + get_app_logger().info(f"IP tracking: {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() From 0705bbd169ccbae3cb745dc9de7d2230190838d1 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:30 +0100 Subject: [PATCH 20/40] feat: add tracked IPs panel and endpoints for viewing tracked IPs --- src/routes/htmx.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 1b5ece9..98373e7 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -343,6 +343,8 @@ async def htmx_ip_insight(ip_address: str, request: Request): stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] stats["reverse_dns"] = stats.get("reverse") + is_tracked = db.is_ip_tracked(ip_address) + templates = get_templates() return templates.TemplateResponse( "dashboard/partials/ip_insight.html", @@ -351,6 +353,7 @@ async def htmx_ip_insight(ip_address: str, request: Request): "dashboard_path": _dashboard_path(request), "stats": stats, "ip_address": ip_address, + "is_tracked": is_tracked, }, ) @@ -371,6 +374,8 @@ async def htmx_ip_detail(ip_address: str, request: Request): stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] stats["reverse_dns"] = stats.get("reverse") + is_tracked = db.is_ip_tracked(ip_address) + templates = get_templates() return templates.TemplateResponse( "dashboard/partials/ip_detail.html", @@ -378,6 +383,7 @@ async def htmx_ip_detail(ip_address: str, request: Request): "request": request, "dashboard_path": _dashboard_path(request), "stats": stats, + "is_tracked": is_tracked, }, ) @@ -464,6 +470,55 @@ async def htmx_ban_attackers( ) +# ── Protected Tracked IPs Panel ────────────────────────────────────── + + +@router.get("/htmx/tracked-ips") +async def htmx_tracked_ips(request: Request): + if not verify_auth(request): + return HTMLResponse( + '
' + '

Nice try bozo

' + "
" + 'Diddy' + "
", + status_code=200, + ) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/tracked_ips_panel.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + }, + ) + + +@router.get("/htmx/tracked-ips/list") +async def htmx_tracked_ips_list( + 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_tracked_ips_paginated(page=max(1, page), page_size=page_size) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/tracked_ips_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["tracked_ips"], + "pagination": result["pagination"], + }, + ) + + @router.get("/htmx/ban/overrides") async def htmx_ban_overrides( request: Request, From 6e575c10ebcbca5cc89fc13677fda71157fb35ba Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:43 +0100 Subject: [PATCH 21/40] feat: implement tracked IP management panel with tracking actions and UI updates --- src/templates/jinja2/dashboard/index.html | 8 +- .../jinja2/dashboard/partials/_ip_detail.html | 13 ++- .../dashboard/partials/tracked_ips_panel.html | 89 +++++++++++++++++++ .../dashboard/partials/tracked_ips_table.html | 55 ++++++++++++ src/templates/static/css/dashboard.css | 55 +++++++++++- src/templates/static/js/dashboard.js | 56 +++++++++++- 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/tracked_ips_panel.html create mode 100644 src/templates/jinja2/dashboard/partials/tracked_ips_table.html diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 7defadf..9e7fee4 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,7 +59,8 @@
IP Insight - IP Banlist + Tracked IPs + IP Banlist {# Lock icon (not authenticated) #} @@ -197,6 +198,11 @@
+ {# ==================== TRACKED IPS 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 dbdf879..bd5d106 100644 --- a/src/templates/jinja2/dashboard/partials/_ip_detail.html +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -12,7 +12,7 @@ {% endif %} - {# Ban/Unban actions — visible only when authenticated #} + {# Ban/Unban + Track/Untrack actions — visible only when authenticated #}
{% if stats.city or stats.country %} diff --git a/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html b/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html new file mode 100644 index 0000000..e671c38 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html @@ -0,0 +1,89 @@ +{# IP Tracking management panel #} +
+ + {# Track IP form #} +
+

Tracked IPs

+

+ Track an IP address to monitor its activity. You can also track IPs from the IP Insight page. +

+ +
+ + +
+ + +

+
+ + {# Tracked IPs list #} +
+

Currently Tracked

+
+
Loading...
+
+
+
+ + diff --git a/src/templates/jinja2/dashboard/partials/tracked_ips_table.html b/src/templates/jinja2/dashboard/partials/tracked_ips_table.html new file mode 100644 index 0000000..da88f66 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/tracked_ips_table.html @@ -0,0 +1,55 @@ +{# HTMX fragment: Tracked IPs list #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} tracked +
+ + +
+
+ + + + + + + + + + + + + + + {% for ip in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressCategoryTotal RequestsLocationLast SeenTracked Since
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ ip.ip | 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 }}{{ ip.tracked_since | format_ts }} + + +
No tracked IPs
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 45a3fab..1207e4b 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -1338,7 +1338,7 @@ tbody { display: inline-flex; align-items: center; justify-content: center; - padding: 4px; + padding: 6px; background: none; border: none; border-radius: 4px; @@ -1346,12 +1346,12 @@ tbody { transition: color 0.2s, background 0.2s; } .ban-icon-btn svg { - width: 18px; - height: 18px; + width: 24px; + height: 24px; fill: currentColor; } .ban-icon-btn .material-symbols-outlined { - font-size: 20px; + font-size: 26px; } .ban-icon-unban { color: #3fb950; @@ -1392,6 +1392,53 @@ tbody { opacity: 1; } +/* IP Tracking buttons */ +.track-form-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; + border: 1px solid rgba(88, 166, 255, 0.3); + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.track-form-btn:hover:not(:disabled) { + background: rgba(88, 166, 255, 0.3); + border-color: rgba(88, 166, 255, 0.5); +} +.track-form-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.track-form-btn .material-symbols-outlined { + font-size: 18px; +} +.track-icon-track { + color: #58a6ff; +} +.track-icon-track:hover { + background: rgba(88, 166, 255, 0.15); +} +.track-icon-untrack { + color: #8b949e; +} +.track-icon-untrack:hover { + color: #c9d1d9; + background: rgba(139, 148, 158, 0.15); +} +.track-icon-inspect { + color: #d2a8ff; +} +.track-icon-inspect:hover { + background: rgba(210, 168, 255, 0.15); +} + /* Custom confirm/alert modal */ .krawl-modal-overlay { position: fixed; diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index a1e104e..1ead4a3 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -48,6 +48,8 @@ document.addEventListener('alpine:init', () => { this.switchToAttacks(); } else if (h === 'banlist') { if (this.authenticated) this.switchToBanlist(); + } else if (h === 'tracked-ips') { + if (this.authenticated) this.switchToTrackedIps(); } else if (h !== 'ip-insight') { if (this.tab !== 'ip-insight') { this.switchToOverview(); @@ -91,6 +93,21 @@ document.addEventListener('alpine:init', () => { }); }, + switchToTrackedIps() { + if (!this.authenticated) return; + this.tab = 'tracked-ips'; + window.location.hash = '#tracked-ips'; + this.$nextTick(() => { + const container = document.getElementById('tracked-ips-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/tracked-ips`, { + target: '#tracked-ips-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + async logout() { try { await fetch(`${this.dashboardPath}/api/auth/logout`, { @@ -99,7 +116,7 @@ document.addEventListener('alpine:init', () => { }); } catch {} this.authenticated = false; - if (this.tab === 'banlist') this.switchToOverview(); + if (this.tab === 'banlist' || this.tab === 'tracked-ips') this.switchToOverview(); }, promptAuth() { @@ -357,6 +374,43 @@ window.ipBanAction = async function(ip, action) { } }; +// Global track action for IP insight page (auth-gated) +window.ipTrackAction = async function(ip, action) { + const data = getAlpineData('[x-data="dashboardApp()"]'); + if (!data || !data.authenticated) { + if (data && typeof data.promptAuth === 'function') data.promptAuth(); + return; + } + const safeIp = escapeHtml(ip); + const label = action === 'track' ? 'track' : 'untrack'; + const confirmed = await krawlModal.confirm(`Are you sure you want to ${label} IP ${safeIp}?`); + if (!confirmed) return; + try { + const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/track-ip`, { + 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 || `${label} successful for ${ip}`)); + // Refresh tracked IPs list if visible + const container = document.getElementById('tracked-ips-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/tracked-ips/list?page=1`, { + target: '#tracked-ips-container', + swap: 'innerHTML' + }); + } + } else { + krawlModal.error(escapeHtml(result.error || `Failed to ${label} 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 => { From e814f1232a12bb7e3244c9e31c38d1e2d7c0833b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:56 +0100 Subject: [PATCH 22/40] linted code --- src/database.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/database.py b/src/database.py index 3998831..9407429 100644 --- a/src/database.py +++ b/src/database.py @@ -2380,7 +2380,10 @@ class DatabaseManager: session = self.session sanitized_ip = sanitize_ip(ip) try: - return session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() is not None + return ( + session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + is not None + ) finally: self.close_session() @@ -2405,15 +2408,19 @@ class DatabaseManager: items = [] for t in tracked_rows: - items.append({ - "ip": t.ip, - "tracked_since": t.tracked_since.isoformat() if t.tracked_since else None, - "category": t.category, - "total_requests": t.total_requests or 0, - "country_code": t.country_code, - "city": t.city, - "last_seen": t.last_seen.isoformat() if t.last_seen else None, - }) + items.append( + { + "ip": t.ip, + "tracked_since": ( + t.tracked_since.isoformat() if t.tracked_since else None + ), + "category": t.category, + "total_requests": t.total_requests or 0, + "country_code": t.country_code, + "city": t.city, + "last_seen": t.last_seen.isoformat() if t.last_seen else None, + } + ) return { "tracked_ips": items, From a261e393072977d1b0d0638767b75b6a4e904f10 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 15:01:42 +0100 Subject: [PATCH 23/40] chore: bump version to 1.1.6 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 6d4d5ab..ec5889f 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.5 -appVersion: 1.1.5 +version: 1.1.6 +appVersion: 1.1.6 keywords: - honeypot - security From 40f1051d1f2885be0a4705550fc0ad3ad7480614 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 17:54:35 +0100 Subject: [PATCH 24/40] feat: add access logging middleware and disable default uvicorn access log --- src/app.py | 28 ++++++++++++++++++++++++++-- src/logger.py | 4 ++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index 7d313bb..6364705 100644 --- a/src/app.py +++ b/src/app.py @@ -16,7 +16,7 @@ from config import get_config from tracker import AccessTracker, set_tracker from database import initialize_database from tasks_master import get_tasksmaster -from logger import initialize_logging, get_app_logger +from logger import initialize_logging, get_app_logger, get_access_logger from generators import random_server_header @@ -121,11 +121,35 @@ def create_app() -> FastAPI: application.add_middleware(DeceptionMiddleware) - # Banned IP check middleware (outermost — runs first on request) + # Banned IP check middleware from middleware.ban_check import BanCheckMiddleware application.add_middleware(BanCheckMiddleware) + # Access log middleware (outermost — logs every request with real client IP) + @application.middleware("http") + async def access_log_middleware(request: Request, call_next): + response: Response = await call_next(request) + from dependencies import get_client_ip + + client_ip = get_client_ip(request) + path = request.url.path + method = request.method + status = response.status_code + access_logger = get_access_logger() + + user_agent = request.headers.get("User-Agent", "") + tracker = request.app.state.tracker + suspicious = tracker.is_suspicious_user_agent(user_agent) + + if suspicious: + access_logger.warning( + f"[SUSPICIOUS] [{method}] {client_ip} - {path} - {status} - {user_agent[:50]}" + ) + else: + access_logger.info(f"[{method}] {client_ip} - {path} - {status}") + return response + # Mount static files for the dashboard config = get_config() secret = config.dashboard_secret_path.lstrip("/") diff --git a/src/logger.py b/src/logger.py index d556684..d65ce50 100644 --- a/src/logger.py +++ b/src/logger.py @@ -112,6 +112,10 @@ class LoggerManager: credential_file_handler.setFormatter(credential_format) self._credential_logger.addHandler(credential_file_handler) + # Disable uvicorn's default access log to avoid duplicate entries + # with the wrong (proxy) IP. Our custom access logger handles this. + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + self._initialized = True @property From 4442bcc4067cd9215d90f0754be1a910931170df Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 17:54:47 +0100 Subject: [PATCH 25/40] feat: enhance logging for authentication events --- src/config.py | 3 +++ src/routes/api.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/config.py b/src/config.py index f43b390..be0cf93 100644 --- a/src/config.py +++ b/src/config.py @@ -258,6 +258,9 @@ def override_config_from_env(config: Config = None): try: field_type = config.__dataclass_fields__[field].type env_value = os.environ[env_var] + # If password is overridden, it's no longer auto-generated + if field == "dashboard_password": + config.dashboard_password_generated = False if field_type == int: setattr(config, field, int(env_value)) elif field_type == float: diff --git a/src/routes/api.py b/src/routes/api.py index 661b3cc..08c9eeb 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -73,6 +73,7 @@ async def authenticate(request: Request, body: AuthRequest): if hmac.compare_digest(body.fingerprint, expected): # Success — clear failed attempts _auth_attempts.pop(ip, None) + get_app_logger().info(f"[AUTH] Successful login from {ip}") token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) @@ -85,6 +86,7 @@ async def authenticate(request: Request, body: AuthRequest): return response # Failed attempt — track and possibly lock out + get_app_logger().warning(f"[AUTH] Failed login attempt from {ip}") if not record: record = {"attempts": 0, "locked_until": 0, "lockouts": 0} _auth_attempts[ip] = record From 68375f6a32103aa811ee9d733775096a2febd1ae Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 17:54:51 +0100 Subject: [PATCH 26/40] feat: refine logging for honeypot trap requests --- src/routes/honeypot.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/routes/honeypot.py b/src/routes/honeypot.py index e4b384c..cd844d5 100644 --- a/src/routes/honeypot.py +++ b/src/routes/honeypot.py @@ -394,13 +394,6 @@ async def trap_page(request: Request, path: str): is_suspicious = tracker.is_suspicious_user_agent(user_agent) - if is_suspicious: - access_logger.warning( - f"[SUSPICIOUS] {client_ip} - {user_agent[:50]} - {full_path}" - ) - else: - access_logger.info(f"[REQUEST] {client_ip} - {full_path}") - # Record access unless the router dependency already handled it # (attack pattern or honeypot path → already recorded by _track_honeypot_request) if not tracker.detect_attack_type(full_path) and not tracker.is_honeypot_path( From 6218a196382f02623e9b444ff46a776ca361d1f6 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 17:59:00 +0100 Subject: [PATCH 27/40] feat: improve access logging for banned IPs and handle connection resets --- src/app.py | 10 +++++++++- src/middleware/ban_check.py | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index 6364705..2884162 100644 --- a/src/app.py +++ b/src/app.py @@ -129,9 +129,17 @@ def create_app() -> FastAPI: # Access log middleware (outermost — logs every request with real client IP) @application.middleware("http") async def access_log_middleware(request: Request, call_next): - response: Response = await call_next(request) from dependencies import get_client_ip + try: + response: Response = await call_next(request) + except ConnectionResetError: + client_ip = get_client_ip(request) + path = request.url.path + method = request.method + get_access_logger().info(f"[BANNED] [{method}] {client_ip} - {path}") + raise + client_ip = get_client_ip(request) path = request.url.path method = request.method diff --git a/src/middleware/ban_check.py b/src/middleware/ban_check.py index a3be689..c4b2e80 100644 --- a/src/middleware/ban_check.py +++ b/src/middleware/ban_check.py @@ -2,6 +2,7 @@ """ Middleware for checking if client IP is banned. +Resets the connection for banned IPs instead of sending a response. """ from starlette.middleware.base import BaseHTTPMiddleware @@ -11,6 +12,13 @@ from starlette.responses import Response from dependencies import get_client_ip +class ConnectionResetResponse(Response): + """Response that abruptly closes the connection without sending data.""" + + async def __call__(self, scope, receive, send): + raise ConnectionResetError() + + class BanCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Skip ban check for dashboard routes @@ -23,7 +31,7 @@ class BanCheckMiddleware(BaseHTTPMiddleware): tracker = request.app.state.tracker if tracker.is_banned_ip(client_ip): - return Response(status_code=500) + return ConnectionResetResponse() response = await call_next(request) return response From 1d3b01f61fe4cd3b48cea105876cc5243c85ac27 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 17:59:20 +0100 Subject: [PATCH 28/40] feat: update chart version to 1.1.7 --- helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index ec5889f..b66537f 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.6 -appVersion: 1.1.6 +version: 1.1.7 +appVersion: 1.1.7 keywords: - honeypot - security From ad8440c3d894f03059c0cced86b30289dcf3918a Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 18:20:49 +0100 Subject: [PATCH 29/40] fix connection exception issue --- src/app.py | 9 +-------- src/middleware/ban_check.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/app.py b/src/app.py index 2884162..a01df71 100644 --- a/src/app.py +++ b/src/app.py @@ -131,14 +131,7 @@ def create_app() -> FastAPI: async def access_log_middleware(request: Request, call_next): from dependencies import get_client_ip - try: - response: Response = await call_next(request) - except ConnectionResetError: - client_ip = get_client_ip(request) - path = request.url.path - method = request.method - get_access_logger().info(f"[BANNED] [{method}] {client_ip} - {path}") - raise + response: Response = await call_next(request) client_ip = get_client_ip(request) path = request.url.path diff --git a/src/middleware/ban_check.py b/src/middleware/ban_check.py index c4b2e80..5fcacf5 100644 --- a/src/middleware/ban_check.py +++ b/src/middleware/ban_check.py @@ -12,13 +12,6 @@ from starlette.responses import Response from dependencies import get_client_ip -class ConnectionResetResponse(Response): - """Response that abruptly closes the connection without sending data.""" - - async def __call__(self, scope, receive, send): - raise ConnectionResetError() - - class BanCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Skip ban check for dashboard routes @@ -31,7 +24,15 @@ class BanCheckMiddleware(BaseHTTPMiddleware): tracker = request.app.state.tracker if tracker.is_banned_ip(client_ip): - return ConnectionResetResponse() + from logger import get_access_logger + + get_access_logger().info( + f"[BANNED] [{request.method}] {client_ip} - {request.url.path}" + ) + transport = request.scope.get("transport") + if transport: + transport.close() + return Response(status_code=500) response = await call_next(request) return response From ba3e6c655dca4adfd3319f1693e73a5f9d5eae28 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 18:31:56 +0100 Subject: [PATCH 30/40] Fix: wrong ip in login logs --- src/app.py | 4 ++++ src/middleware/ban_check.py | 1 + src/routes/api.py | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index a01df71..d3d8e7b 100644 --- a/src/app.py +++ b/src/app.py @@ -133,6 +133,10 @@ def create_app() -> FastAPI: response: Response = await call_next(request) + # Banned requests are already logged by BanCheckMiddleware + if getattr(request.state, "banned", False): + return response + client_ip = get_client_ip(request) path = request.url.path method = request.method diff --git a/src/middleware/ban_check.py b/src/middleware/ban_check.py index 5fcacf5..9ab03f9 100644 --- a/src/middleware/ban_check.py +++ b/src/middleware/ban_check.py @@ -29,6 +29,7 @@ class BanCheckMiddleware(BaseHTTPMiddleware): get_access_logger().info( f"[BANNED] [{request.method}] {client_ip} - {request.url.path}" ) + request.state.banned = True transport = request.scope.get("transport") if transport: transport.close() diff --git a/src/routes/api.py b/src/routes/api.py index 08c9eeb..11ee8ce 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse from pydantic import BaseModel -from dependencies import get_db +from dependencies import get_db, get_client_ip from logger import get_app_logger # Server-side session token store (valid tokens for authenticated sessions) @@ -52,7 +52,7 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): - ip = request.client.host + ip = get_client_ip(request) # Check if IP is currently locked out record = _auth_attempts.get(ip) From 1eb4f54f5c249ebb53829f31b7453a4ac1cb567b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Tue, 10 Mar 2026 11:00:22 +0100 Subject: [PATCH 31/40] feat: implement in-memory caching for dashboard data and background warmup task --- src/dashboard_cache.py | 32 +++++++++++++++++ src/routes/api.py | 9 ++++- src/routes/dashboard.py | 19 +++++----- src/routes/htmx.py | 38 +++++++++++++------- src/tasks/dashboard_warmup.py | 68 +++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 src/dashboard_cache.py create mode 100644 src/tasks/dashboard_warmup.py diff --git a/src/dashboard_cache.py b/src/dashboard_cache.py new file mode 100644 index 0000000..c0dcd7f --- /dev/null +++ b/src/dashboard_cache.py @@ -0,0 +1,32 @@ +""" +In-memory cache for dashboard Overview data. + +A background task periodically refreshes this cache so the dashboard +serves pre-computed data instantly instead of hitting SQLite cold. + +Memory footprint is fixed — each key is overwritten on every refresh. +""" + +import threading +from typing import Any, Dict, Optional + +_lock = threading.Lock() +_cache: Dict[str, Any] = {} + + +def get_cached(key: str) -> Optional[Any]: + """Get a value from the dashboard cache.""" + with _lock: + return _cache.get(key) + + +def set_cached(key: str, value: Any) -> None: + """Set a value in the dashboard cache.""" + with _lock: + _cache[key] = value + + +def is_warm() -> bool: + """Check if the cache has been populated at least once.""" + with _lock: + return "stats" in _cache diff --git a/src/routes/api.py b/src/routes/api.py index 11ee8ce..64e05b7 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from dependencies import get_db, get_client_ip from logger import get_app_logger +from dashboard_cache import get_cached, is_warm # Server-side session token store (valid tokens for authenticated sessions) _auth_tokens: set = set() @@ -249,10 +250,16 @@ async def all_ips( sort_by: str = Query("total_requests"), sort_order: str = Query("desc"), ): - db = get_db() page = max(1, page) page_size = min(max(1, page_size), 10000) + # Serve from cache on default map request (top 100 IPs) + if page == 1 and page_size == 100 and sort_by == "total_requests" and sort_order == "desc" and is_warm(): + cached = get_cached("map_ips") + if cached: + return JSONResponse(content=cached, headers=_no_cache_headers()) + + db = get_db() try: result = db.get_all_ips_paginated( page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py index 081336c..37f9d51 100644 --- a/src/routes/dashboard.py +++ b/src/routes/dashboard.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse from logger import get_app_logger from dependencies import get_db, get_templates +from dashboard_cache import get_cached, is_warm router = APIRouter() @@ -17,17 +18,19 @@ router = APIRouter() @router.get("") @router.get("/") async def dashboard_page(request: Request): - db = get_db() config = request.app.state.config dashboard_path = "/" + config.dashboard_secret_path.lstrip("/") - # Get initial data for server-rendered sections - stats = db.get_dashboard_counts() - suspicious = db.get_recent_suspicious(limit=10) - - # Get credential count for the stats card - cred_result = db.get_credentials_paginated(page=1, page_size=1) - stats["credential_count"] = cred_result["pagination"]["total"] + # Serve from pre-computed cache when available, fall back to live queries + if is_warm(): + stats = get_cached("stats") + suspicious = get_cached("suspicious") + else: + db = get_db() + stats = db.get_dashboard_counts() + suspicious = db.get_recent_suspicious(limit=10) + cred_result = db.get_credentials_paginated(page=1, page_size=1) + stats["credential_count"] = cred_result["pagination"]["total"] templates = get_templates() return templates.TemplateResponse( diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 98373e7..2d7e20d 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse from dependencies import get_db, get_templates from routes.api import verify_auth +from dashboard_cache import get_cached, is_warm router = APIRouter() @@ -58,10 +59,15 @@ async def htmx_top_ips( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_ips_paginated( - page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order - ) + # Serve from cache on default first-page request + cached = get_cached("top_ips") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_ips_paginated( + page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( @@ -87,10 +93,14 @@ async def htmx_top_paths( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_paths_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order - ) + cached = get_cached("top_paths") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_paths_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( @@ -116,10 +126,14 @@ async def htmx_top_ua( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_user_agents_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order - ) + cached = get_cached("top_ua") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_user_agents_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( diff --git a/src/tasks/dashboard_warmup.py b/src/tasks/dashboard_warmup.py new file mode 100644 index 0000000..3734864 --- /dev/null +++ b/src/tasks/dashboard_warmup.py @@ -0,0 +1,68 @@ +# tasks/dashboard_warmup.py + +""" +Pre-computes all Overview tab data and stores it in the in-memory cache. +This keeps SQLite page buffers warm and lets the dashboard respond instantly. +""" + +from logger import get_app_logger +from database import get_database +from dashboard_cache import set_cached + +app_logger = get_app_logger() + +# ---------------------- +# TASK CONFIG +# ---------------------- +TASK_CONFIG = { + "name": "dashboard-warmup", + "cron": "*/1 * * * *", + "enabled": True, + "run_when_loaded": True, +} + + +# ---------------------- +# TASK LOGIC +# ---------------------- +def main(): + """ + Refresh the in-memory dashboard cache with current Overview data. + TasksMaster will call this function based on the cron schedule. + """ + task_name = TASK_CONFIG.get("name") + app_logger.info(f"[Background Task] {task_name} starting...") + + try: + db = get_database() + + # --- Server-rendered data (stats cards + suspicious table) --- + stats = db.get_dashboard_counts() + + cred_result = db.get_credentials_paginated(page=1, page_size=1) + stats["credential_count"] = cred_result["pagination"]["total"] + + suspicious = db.get_recent_suspicious(limit=10) + + # --- HTMX Overview tables (first page, default sort) --- + top_ips = db.get_top_ips_paginated(page=1, page_size=8) + top_ua = db.get_top_user_agents_paginated(page=1, page_size=5) + top_paths = db.get_top_paths_paginated(page=1, page_size=5) + + # --- Map data (default: top 100 IPs by total_requests) --- + map_ips = db.get_all_ips_paginated( + page=1, page_size=100, sort_by="total_requests", sort_order="desc" + ) + + # Store everything in the cache (overwrites previous values) + set_cached("stats", stats) + set_cached("suspicious", suspicious) + set_cached("top_ips", top_ips) + set_cached("top_ua", top_ua) + set_cached("top_paths", top_paths) + set_cached("map_ips", map_ips) + + app_logger.info(f"[Background Task] {task_name} cache refreshed successfully.") + + except Exception as e: + app_logger.error(f"[Background Task] {task_name} failed: {e}") From 9fde3f49bebb174747d94e53628e936665ef339d Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Tue, 10 Mar 2026 11:00:32 +0100 Subject: [PATCH 32/40] chore: remove unused dashboard and database images --- img/dashboard-1.png | Bin 136202 -> 0 bytes img/dashboard-2.png | Bin 77787 -> 0 bytes img/dashboard-3.png | Bin 210421 -> 0 bytes img/database.png | Bin 73797 -> 0 bytes 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 img/dashboard-1.png delete mode 100644 img/dashboard-2.png delete mode 100644 img/dashboard-3.png delete mode 100644 img/database.png diff --git a/img/dashboard-1.png b/img/dashboard-1.png deleted file mode 100644 index 4479914823a5b58fae54cd74e1185f8d788b104a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136202 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 z 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
JA+bzSe8ehaDT3JZS#p>O;10J*YxIBtw`9K1J zmVy;QSd|Z5|CZ!?U=?OFVSk|iYUghoqz^B(1kYyzRJ3=r=B#ls%?Eg+>esj@do#Z8 zpCDKN8*y(L5LNrGj}ARFNDbYH0@6r#DJ7zS<0@A_&64D?I(%s!i zNuCGweSdrJ^WPuN`EU+jfQdD;o^?NWT-SBq%k~VXXWxn2a#-PXzfS9so6eURt&#bF z?RMCClYz45v8AlROwhup4`;`Lrr?Rz2NBA^x&C4Yk9Zvn`(X=giWqe@TiG@zG?6X!jApY!@ zx^6{{H`9jB|E5R(Bh&vz4sh8hc9%PBuMgH9GSTP;L}6&e#-#-v)ZSM&PV-rLqYy}XA$inofdz1TD4eK$G)2#2nkU5%8y!5$ z){Q7%?{BU{-*aS>O4N#CJ->aH(O3UM{^1!lmxYSO)p6h0^{7PCk^@af<#ElG8p~M- z$|v3$`_rEGN3(lQkvf;6(h_O@oNkK9sxRZ9aIj>Xt1V2P!+fMSnZKL03?utV<63C2 zAU;@}-%=MqyULv9_<+J^two&R&QaHNwBMU$!4s5zk)O;{yhpBj6T;Yr+nQZ0M|6TD zb#Htl-fm@7?RCQ5^5%clh4&+VYX8%DV5gEDGgoHla&Ha`LN@!b<(QJt9?efAgrW9~ z>$AvLeA$N_BZ5rgUvUSos&E&5e_+H*_|PzK<5nRnx;s+(TnwsPOaT=$xqY@SlviJ5 zUNdTl=r5Y1-q)%O0HFWU!twuP)Jk-@a7I?JUsk5c+rYngjGg6?3j&}z&VeP2g}wiO zgEu!*a{jluHNWQq!Dl1Ask-Ff8@KAVc5+{FA3Lxo3qA4rpCAm!sXET}@GTtj|FU!3 zAQNGFEy{_(8Fxd%-nZC5drZHxZ)gO+hFTa&cjdV~x>#+suyLyQ z4*L*8xU+%;uku9MIg?0Sqoje9znkk1z_6F9=6GxuKa6jb==*e9!sU|SI)c+-G;G&l z>87$ce$Lq-xqK{w6KvF?jFZ5JfuayH*JLQ4u_fy{sw8{A>9tX_x^Gb+H)pVFYhN|D zS+%R~4WGc9@IIXikIsmcv>UZzm7_7dmRWn>LoBoX?q61BqvMSJc;GpV!UJL`=1KeQ zVj{#xLO)ol&d*2a*QxroB5DW*t9gLyf{Y@USJYq)!)q>`h`|>ZI5mdak-PaO++3^A zT9B*uDyg=7q%L%y`dKl;*=N_GRmg+(=VGrc*vG>nvU1XQgYTE@o(P?ee&RGD{}92= z%PVR}JJpK=DYz2hozAG>Dsk{q|U$8Fj5S=`i4+UKi2V~b2F0an*u-m1D? zb*hVPCa;&WV2JSK?&~O#wC{4W;`Cf=+szYS4M2?%E;X9UDvnjh_An)}@ZDs|V!GEq z$eW4;K8iw6y3y_}K0T3eCEBf~=i6MeQAl^MMG4ZbE$>sj`$Y$dat<3MI)hF|H>ZQ#kGg4%zG7`5_)bTfX@&#teAoPd{T0=&dByK4cV67o`A zNMLMS@BxwuFL%Yfx5Mcz8&P*sk&)Vvz4s23X=J<9x@Y~#2}=gtZrthW=sbP@S>39odHRD6D}w}Po4N!M@g zhdWz%>gIJ7)7&3@g$pY0f&}&8t}k1nTeJ|_d(CU)D31`&G4JUvWfj?QGzd)-E1xdj zPY@J`4DZ%OQYe5MdU9@NQ*OM<@mW{w^jOFJ8rw z%7-nJEC@WcYp&*>3|d(K6Atg!K`i(>jc%RI8uzckpX{EOF>QXKJ-aw3{n%4W9WSBd z_h$N_H`Ulz6jKUeIA6=8Na`&ZZ zyWG2$dHlxIN8z~m&6&sg%3g~`z3loxR&-18QSqgm(1RRmc5y#|1n*6kn!qHuRfHyt zCQ!;=3mSiP8bu73$A!N!SyXT+Y3z1rMe*Mcc~$N z!mEo6|B;0hQ`6ZAk--T?)QmTKm51tM&!^HiWz?X-hnZEJdm&fno(Zq*8orL^WVoM8 zEGOol3K_q+-=MvZ_91E~XTBnKzv^_@Fs1phzA<6;5?!5arUhKJ01wY%t<;LXY0;aq zE*(3J(9jc;sk!;AZim)yGKcQo!_c{XYp(5KtL_9btOnLd9|XmT>2n6~xROGA;~2WO zQf0>3W7-xbPu5wwgKC3nqs%N5`|XXVUSEq9g&w}oI2%-Oq;AfBRyr`yRQe12}4$#0ZPH~a%5UU2(Tw^0T-$j(GQEN;kU}hM?Q0F7K5w&8Ch4Is6QQqZSI$Q_%19`QEsVQ`Os1* z%<23sQPa4;=C7QpHQ4e0u~OlTR{apR)}Not`P4Pt1^MYYo(MWx-^HD?@rU*{N`wU7 z#U&rYGJd<%Q?|QZ_ZW>kt#GKkI6YJ@>lN3W$PuB#C>?v{KKSRWxg8UGJm0+NF>1Db zMYI_#os>0pap7 zPGYwgzMM4^x`m)aH)>Z?mRbJlq#av{T3mNiL}9?GYI4YKz|z;^vek1w3M zz~1FcuMutZz)x&&SltoM{JsaZqW&q)9Q(IR8av@16Tu;T=Z;znr6QfBQXkCQ zlTHR+-yQPMe_wyAi?l~NU~>M?rkx%(`rz|Zttve+a)_*AKJ{b1V5!eHj~?}B7&@6o zZVU_rYboFG$op)GudgEKE3vas&}XCRKD0hpAV-n|FZqsy;y?E!(t+KCk>~0~MfcuLH6+ugT)iSKtSYVITwf+3xLNOZHQJ7M zJtz+P#|6(acOHVi>wNa%X~zGe!TS#{`48vL_z$0EeRS)A*r@9U;?|;}7j)D>O*XGQ zb<^e6`4D*|PkosFi>s$X;ko*x;y*=fu2%)###5uT93X8Po@3uURN%dS;-@V>x0g{& z?LSIQYbWFEj6>6%6zIGCi>kw9r)_rjo-BCiYO(ZX^e;NI?=#}=tgc>FxUU%H8D4fz zpKisaxN})YXZaL(>krZ-1k@J*FWO(Q zMLh32{`=)(=ZkA#OzOa{z;TUxn}z%m@~Rh`ZT(Xl8KE@P0yJ;c%B5oxu4K6?gwhDCSn3Wq+H)Aq?|A>Ks zO)2e3|L?{)iu~9JiY7&l4v$v=YwE>sR_j5` z7;=YHT6U-htglS=Z(ee{IJ}g57l>uvs{LJUF`4FGkUtxVclI7P+CJ-J&=isq37Qo` zyVMvQ&=txQyXPAn_?zcbvOhc*kd@fG%ae8%#ROFk*){myxI^X$08PXBQ~WPqf!;~% zUo~5`ko8~=m*ypkd2}i&vJ64`hji+DSG$T^r^R1$pGI5YClh}9tY%bjx8+wQONK?e z!`d64aC(Ph%n5kW4gUw8nh2~A__YPJMqt$M{p28UM-1S4S|rRXK5M^e5Vsp(XM!lY z&ylZ|T(rkBn0-~HMaD_BOuI)Sv>+J2tk{Kj!@$awyE>^ zUe@+1pCf#$|3K;>tKQMHF=Ra&KAS1%h0rbK%AS$QxIVMo(z)XK%pt?g{!@LKct+ng zNHo!fSboyFIvfcUGpS$-Px1pV;zza59(y8ShQuh%aTs*;LWHa{!MA=<8Q*>;J+fhv z=iM)I*FeZ$S=xqJ)qC%glMpC-ij7%sZIdH_0%bGQJXk%n%Ym5(4^t3`khF>3|1Ry< z%$SFqdm68%Io2e0uZ?7H5!`oWKa|R{U;Q}?Z>z3nv@AhNGAM--u6}j5xD)w}Z?oPJ zYX0p4W{b#wq>F2D)