diff --git a/Dockerfile b/Dockerfile index 4015c74..f6caa8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,4 +26,4 @@ EXPOSE 5000 ENV PYTHONUNBUFFERED=1 ENTRYPOINT ["/app/entrypoint.sh"] -CMD ["python3", "src/server.py"] +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--app-dir", "src"] diff --git a/requirements.txt b/requirements.txt index b3f9b03..2c0b7b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,9 @@ SQLAlchemy>=2.0.0,<3.0.0 APScheduler>=3.11.2 requests>=2.32.5 + +# Web framework +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +jinja2>=3.1.0 +python-multipart>=0.0.9 \ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..4e5ee9d --- /dev/null +++ b/src/app.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +""" +FastAPI application factory for the Krawl honeypot. +Replaces the old http.server-based server.py. +""" + +import sys +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, Response +from fastapi.staticfiles import StaticFiles + +from config import get_config +from tracker import AccessTracker +from database import initialize_database +from tasks_master import get_tasksmaster +from logger import initialize_logging, get_app_logger +from generators import random_server_header + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application startup and shutdown lifecycle.""" + config = get_config() + + # Initialize logging + initialize_logging() + app_logger = get_app_logger() + + # Initialize database + try: + initialize_database(config.database_path) + app_logger.info(f"Database initialized at: {config.database_path}") + except Exception as e: + app_logger.warning( + f"Database initialization failed: {e}. Continuing with in-memory only." + ) + + # Initialize tracker + tracker = AccessTracker(config.max_pages_limit, config.ban_duration_seconds) + + # Store in app.state for dependency injection + app.state.config = config + app.state.tracker = tracker + + # Load webpages file if provided via env var + webpages = None + webpages_file = os.environ.get("KRAWL_WEBPAGES_FILE") + if webpages_file: + try: + with open(webpages_file, "r") as f: + webpages = f.readlines() + if not webpages: + app_logger.warning( + "The webpages file was empty. Using randomly generated links." + ) + webpages = None + except IOError: + app_logger.warning("Can't read webpages file. Using randomly generated links.") + app.state.webpages = webpages + + # Initialize canary counter + app.state.counter = config.canary_token_tries + + # Start scheduled tasks + tasks_master = get_tasksmaster() + tasks_master.run_scheduled_tasks() + + banner = f""" + +============================================================ +DASHBOARD AVAILABLE AT +{config.dashboard_secret_path} +============================================================ + """ + app_logger.info(banner) + app_logger.info(f"Starting deception server on port {config.port}...") + if config.canary_token_url: + app_logger.info( + f"Canary token will appear after {config.canary_token_tries} tries" + ) + else: + app_logger.info( + "No canary token configured (set CANARY_TOKEN_URL to enable)" + ) + + yield + + # Shutdown + app_logger.info("Server shutting down...") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + application = FastAPI( + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + # Random server header middleware (innermost — runs last on request, first on response) + @application.middleware("http") + async def server_header_middleware(request: Request, call_next): + response: Response = await call_next(request) + response.headers["Server"] = random_server_header() + return response + + # Deception detection middleware (path traversal, XXE, command injection) + from middleware.deception import DeceptionMiddleware + + application.add_middleware(DeceptionMiddleware) + + # Banned IP check middleware (outermost — runs first on request) + from middleware.ban_check import BanCheckMiddleware + + application.add_middleware(BanCheckMiddleware) + + # Mount static files for the dashboard + config = get_config() + secret = config.dashboard_secret_path.lstrip("/") + static_dir = os.path.join(os.path.dirname(__file__), "templates", "static") + application.mount( + f"/{secret}/static", + StaticFiles(directory=static_dir), + name="dashboard-static", + ) + + # Import and include routers + from routes.honeypot import router as honeypot_router + from routes.api import router as api_router + from routes.dashboard import router as dashboard_router + from routes.htmx import router as htmx_router + + # Dashboard/API/HTMX routes (prefixed with secret path, before honeypot catch-all) + dashboard_prefix = f"/{secret}" + application.include_router(dashboard_router, prefix=dashboard_prefix) + application.include_router(api_router, prefix=dashboard_prefix) + application.include_router(htmx_router, prefix=dashboard_prefix) + + # Honeypot routes (catch-all must be last) + application.include_router(honeypot_router) + + return application + + +app = create_app() \ No newline at end of file diff --git a/src/database.py b/src/database.py index 5c1a275..54d8636 100644 --- a/src/database.py +++ b/src/database.py @@ -359,6 +359,16 @@ class DatabaseManager: sanitized_ip = sanitize_ip(ip) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + if not ip_stats: + applogger.warning( + f"No IpStats record found for {sanitized_ip}, creating one." + ) + now = datetime.now() + ip_stats = IpStats( + ip=sanitized_ip, total_requests=0, first_seen=now, last_seen=now + ) + session.add(ip_stats) + # Check if category has changed and record it old_category = ip_stats.category if old_category != category: @@ -390,6 +400,10 @@ class DatabaseManager: sanitized_ip = sanitize_ip(ip) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + if not ip_stats: + applogger.warning(f"No IpStats record found for {sanitized_ip}") + return + # Record the manual category change old_category = ip_stats.category if old_category != category: diff --git a/src/dependencies.py b/src/dependencies.py new file mode 100644 index 0000000..d5d8bf1 --- /dev/null +++ b/src/dependencies.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +""" +FastAPI dependency injection providers. +Replaces Handler class variables with proper DI. +""" + +import os +from datetime import datetime + +from fastapi import Request +from fastapi.templating import Jinja2Templates + +from config import Config +from tracker import AccessTracker +from database import DatabaseManager, get_database +from logger import get_app_logger, get_access_logger, get_credential_logger + +# Shared Jinja2 templates instance +_templates = None + + +def get_templates() -> Jinja2Templates: + """Get shared Jinja2Templates instance with custom filters.""" + global _templates + if _templates is None: + templates_dir = os.path.join( + os.path.dirname(__file__), "templates", "jinja2" + ) + _templates = Jinja2Templates(directory=templates_dir) + _templates.env.filters["format_ts"] = _format_ts + return _templates + + +def _format_ts(value, time_only=False): + """Custom Jinja2 filter for formatting ISO timestamps.""" + if not value: + return "N/A" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value) + except (ValueError, TypeError): + return value + if time_only: + return value.strftime("%H:%M:%S") + return value.strftime("%m/%d/%Y %H:%M:%S") + + +def get_tracker(request: Request) -> AccessTracker: + return request.app.state.tracker + + +def get_app_config(request: Request) -> Config: + return request.app.state.config + + +def get_db() -> DatabaseManager: + return get_database() + + +def get_client_ip(request: Request) -> str: + """Extract client IP address from request, checking proxy headers first.""" + forwarded_for = request.headers.get("X-Forwarded-For") + if forwarded_for: + return forwarded_for.split(",")[0].strip() + + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + + if request.client: + return request.client.host + + return "0.0.0.0" + + +def build_raw_request(request: Request, body: str = "") -> str: + """Build raw HTTP request string for forensic analysis.""" + try: + raw = f"{request.method} {request.url.path}" + if request.url.query: + raw += f"?{request.url.query}" + raw += f" HTTP/1.1\r\n" + + for header, value in request.headers.items(): + raw += f"{header}: {value}\r\n" + + raw += "\r\n" + + if body: + raw += body + + return raw + except Exception as e: + return f"{request.method} {request.url.path} (error building full request: {str(e)})" \ No newline at end of file diff --git a/src/middleware/__init__.py b/src/middleware/__init__.py new file mode 100644 index 0000000..f7bb692 --- /dev/null +++ b/src/middleware/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +""" +FastAPI middleware package for the Krawl honeypot. +""" \ No newline at end of file diff --git a/src/middleware/ban_check.py b/src/middleware/ban_check.py new file mode 100644 index 0000000..fc9ffa0 --- /dev/null +++ b/src/middleware/ban_check.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +""" +Middleware for checking if client IP is banned. +""" + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from dependencies import get_client_ip + + +class BanCheckMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Skip ban check for dashboard routes + config = request.app.state.config + dashboard_prefix = "/" + config.dashboard_secret_path.lstrip("/") + if request.url.path.startswith(dashboard_prefix): + return await call_next(request) + + client_ip = get_client_ip(request) + tracker = request.app.state.tracker + + if tracker.is_banned_ip(client_ip): + return Response(status_code=500) + + response = await call_next(request) + return response \ No newline at end of file diff --git a/src/middleware/deception.py b/src/middleware/deception.py new file mode 100644 index 0000000..aa1af13 --- /dev/null +++ b/src/middleware/deception.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 + +""" +Middleware for deception response detection (path traversal, XXE, command injection). +Short-circuits the request if a deception response is triggered. +""" + +import asyncio +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from deception_responses import detect_and_respond_deception +from dependencies import get_client_ip, build_raw_request +from logger import get_app_logger, get_access_logger + + +class DeceptionMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Skip deception detection for dashboard routes + config = request.app.state.config + dashboard_prefix = "/" + config.dashboard_secret_path.lstrip("/") + if path.startswith(dashboard_prefix): + return await call_next(request) + + query = request.url.query or "" + method = request.method + + # Read body for POST requests + body = "" + if method == "POST": + body_bytes = await request.body() + body = body_bytes.decode("utf-8", errors="replace") + + result = detect_and_respond_deception(path, query, body, method) + + if result: + response_body, content_type, status_code = result + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + app_logger = get_app_logger() + access_logger = get_access_logger() + + # Determine attack type for logging + full_input = f"{path} {query} {body}".lower() + attack_type_log = "UNKNOWN" + + if ( + "passwd" in path.lower() + or "shadow" in path.lower() + or ".." in path + or ".." in query + ): + attack_type_log = "PATH_TRAVERSAL" + elif body and (" dict: + return { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0", + "Access-Control-Allow-Origin": "*", + } + + +@router.get("/api/all-ip-stats") +async def all_ip_stats(request: Request): + db = get_db() + try: + ip_stats_list = db.get_ip_stats(limit=500) + return JSONResponse( + content={"ips": ip_stats_list}, + headers=_no_cache_headers(), + ) + except Exception as e: + get_app_logger().error(f"Error fetching all IP stats: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/attackers") +async def attackers( + request: Request, + page: int = Query(1), + page_size: int = Query(25), + 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), 100) + + try: + result = db.get_attackers_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching attackers: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/all-ips") +async def all_ips( + request: Request, + page: int = Query(1), + page_size: int = Query(25), + 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), 100) + + try: + result = db.get_all_ips_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching all IPs: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/ip-stats/{ip_address:path}") +async def ip_stats(ip_address: str, request: Request): + db = get_db() + try: + stats = db.get_ip_stats_by_ip(ip_address) + if stats: + return JSONResponse(content=stats, headers=_no_cache_headers()) + else: + return JSONResponse( + content={"error": "IP not found"}, headers=_no_cache_headers() + ) + except Exception as e: + get_app_logger().error(f"Error fetching IP stats: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/honeypot") +async def honeypot( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("count"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_honeypot_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching honeypot data: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/credentials") +async def credentials( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("timestamp"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_credentials_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching credentials: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/top-ips") +async def top_ips( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("count"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_top_ips_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching top IPs: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/top-paths") +async def top_paths( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("count"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_top_paths_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching top paths: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/top-user-agents") +async def top_user_agents( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("count"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_top_user_agents_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching top user agents: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/attack-types-stats") +async def attack_types_stats( + request: Request, + limit: int = Query(20), +): + db = get_db() + limit = min(max(1, limit), 100) + + try: + result = db.get_attack_types_stats(limit=limit) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching attack types stats: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/attack-types") +async def attack_types( + request: Request, + page: int = Query(1), + page_size: int = Query(5), + sort_by: str = Query("timestamp"), + sort_order: str = Query("desc"), +): + db = get_db() + page = max(1, page) + page_size = min(max(1, page_size), 100) + + try: + result = db.get_attack_types_paginated( + page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order + ) + return JSONResponse(content=result, headers=_no_cache_headers()) + except Exception as e: + get_app_logger().error(f"Error fetching attack types: {e}") + return JSONResponse(content={"error": str(e)}, headers=_no_cache_headers()) + + +@router.get("/api/raw-request/{log_id:int}") +async def raw_request(log_id: int, request: Request): + db = get_db() + try: + raw = db.get_raw_request_by_id(log_id) + if raw is None: + return JSONResponse( + content={"error": "Raw request not found"}, status_code=404 + ) + return JSONResponse( + content={"raw_request": raw}, headers=_no_cache_headers() + ) + except Exception as e: + get_app_logger().error(f"Error fetching raw request: {e}") + return JSONResponse(content={"error": str(e)}, status_code=500) + + +@router.get("/api/get_banlist") +async def get_banlist(request: Request, fwtype: str = Query("iptables")): + config = request.app.state.config + + filename = f"{fwtype}_banlist.txt" + if fwtype == "raw": + filename = "malicious_ips.txt" + + file_path = os.path.join(config.exports_path, filename) + + try: + if os.path.exists(file_path): + with open(file_path, "rb") as f: + content = f.read() + return Response( + content=content, + status_code=200, + media_type="text/plain", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "Content-Length": str(len(content)), + }, + ) + else: + return PlainTextResponse("File not found", status_code=404) + except Exception as e: + get_app_logger().error(f"Error serving malicious IPs file: {e}") + return PlainTextResponse("Internal server error", status_code=500) + + +@router.get("/api/download/malicious_ips.txt") +async def download_malicious_ips(request: Request): + config = request.app.state.config + file_path = os.path.join(config.exports_path, "malicious_ips.txt") + + try: + if os.path.exists(file_path): + with open(file_path, "rb") as f: + content = f.read() + return Response( + content=content, + status_code=200, + media_type="text/plain", + headers={ + "Content-Disposition": 'attachment; filename="malicious_ips.txt"', + "Content-Length": str(len(content)), + }, + ) + else: + return PlainTextResponse("File not found", status_code=404) + except Exception as e: + get_app_logger().error(f"Error serving malicious IPs file: {e}") + return PlainTextResponse("Internal server error", status_code=500) diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py new file mode 100644 index 0000000..a549f70 --- /dev/null +++ b/src/routes/dashboard.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +""" +Dashboard page route. +Renders the main dashboard page with server-side data for initial load. +""" + +from fastapi import APIRouter, Request + +from dependencies import get_db, get_templates + +router = APIRouter() + + +@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=20) + + # 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"] + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/index.html", + { + "request": request, + "dashboard_path": dashboard_path, + "stats": stats, + "suspicious_activities": suspicious, + }, + ) diff --git a/src/routes/honeypot.py b/src/routes/honeypot.py new file mode 100644 index 0000000..9d93be7 --- /dev/null +++ b/src/routes/honeypot.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 + +""" +Honeypot trap routes for the Krawl deception server. +Migrated from handler.py serve_special_path(), do_POST(), and do_GET() catch-all. +""" + +import asyncio +import random +import time +from datetime import datetime +from urllib.parse import urlparse, parse_qs, unquote_plus + +from fastapi import APIRouter, Request, Response, Depends +from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse + +from dependencies import ( + get_tracker, + get_app_config, + get_client_ip, + build_raw_request, +) +from config import Config +from tracker import AccessTracker +from templates import html_templates +from generators import ( + credentials_txt, + passwords_txt, + users_json, + api_keys_json, + api_response, + directory_listing, +) +from deception_responses import ( + generate_sql_error_response, + get_sql_response_with_data, + detect_xss_pattern, + generate_xss_response, + generate_server_error, +) +from wordlists import get_wordlists +from logger import get_app_logger, get_access_logger, get_credential_logger + +router = APIRouter() + + +# --- Helper functions --- + +def _should_return_error(config: Config) -> bool: + if config.probability_error_codes <= 0: + return False + return random.randint(1, 100) <= config.probability_error_codes + + +def _get_random_error_code() -> int: + wl = get_wordlists() + error_codes = wl.error_codes + if not error_codes: + error_codes = [400, 401, 403, 404, 500, 502, 503] + return random.choice(error_codes) + + +# --- HEAD --- + +@router.head("/{path:path}") +async def handle_head(path: str): + return Response(status_code=200, headers={"Content-Type": "text/html"}) + + +# --- POST routes --- + +@router.post("/api/search") +@router.post("/api/sql") +@router.post("/api/database") +async def sql_endpoint_post(request: Request): + client_ip = get_client_ip(request) + access_logger = get_access_logger() + + body_bytes = await request.body() + post_data = body_bytes.decode("utf-8", errors="replace") + + base_path = request.url.path + access_logger.info( + f"[SQL ENDPOINT POST] {client_ip} - {base_path} - Data: {post_data[:100] if post_data else 'empty'}" + ) + + error_msg, content_type, status_code = generate_sql_error_response(post_data) + + if error_msg: + access_logger.warning( + f"[SQL INJECTION DETECTED POST] {client_ip} - {base_path}" + ) + return Response(content=error_msg, status_code=status_code, media_type=content_type) + else: + response_data = get_sql_response_with_data(base_path, post_data) + return Response(content=response_data, status_code=200, media_type="application/json") + + +@router.post("/api/contact") +async def contact_post(request: Request): + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + tracker = request.app.state.tracker + access_logger = get_access_logger() + app_logger = get_app_logger() + + body_bytes = await request.body() + post_data = body_bytes.decode("utf-8", errors="replace") + + parsed_data = {} + if post_data: + parsed_qs = parse_qs(post_data) + parsed_data = {k: v[0] if v else "" for k, v in parsed_qs.items()} + + xss_detected = any(detect_xss_pattern(str(v)) for v in parsed_data.values()) + + if xss_detected: + access_logger.warning( + f"[XSS ATTEMPT DETECTED] {client_ip} - {request.url.path} - Data: {post_data[:200]}" + ) + else: + access_logger.info( + f"[XSS ENDPOINT POST] {client_ip} - {request.url.path}" + ) + + tracker.record_access( + ip=client_ip, + path=str(request.url.path), + user_agent=user_agent, + body=post_data, + method="POST", + raw_request=build_raw_request(request, post_data), + ) + + response_html = generate_xss_response(parsed_data) + return HTMLResponse(content=response_html, status_code=200) + + +@router.post("/{path:path}") +async def credential_capture_post(request: Request, path: str): + """Catch-all POST handler for credential capture.""" + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + tracker = request.app.state.tracker + access_logger = get_access_logger() + credential_logger = get_credential_logger() + + body_bytes = await request.body() + post_data = body_bytes.decode("utf-8", errors="replace") + + full_path = f"/{path}" + + access_logger.warning( + f"[LOGIN ATTEMPT] {client_ip} - {full_path} - {user_agent[:50]}" + ) + + if post_data: + access_logger.warning(f"[POST DATA] {post_data[:200]}") + + username, password = tracker.parse_credentials(post_data) + if username or password: + timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") + credential_line = f"{timestamp}|{client_ip}|{username or 'N/A'}|{password or 'N/A'}|{full_path}" + credential_logger.info(credential_line) + + tracker.record_credential_attempt( + client_ip, full_path, username or "N/A", password or "N/A" + ) + + access_logger.warning( + f"[CREDENTIALS CAPTURED] {client_ip} - Username: {username or 'N/A'} - Path: {full_path}" + ) + + tracker.record_access( + client_ip, + full_path, + user_agent, + post_data, + method="POST", + raw_request=build_raw_request(request, post_data), + ) + + await asyncio.sleep(1) + return HTMLResponse(content=html_templates.login_error(), status_code=200) + + +# --- GET special paths --- + +@router.get("/robots.txt") +async def robots_txt(): + return PlainTextResponse(html_templates.robots_txt()) + + +@router.get("/credentials.txt") +async def fake_credentials(): + return PlainTextResponse(credentials_txt()) + + +@router.get("/passwords.txt") +@router.get("/admin_notes.txt") +async def fake_passwords(): + return PlainTextResponse(passwords_txt()) + + +@router.get("/users.json") +async def fake_users_json(): + return JSONResponse(content=None, status_code=200, media_type="application/json") + + +@router.get("/api_keys.json") +async def fake_api_keys(): + return Response(content=api_keys_json(), status_code=200, media_type="application/json") + + +@router.get("/config.json") +async def fake_config_json(): + return Response(content=api_response("/api/config"), status_code=200, media_type="application/json") + + +# Override the generic /users.json to return actual content +@router.get("/users.json", include_in_schema=False) +async def fake_users_json_content(): + return Response(content=users_json(), status_code=200, media_type="application/json") + + +@router.get("/admin") +@router.get("/admin/") +@router.get("/admin/login") +@router.get("/login") +async def fake_login(): + return HTMLResponse(html_templates.login_form()) + + +@router.get("/users") +@router.get("/user") +@router.get("/database") +@router.get("/db") +@router.get("/search") +async def fake_product_search(): + return HTMLResponse(html_templates.product_search()) + + +@router.get("/info") +@router.get("/input") +@router.get("/contact") +@router.get("/feedback") +@router.get("/comment") +async def fake_input_form(): + return HTMLResponse(html_templates.input_form()) + + +@router.get("/server") +async def fake_server_error(): + error_html, content_type = generate_server_error() + return Response(content=error_html, status_code=500, media_type=content_type) + + +@router.get("/wp-login.php") +@router.get("/wp-login") +@router.get("/wp-admin") +@router.get("/wp-admin/") +async def fake_wp_login(): + return HTMLResponse(html_templates.wp_login()) + + +@router.get("/wp-content/{path:path}") +@router.get("/wp-includes/{path:path}") +async def fake_wordpress(path: str = ""): + return HTMLResponse(html_templates.wordpress()) + + +@router.get("/phpmyadmin") +@router.get("/phpmyadmin/{path:path}") +@router.get("/phpMyAdmin") +@router.get("/phpMyAdmin/{path:path}") +@router.get("/pma") +@router.get("/pma/") +async def fake_phpmyadmin(path: str = ""): + return HTMLResponse(html_templates.phpmyadmin()) + + +@router.get("/.env") +async def fake_env(): + return Response(content=api_response("/.env"), status_code=200, media_type="application/json") + + +@router.get("/backup/") +@router.get("/uploads/") +@router.get("/private/") +@router.get("/config/") +@router.get("/database/") +async def fake_directory_listing(request: Request): + return HTMLResponse(directory_listing(request.url.path)) + + +# --- SQL injection honeypot GET endpoints --- + +@router.get("/api/search") +@router.get("/api/sql") +@router.get("/api/database") +async def sql_endpoint_get(request: Request): + client_ip = get_client_ip(request) + access_logger = get_access_logger() + app_logger = get_app_logger() + + base_path = request.url.path + request_query = request.url.query or "" + + error_msg, content_type, status_code = generate_sql_error_response(request_query) + + if error_msg: + access_logger.warning( + f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" + ) + return Response(content=error_msg, status_code=status_code, media_type=content_type) + else: + access_logger.info( + f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" + ) + response_data = get_sql_response_with_data(base_path, request_query) + return Response(content=response_data, status_code=200, media_type="application/json") + + +# --- Generic /api/* fake endpoints --- + +@router.get("/api/{path:path}") +async def fake_api_catchall(request: Request, path: str): + full_path = f"/api/{path}" + return Response(content=api_response(full_path), status_code=200, media_type="application/json") + + +# --- Catch-all GET (trap pages with random links) --- +# This MUST be registered last in the router + +@router.get("/{path:path}") +async def trap_page(request: Request, path: str): + """Generate trap page with random links. This is the catch-all route.""" + config = request.app.state.config + tracker = request.app.state.tracker + app_logger = get_app_logger() + access_logger = get_access_logger() + + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "") + full_path = f"/{path}" if path else "/" + + # Check wordpress-like paths + if "wordpress" in full_path.lower(): + return HTMLResponse(html_templates.wordpress()) + + # Record access + tracker.record_access( + client_ip, + full_path, + user_agent, + method="GET", + raw_request=build_raw_request(request), + ) + + if tracker.is_suspicious_user_agent(user_agent): + access_logger.warning( + f"[SUSPICIOUS] {client_ip} - {user_agent[:50]} - {full_path}" + ) + + # Random error response + if _should_return_error(config): + error_code = _get_random_error_code() + access_logger.info( + f"Returning error {error_code} to {client_ip} - {full_path}" + ) + return Response(status_code=error_code) + + # Response delay + await asyncio.sleep(config.delay / 1000.0) + + # Increment page visit counter + current_visit_count = tracker.increment_page_visit(client_ip) + + # Generate page + page_html = _generate_page( + config, tracker, client_ip, full_path, current_visit_count, request.app + ) + + # Decrement canary counter + request.app.state.counter -= 1 + if request.app.state.counter < 0: + request.app.state.counter = config.canary_token_tries + + return HTMLResponse(content=page_html, status_code=200) + + +def _generate_page(config, tracker, client_ip, seed, page_visit_count, app) -> str: + """Generate a webpage containing random links or canary token.""" + random.seed(seed) + + ip_category = tracker.get_category_by_ip(client_ip) + + should_apply_crawler_limit = False + if config.infinite_pages_for_malicious: + if ( + ip_category == "good_crawler" or ip_category == "regular_user" + ) and page_visit_count >= config.max_pages_limit: + should_apply_crawler_limit = True + else: + if ( + ip_category == "good_crawler" + or ip_category == "bad_crawler" + or ip_category == "attacker" + ) and page_visit_count >= config.max_pages_limit: + should_apply_crawler_limit = True + + if should_apply_crawler_limit: + return html_templates.main_page( + app.state.counter, "

Crawl limit reached.

" + ) + + num_pages = random.randint(*config.links_per_page_range) + content = "" + + if app.state.counter <= 0 and config.canary_token_url: + content += f""" + +""" + + webpages = app.state.webpages + if webpages is None: + for _ in range(num_pages): + address = "".join( + [ + random.choice(config.char_space) + for _ in range(random.randint(*config.links_length_range)) + ] + ) + content += f""" + +""" + else: + for _ in range(num_pages): + address = random.choice(webpages) + content += f""" + +""" + + return html_templates.main_page(app.state.counter, content) \ No newline at end of file diff --git a/src/routes/htmx.py b/src/routes/htmx.py new file mode 100644 index 0000000..4013ce5 --- /dev/null +++ b/src/routes/htmx.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 + +""" +HTMX fragment endpoints. +Server-rendered HTML partials for table pagination, sorting, and IP details. +""" + +from fastapi import APIRouter, Request, Response, Query + +from dependencies import get_db, get_templates + +router = APIRouter() + + +def _dashboard_path(request: Request) -> str: + config = request.app.state.config + return "/" + config.dashboard_secret_path.lstrip("/") + + +# ── Honeypot Triggers ──────────────────────────────────────────────── + + +@router.get("/htmx/honeypot") +async def htmx_honeypot( + request: Request, + page: int = Query(1), + sort_by: str = Query("count"), + sort_order: str = Query("desc"), +): + db = get_db() + result = db.get_honeypot_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/honeypot_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["honeypots"], + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Top IPs ────────────────────────────────────────────────────────── + + +@router.get("/htmx/top-ips") +async def htmx_top_ips( + request: Request, + page: int = Query(1), + 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=5, sort_by=sort_by, sort_order=sort_order + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/top_ips_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["ips"], + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Top Paths ──────────────────────────────────────────────────────── + + +@router.get("/htmx/top-paths") +async def htmx_top_paths( + request: Request, + page: int = Query(1), + 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 + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/top_paths_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["paths"], + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Top User-Agents ───────────────────────────────────────────────── + + +@router.get("/htmx/top-ua") +async def htmx_top_ua( + request: Request, + page: int = Query(1), + 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 + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/top_ua_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["user_agents"], + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Attackers ──────────────────────────────────────────────────────── + + +@router.get("/htmx/attackers") +async def htmx_attackers( + request: Request, + page: int = Query(1), + sort_by: str = Query("total_requests"), + sort_order: str = Query("desc"), +): + db = get_db() + result = db.get_attackers_paginated( + page=max(1, page), page_size=25, sort_by=sort_by, sort_order=sort_order + ) + + # Normalize pagination key (DB returns total_attackers, template expects total) + pagination = result["pagination"] + if "total_attackers" in pagination and "total" not in pagination: + pagination["total"] = pagination["total_attackers"] + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/attackers_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["attackers"], + "pagination": pagination, + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Credentials ────────────────────────────────────────────────────── + + +@router.get("/htmx/credentials") +async def htmx_credentials( + request: Request, + page: int = Query(1), + sort_by: str = Query("timestamp"), + sort_order: str = Query("desc"), +): + db = get_db() + result = db.get_credentials_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/credentials_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["credentials"], + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Attack Types ───────────────────────────────────────────────────── + + +@router.get("/htmx/attacks") +async def htmx_attacks( + request: Request, + page: int = Query(1), + sort_by: str = Query("timestamp"), + sort_order: str = Query("desc"), +): + db = get_db() + result = db.get_attack_types_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) + + # Transform attack data for template (join attack_types list, map id to log_id) + items = [] + for attack in result["attacks"]: + items.append( + { + "ip": attack["ip"], + "path": attack["path"], + "attack_type": ", ".join(attack.get("attack_types", [])), + "user_agent": attack.get("user_agent", ""), + "timestamp": attack.get("timestamp"), + "log_id": attack.get("id"), + } + ) + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/attack_types_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": items, + "pagination": result["pagination"], + "sort_by": sort_by, + "sort_order": sort_order, + }, + ) + + +# ── Attack Patterns ────────────────────────────────────────────────── + + +@router.get("/htmx/patterns") +async def htmx_patterns( + request: Request, + page: int = Query(1), +): + db = get_db() + page = max(1, page) + page_size = 10 + + # Get all attack type stats and paginate manually + result = db.get_attack_types_stats(limit=100) + all_patterns = [ + {"pattern": item["type"], "count": item["count"]} + for item in result.get("attack_types", []) + ] + + total = len(all_patterns) + total_pages = max(1, (total + page_size - 1) // page_size) + offset = (page - 1) * page_size + items = all_patterns[offset : offset + page_size] + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/patterns_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": items, + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }, + }, + ) + + +# ── IP Detail ──────────────────────────────────────────────────────── + + +@router.get("/htmx/ip-detail/{ip_address:path}") +async def htmx_ip_detail(ip_address: str, request: Request): + db = get_db() + stats = db.get_ip_stats_by_ip(ip_address) + + if not stats: + stats = {"ip": ip_address, "total_requests": "N/A"} + + # Transform fields for template compatibility + list_on = stats.get("list_on") or {} + stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] + stats["reverse_dns"] = stats.get("reverse") + + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/ip_detail.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "stats": stats, + }, + ) diff --git a/src/templates/jinja2/base.html b/src/templates/jinja2/base.html new file mode 100644 index 0000000..f4f27bc --- /dev/null +++ b/src/templates/jinja2/base.html @@ -0,0 +1,25 @@ + + + + + + Krawl Dashboard + + + + + + + + + + + {% block content %}{% endblock %} + + + + + + {% block scripts %}{% endblock %} + + diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html new file mode 100644 index 0000000..5ec70f7 --- /dev/null +++ b/src/templates/jinja2/dashboard/index.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% block content %} +
+ + {# GitHub logo #} + + + {# Banlist export dropdown - Alpine.js #} +
+
+ + +
+
+ +

Krawl Dashboard

+ + {# Stats cards - server-rendered #} + {% include "dashboard/partials/stats_cards.html" %} + + {# Tab navigation - Alpine.js #} +
+ Overview + Attacks +
+ + {# ==================== OVERVIEW TAB ==================== #} +
+ + {# Suspicious Activity - server-rendered #} + {% include "dashboard/partials/suspicious_table.html" %} + + {# Honeypot Triggers - HTMX loaded #} +
+

Honeypot Triggers by IP

+
+
Loading...
+
+
+ + {# Top IPs + Top User-Agents side by side #} +
+
+

Top IP Addresses

+
+
Loading...
+
+
+
+

Top User-Agents

+
+
Loading...
+
+
+
+ + {# Top Paths #} +
+

Top Paths

+
+
Loading...
+
+
+
+ + {# ==================== ATTACKS TAB ==================== #} +
+ + {# Map section #} + {% include "dashboard/partials/map_section.html" %} + + {# Attackers table - HTMX loaded #} +
+

Attackers by Total Requests

+
+
Loading...
+
+
+ + {# Credentials table #} +
+

Captured Credentials

+
+
Loading...
+
+
+ + {# Attack Types table #} +
+

Detected Attack Types

+
+
Loading...
+
+
+ + {# Charts + Patterns side by side #} +
+
+

Most Recurring Attack Types

+
+ +
+
+
+

Most Recurring Attack Patterns

+
+
Loading...
+
+
+
+
+ + {# Raw request modal - Alpine.js #} + {% include "dashboard/partials/raw_request_modal.html" %} + +
+{% endblock %} diff --git a/src/templates/jinja2/dashboard/partials/attack_types_table.html b/src/templates/jinja2/dashboard/partials/attack_types_table.html new file mode 100644 index 0000000..8a74572 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/attack_types_table.html @@ -0,0 +1,80 @@ +{# HTMX fragment: Detected Attack Types table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + + + + + {% for attack in items %} + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressPathAttack TypesUser-Agent + Time + Actions
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ attack.ip | e }} + +
+ {{ attack.path | e }} + {% if attack.path | length > 30 %} +
{{ attack.path | e }}
+ {% endif %} +
+
+
+ {{ attack.attack_type | e }} + {% if attack.attack_type | length > 30 %} +
{{ attack.attack_type | e }}
+ {% endif %} +
+
{{ (attack.user_agent | default(''))[:50] | e }}{{ attack.timestamp | format_ts }} + {% if attack.log_id %} + + {% endif %} +
No attacks detected
diff --git a/src/templates/jinja2/dashboard/partials/attackers_table.html b/src/templates/jinja2/dashboard/partials/attackers_table.html new file mode 100644 index 0000000..632137d --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/attackers_table.html @@ -0,0 +1,60 @@ +{# HTMX fragment: Attackers table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} attackers +
+ + +
+
+ + + + + + + + + + + + + {% for ip in items %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP Address + Total Requests + First SeenLast SeenLocation
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ ip.ip | e }} + {{ ip.total_requests }}{{ ip.first_seen | format_ts }}{{ ip.last_seen | format_ts }}{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}
No attackers found
diff --git a/src/templates/jinja2/dashboard/partials/credentials_table.html b/src/templates/jinja2/dashboard/partials/credentials_table.html new file mode 100644 index 0000000..ccfb364 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/credentials_table.html @@ -0,0 +1,60 @@ +{# HTMX fragment: Captured Credentials table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + + + + {% for cred in items %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressUsernamePasswordPath + Time +
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ cred.ip | e }} + {{ cred.username | default('N/A') | e }}{{ cred.password | default('N/A') | e }}{{ cred.path | default('') | e }}{{ cred.timestamp | format_ts }}
No credentials captured
diff --git a/src/templates/jinja2/dashboard/partials/honeypot_table.html b/src/templates/jinja2/dashboard/partials/honeypot_table.html new file mode 100644 index 0000000..35676fc --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/honeypot_table.html @@ -0,0 +1,54 @@ +{# HTMX fragment: Honeypot triggers table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + {% for item in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
#IP Address + Honeypot Triggers +
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ item.ip | e }} + {{ item.count }}
No data
diff --git a/src/templates/jinja2/dashboard/partials/ip_detail.html b/src/templates/jinja2/dashboard/partials/ip_detail.html new file mode 100644 index 0000000..9b6565c --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/ip_detail.html @@ -0,0 +1,138 @@ +{# HTMX fragment: IP detail expansion row content #} +{# Replaces the ~250 line formatIpStats() JavaScript function #} +
+
+ Total Requests: + {{ stats.total_requests | default('N/A') }} +
+
+ First Seen: + {{ stats.first_seen | format_ts }} +
+
+ Last Seen: + {{ stats.last_seen | format_ts }} +
+ {% if stats.city or stats.country_code %} +
+ Location: + {{ stats.city | default('') }}{% if stats.city and stats.country_code %}, {% endif %}{{ stats.country_code | default('') }} +
+ {% endif %} + {% if stats.reverse_dns %} +
+ Reverse DNS: + {{ stats.reverse_dns | e }} +
+ {% endif %} + {% if stats.asn_org %} +
+ ASN Org: + {{ stats.asn_org | e }} +
+ {% endif %} + {% if stats.asn %} +
+ ASN: + {{ stats.asn | e }} +
+ {% endif %} + {% if stats.isp %} +
+ ISP: + {{ stats.isp | e }} +
+ {% endif %} + + {# Flags #} + {% set flags = [] %} + {% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %} + {% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %} + {% if flags %} +
+ Flags: + {{ flags | join(', ') }} +
+ {% endif %} + + {% if stats.reputation_score is not none %} +
+ Reputation Score: + + {{ stats.reputation_score }}/100 + +
+ {% endif %} + + {% if stats.category %} +
+ Category: + + {{ stats.category | replace('_', ' ') | title }} + +
+ {% endif %} + + {# Timeline + Reputation section #} + {% if stats.category_history or stats.blocklist_memberships %} +
+
+ {# Behavior Timeline #} + {% if stats.category_history %} +
+
Behavior Timeline
+
+ {% for entry in stats.category_history %} +
+
+
+ {{ entry.new_category | default('unknown') | replace('_', ' ') | title }} + {% if entry.old_category %} from {{ entry.old_category | replace('_', ' ') | title }}{% endif %} +
{{ entry.timestamp | format_ts }} +
+
+ {% endfor %} +
+
+ {% endif %} + + {# Reputation / Listed On #} +
+
Reputation
+ {% if stats.blocklist_memberships %} +
Listed On
+ {% for bl in stats.blocklist_memberships %} + {{ bl | e }} + {% endfor %} + {% else %} + Clean - Not listed on any blocklists + {% endif %} +
+
+
+ {% endif %} +
+ +{# Radar chart (right side) #} +{% if stats.category_scores %} +
+
+ +
+
+
Attacker
+
Good Bot
+
Bad Bot
+
Regular
+
Unknown
+
+
+{% endif %} diff --git a/src/templates/jinja2/dashboard/partials/map_section.html b/src/templates/jinja2/dashboard/partials/map_section.html new file mode 100644 index 0000000..1191671 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/map_section.html @@ -0,0 +1,27 @@ +{# Map section with filter checkboxes #} +
+

IP Origins Map

+
+ + + + + +
+
+
diff --git a/src/templates/jinja2/dashboard/partials/patterns_table.html b/src/templates/jinja2/dashboard/partials/patterns_table.html new file mode 100644 index 0000000..260f31d --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/patterns_table.html @@ -0,0 +1,43 @@ +{# HTMX fragment: Attack Patterns table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} patterns +
+ + +
+
+ + + + + + + + + + {% for pattern in items %} + + + + + + {% else %} + + {% endfor %} + +
#Attack PatternOccurrences
{{ loop.index + (pagination.page - 1) * pagination.page_size }} +
+ {{ pattern.pattern | e }} + {% if pattern.pattern | length > 40 %} +
{{ pattern.pattern | e }}
+ {% endif %} +
+
{{ pattern.count }}
No patterns found
diff --git a/src/templates/jinja2/dashboard/partials/raw_request_modal.html b/src/templates/jinja2/dashboard/partials/raw_request_modal.html new file mode 100644 index 0000000..7f9ecc7 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/raw_request_modal.html @@ -0,0 +1,20 @@ +{# Raw request viewer modal - Alpine.js controlled #} +
+
+
+

Raw HTTP Request

+ × +
+
+

+        
+ +
+
diff --git a/src/templates/jinja2/dashboard/partials/stats_cards.html b/src/templates/jinja2/dashboard/partials/stats_cards.html new file mode 100644 index 0000000..260076c --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/stats_cards.html @@ -0,0 +1,31 @@ +{# Stats cards - server-rendered on initial page load #} +
+
+
{{ stats.total_accesses }}
+
Total Accesses
+
+
+
{{ stats.unique_ips }}
+
Unique IPs
+
+
+
{{ stats.unique_paths }}
+
Unique Paths
+
+
+
{{ stats.suspicious_accesses }}
+
Suspicious Accesses
+
+
+
{{ stats.honeypot_ips | default(0) }}
+
Honeypot Caught
+
+
+
{{ stats.credential_count | default(0) }}
+
Credentials Captured
+
+
+
{{ stats.unique_attackers | default(0) }}
+
Unique Attackers
+
+
diff --git a/src/templates/jinja2/dashboard/partials/suspicious_table.html b/src/templates/jinja2/dashboard/partials/suspicious_table.html new file mode 100644 index 0000000..72a0480 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/suspicious_table.html @@ -0,0 +1,39 @@ +{# Recent Suspicious Activity - server-rendered on page load #} +
+

Recent Suspicious Activity

+ + + + + + + + + + + {% for activity in suspicious_activities %} + + + + + + + + + + {% else %} + + {% endfor %} + +
IP AddressPathUser-AgentTime
+ {{ activity.ip | e }} + {{ activity.path | e }}{{ (activity.user_agent | default(''))[:80] | e }}{{ activity.timestamp | format_ts(time_only=True) }}
No suspicious activity detected
+
diff --git a/src/templates/jinja2/dashboard/partials/top_ips_table.html b/src/templates/jinja2/dashboard/partials/top_ips_table.html new file mode 100644 index 0000000..84b335f --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/top_ips_table.html @@ -0,0 +1,54 @@ +{# HTMX fragment: Top IPs table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + {% for item in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
#IP Address + Access Count +
{{ loop.index + (pagination.page - 1) * pagination.page_size }} + {{ item.ip | e }} + {{ item.count }}
No data
diff --git a/src/templates/jinja2/dashboard/partials/top_paths_table.html b/src/templates/jinja2/dashboard/partials/top_paths_table.html new file mode 100644 index 0000000..d1ec6d1 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/top_paths_table.html @@ -0,0 +1,41 @@ +{# HTMX fragment: Top Paths table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + {% for item in items %} + + + + + + {% else %} + + {% endfor %} + +
#Path + Access Count +
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ item.path | e }}{{ item.count }}
No data
diff --git a/src/templates/jinja2/dashboard/partials/top_ua_table.html b/src/templates/jinja2/dashboard/partials/top_ua_table.html new file mode 100644 index 0000000..faf487e --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/top_ua_table.html @@ -0,0 +1,41 @@ +{# HTMX fragment: Top User-Agents table #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} total +
+ + +
+
+ + + + + + + + + + {% for item in items %} + + + + + + {% else %} + + {% endfor %} + +
#User-Agent + Count +
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ item.user_agent | e }}{{ item.count }}
No data
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css new file mode 100644 index 0000000..655a038 --- /dev/null +++ b/src/templates/static/css/dashboard.css @@ -0,0 +1,1251 @@ +/* Krawl Dashboard Styles */ +/* Extracted from dashboard_template.py */ + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #0d1117; + color: #c9d1d9; + margin: 0; + padding: 20px; +} +.container { + max-width: 1400px; + margin: 0 auto; + position: relative; +} +.github-logo { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #58a6ff; + transition: color 0.2s; +} +.github-logo:hover { + color: #79c0ff; +} +.github-logo svg { + width: 32px; + height: 32px; + fill: currentColor; +} +.github-logo-text { + font-size: 14px; + font-weight: 600; + text-decoration: none; +} +h1 { + color: #58a6ff; + text-align: center; + margin-bottom: 40px; +} +.download-section { + position: absolute; + top: 0; + right: 0; +} +.download-btn { + display: inline-block; + padding: 8px 14px; + background: #238636; + color: #ffffff; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 13px; + transition: background 0.2s; + border: 1px solid #2ea043; +} +.download-btn:hover { + background: #2ea043; +} +.download-btn:active { + background: #1f7a2f; +} +.banlist-dropdown { + position: relative; + display: inline-block; + width: 100%; +} +.banlist-dropdown-btn { + display: block; + width: 100%; + padding: 8px 14px; + background: #238636; + color: #ffffff; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 13px; + transition: background 0.2s; + border: 1px solid #2ea043; + cursor: pointer; + text-align: left; + box-sizing: border-box; +} +.banlist-dropdown-btn:hover { + background: #2ea043; +} +.banlist-dropdown-menu { + display: none; + position: absolute; + right: 0; + left: 0; + background-color: #161b22; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.3); + z-index: 1; + border: 1px solid #30363d; + border-radius: 6px; + margin-top: 4px; + overflow: hidden; +} +.banlist-dropdown-menu.show { + display: block; +} +.banlist-dropdown-menu a { + color: #c9d1d9; + padding: 6px 12px; + text-decoration: none; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; + font-size: 12px; +} +.banlist-dropdown-menu a:hover { + background-color: #1c2128; + color: #58a6ff; +} +.banlist-dropdown-menu a.disabled { + color: #6e7681; + cursor: not-allowed; + pointer-events: none; +} +.banlist-icon { + font-size: 14px; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 20px; + margin-bottom: 40px; +} +.stat-card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 20px; + text-align: center; +} +.stat-card.alert { + border-color: #f85149; +} +.stat-value { + font-size: 36px; + font-weight: bold; + color: #58a6ff; +} +.stat-value.alert { + color: #f85149; +} +.stat-label { + font-size: 14px; + color: #8b949e; + margin-top: 5px; +} +.table-container { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 12px; + margin-bottom: 20px; +} +h2 { + color: #58a6ff; + margin-top: 0; +} +table { + width: 100%; + border-collapse: collapse; +} +th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #30363d; +} +th { + background: #0d1117; + color: #58a6ff; + font-weight: 600; +} +tr:hover { + background: #1c2128; +} +.rank { + color: #8b949e; + font-weight: bold; +} +.alert-section { + background: #1c1917; + border-left: 4px solid #f85149; +} +th.sortable { + cursor: pointer; + user-select: none; + position: relative; + padding-right: 24px; +} +th.sortable:hover { + background: #1c2128; +} +th.sortable::after { + content: '\21C5'; + position: absolute; + right: 8px; + opacity: 0.5; + font-size: 12px; +} +th.sortable.asc::after { + content: '\25B2'; + opacity: 1; +} +th.sortable.desc::after { + content: '\25BC'; + opacity: 1; +} +tbody { + transition: opacity 0.1s ease; +} +tbody { + animation: fadeIn 0.3s ease-in; +} +.ip-row { + transition: background-color 0.2s; +} +.ip-clickable { + cursor: pointer; + color: #58a6ff !important; + font-weight: 500; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; +} +.ip-clickable:hover { + color: #79c0ff !important; + text-decoration-style: solid; + background: #1c2128; +} +.ip-stats-row { + background: #0d1117; +} +.ip-stats-cell { + padding: 0 !important; +} +.ip-stats-dropdown { + margin-top: 10px; + padding: 15px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + font-size: 13px; + display: flex; + gap: 20px; +} +.stats-left { + flex: 1; +} +.stats-right { + flex: 0 0 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +.radar-chart { + position: relative; + width: 220px; + height: 220px; + overflow: visible; +} +.radar-legend { + margin-top: 10px; + font-size: 11px; +} +.radar-legend-item { + display: flex; + align-items: center; + gap: 6px; + margin: 3px 0; +} +.radar-legend-color { + width: 12px; + height: 12px; + border-radius: 2px; +} +.ip-stats-dropdown .loading { + color: #8b949e; + font-style: italic; +} +.stat-row { + display: flex; + justify-content: space-between; + padding: 5px 0; + border-bottom: 1px solid #21262d; +} +.stat-row:last-child { + border-bottom: none; +} +.stat-label-sm { + color: #8b949e; + font-weight: 500; +} +.stat-value-sm { + color: #58a6ff; + font-weight: 600; +} +.category-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} +.category-attacker { + background: #f851491a; + color: #f85149; + border: 1px solid #f85149; +} +.category-good-crawler { + background: #3fb9501a; + color: #3fb950; + border: 1px solid #3fb950; +} +.category-bad-crawler { + background: #f0883e1a; + color: #f0883e; + border: 1px solid #f0883e; +} +.category-regular-user { + background: #58a6ff1a; + color: #58a6ff; + border: 1px solid #58a6ff; +} +.category-unknown { + background: #8b949e1a; + color: #8b949e; + border: 1px solid #8b949e; +} +.timeline-section { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #30363d; +} +.timeline-container { + display: flex; + gap: 20px; + min-height: 200px; +} +.timeline-column { + flex: 1; + min-width: 0; + overflow: auto; + max-height: 350px; +} +.timeline-column:first-child { + flex: 1.5; +} +.timeline-column:last-child { + flex: 1; +} +.timeline-header { + color: #58a6ff; + font-size: 13px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #30363d; +} +.reputation-title { + color: #8b949e; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 8px; +} +.reputation-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 8px; + background: #161b22; + border: 1px solid #f851494d; + border-radius: 4px; + font-size: 11px; + color: #f85149; + text-decoration: none; + transition: all 0.2s; + margin-bottom: 6px; + margin-right: 6px; + white-space: nowrap; +} +.reputation-badge:hover { + background: #1c2128; + border-color: #f85149; +} +.reputation-clean { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 8px; + background: #161b22; + border: 1px solid #3fb9504d; + border-radius: 4px; + font-size: 11px; + color: #3fb950; + margin-bottom: 6px; +} +.timeline { + position: relative; + padding-left: 28px; +} +.timeline::before { + content: ''; + position: absolute; + left: 11px; + top: 0; + bottom: 0; + width: 2px; + background: #30363d; +} +.timeline-item { + position: relative; + padding-bottom: 12px; + font-size: 12px; +} +.timeline-item:last-child { + padding-bottom: 0; +} +.timeline-marker { + position: absolute; + left: -23px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid #0d1117; +} +.timeline-marker.attacker { background: #f85149; } +.timeline-marker.good-crawler { background: #3fb950; } +.timeline-marker.bad-crawler { background: #f0883e; } +.timeline-marker.regular-user { background: #58a6ff; } +.timeline-marker.unknown { background: #8b949e; } +.tabs-container { + border-bottom: 1px solid #30363d; + margin-bottom: 30px; + display: flex; + gap: 2px; + background: #161b22; + border-radius: 6px 6px 0 0; + overflow-x: auto; + overflow-y: hidden; +} +.tab-button { + padding: 12px 20px; + background: transparent; + border: none; + color: #8b949e; + font-size: 14px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; + border-bottom: 3px solid transparent; + position: relative; + bottom: -1px; +} +.tab-button:hover { + color: #c9d1d9; + background: #1c2128; +} +.tab-button.active { + color: #58a6ff; + border-bottom-color: #58a6ff; +} +.tab-content { + display: none; +} +.tab-content.active { + display: block; +} +.ip-stats-table { + width: 100%; + border-collapse: collapse; +} +.ip-stats-table th, .ip-stats-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #30363d; +} +.ip-stats-table th { + background: #0d1117; + color: #58a6ff; + font-weight: 600; +} +.ip-stats-table tr:hover { + background: #1c2128; +} +.ip-detail-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 1000; + align-items: center; + justify-content: center; +} +.ip-detail-modal.show { + display: flex; +} +.ip-detail-content { + background: #161b22; + border: 1px solid #30363d; + border-radius: 8px; + padding: 30px; + max-width: 900px; + max-height: 90vh; + overflow-y: auto; + position: relative; +} +.ip-detail-close { + position: absolute; + top: 15px; + right: 15px; + background: none; + border: none; + color: #8b949e; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} +.ip-detail-close:hover { + color: #c9d1d9; +} +#attacker-map { + background: #0d1117 !important; +} +.leaflet-container { + background: #0d1117 !important; +} +.leaflet-tile { + filter: none; +} +.leaflet-popup-content-wrapper { + background-color: #0d1117; + color: #c9d1d9; + border: 1px solid #30363d; + border-radius: 6px; + padding: 0; +} +.leaflet-popup-content { + margin: 0; + min-width: 280px; +} +.leaflet-popup-content-wrapper a { + color: #58a6ff; +} +.leaflet-popup-tip { + background: #0d1117; + border: 1px solid #30363d; +} +.ip-detail-popup .leaflet-popup-content-wrapper { + max-width: 340px !important; +} +/* Remove the default leaflet icon background */ +.ip-custom-marker { + background: none !important; + border: none !important; +} +.ip-marker { + border: 2px solid #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: bold; + color: white; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} +.ip-marker:hover { + transform: scale(1.15); +} +.marker-attacker { + background: #f85149; + box-shadow: 0 0 8px rgba(248, 81, 73, 0.8), inset 0 0 4px rgba(248, 81, 73, 0.5); +} +.marker-attacker:hover { + box-shadow: 0 0 15px rgba(248, 81, 73, 1), inset 0 0 6px rgba(248, 81, 73, 0.7); +} +.marker-bad_crawler { + background: #f0883e; + box-shadow: 0 0 8px rgba(240, 136, 62, 0.8), inset 0 0 4px rgba(240, 136, 62, 0.5); +} +.marker-bad_crawler:hover { + box-shadow: 0 0 15px rgba(240, 136, 62, 1), inset 0 0 6px rgba(240, 136, 62, 0.7); +} +.marker-good_crawler { + background: #3fb950; + box-shadow: 0 0 8px rgba(63, 185, 80, 0.8), inset 0 0 4px rgba(63, 185, 80, 0.5); +} +.marker-good_crawler:hover { + box-shadow: 0 0 15px rgba(63, 185, 80, 1), inset 0 0 6px rgba(63, 185, 80, 0.7); +} +.marker-regular_user { + background: #58a6ff; + box-shadow: 0 0 8px rgba(88, 166, 255, 0.8), inset 0 0 4px rgba(88, 166, 255, 0.5); +} +.marker-regular_user:hover { + box-shadow: 0 0 15px rgba(88, 166, 255, 1), inset 0 0 6px rgba(88, 166, 255, 0.7); +} +.marker-unknown { + background: #8b949e; + box-shadow: 0 0 8px rgba(139, 148, 158, 0.8), inset 0 0 4px rgba(139, 148, 158, 0.5); +} +.marker-unknown:hover { + box-shadow: 0 0 15px rgba(139, 148, 158, 1), inset 0 0 6px rgba(139, 148, 158, 0.7); +} +.leaflet-bottom.leaflet-right { + display: none !important; +} +.charts-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 20px; +} +.chart-section { + display: flex; + flex-direction: column; +} +.chart-wrapper { + display: flex; + flex-direction: column; +} +#attack-types-chart { + max-height: 350px; +} +#attack-patterns-chart { + max-height: 350px; +} +@media (max-width: 1200px) { + .charts-container { + grid-template-columns: 1fr; + } +} + +/* Raw Request Modal */ +.raw-request-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + overflow: auto; +} +.raw-request-modal-content { + background-color: #161b22; + margin: 5% auto; + padding: 0; + border: 1px solid #30363d; + border-radius: 6px; + width: 80%; + max-width: 900px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.raw-request-modal-header { + padding: 16px 20px; + background-color: #21262d; + border-bottom: 1px solid #30363d; + border-radius: 6px 6px 0 0; + display: flex; + justify-content: space-between; + align-items: center; +} +.raw-request-modal-header h3 { + margin: 0; + color: #58a6ff; + font-size: 16px; +} +.raw-request-modal-close { + color: #8b949e; + font-size: 28px; + font-weight: bold; + cursor: pointer; + line-height: 20px; + transition: color 0.2s; +} +.raw-request-modal-close:hover { + color: #c9d1d9; +} +.raw-request-modal-body { + padding: 20px; +} +.raw-request-content { + background-color: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + padding: 16px; + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + color: #c9d1d9; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 400px; + overflow-y: auto; +} +.raw-request-modal-footer { + padding: 16px 20px; + background-color: #21262d; + border-top: 1px solid #30363d; + border-radius: 0 0 6px 6px; + text-align: right; +} +.raw-request-download-btn { + padding: 8px 16px; + background: #238636; + color: #ffffff; + border: none; + border-radius: 6px; + font-weight: 500; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} +.raw-request-download-btn:hover { + background: #2ea043; +} + +/* Attack Types Cell Styling */ +.attack-types-cell { + max-width: 280px; + position: relative; + display: inline-block; + width: 100%; + overflow: visible; +} +.attack-types-truncated { + display: block; + width: 100%; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #fb8500; + font-weight: 500; + transition: all 0.2s; + position: relative; +} +.attack-types-tooltip { + position: absolute; + bottom: 100%; + left: 0; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; + max-width: 400px; + word-wrap: break-word; + white-space: normal; + z-index: 1000; + color: #c9d1d9; + font-size: 12px; + font-weight: normal; + display: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + pointer-events: auto; +} +.attack-types-cell:hover .attack-types-tooltip { + display: block; +} +.attack-types-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 12px; + border: 6px solid transparent; + border-top-color: #30363d; +} +.attack-types-tooltip::before { + content: ''; + position: absolute; + top: 100%; + left: 13px; + border: 5px solid transparent; + border-top-color: #0d1117; + z-index: 1; +} + +/* Path Cell Styling for Attack Table */ +.path-cell-container { + position: relative; + display: inline-block; + max-width: 100%; +} +.path-truncated { + display: block; + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + color: #f85149 !important; + font-weight: 500; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; + transition: all 0.2s; +} +.path-truncated:hover { + color: #ff7369 !important; + text-decoration-style: solid; +} +.path-cell-container:hover .path-tooltip { + display: block; +} +.path-tooltip { + position: absolute; + bottom: 100%; + left: 0; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 8px; + max-width: 500px; + word-wrap: break-word; + white-space: normal; + z-index: 1000; + color: #c9d1d9; + font-size: 12px; + font-weight: normal; + display: none; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + font-family: 'Courier New', monospace; +} +.path-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 12px; + border: 6px solid transparent; + border-top-color: #30363d; +} +.path-tooltip::before { + content: ''; + position: absolute; + top: 100%; + left: 13px; + border: 5px solid transparent; + border-top-color: #0d1117; + z-index: 1; +} + +/* Mobile Optimization - Tablets (768px and down) */ +@media (max-width: 768px) { + body { + padding: 12px; + } + .container { + max-width: 100%; + } + h1 { + font-size: 24px; + margin-bottom: 20px; + } + .github-logo { + position: relative; + top: auto; + left: auto; + margin-bottom: 15px; + } + .download-section { + position: relative; + top: auto; + right: auto; + margin-bottom: 20px; + } + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-bottom: 20px; + } + .stat-value { + font-size: 28px; + } + .stat-card { + padding: 15px; + } + .table-container { + padding: 12px; + margin-bottom: 15px; + overflow-x: auto; + } + table { + font-size: 13px; + } + th, td { + padding: 10px 6px; + } + h2 { + font-size: 18px; + } + .tabs-container { + gap: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .tab-button { + padding: 10px 16px; + font-size: 12px; + } + .ip-stats-dropdown { + flex-direction: column; + gap: 15px; + } + .stats-right { + flex: 0 0 auto; + width: 100%; + } + .radar-chart { + width: 160px; + height: 160px; + } + .timeline-container { + flex-direction: column; + gap: 15px; + min-height: auto; + } + .timeline-column { + flex: 1 !important; + max-height: 300px; + } + #attacker-map { + height: 350px !important; + } + .leaflet-popup-content { + min-width: 200px !important; + } + .ip-marker { + font-size: 8px; + } + .ip-detail-content { + padding: 20px; + max-width: 95%; + max-height: 85vh; + } + .download-btn { + padding: 6px 12px; + font-size: 12px; + } +} + +/* Mobile Optimization - Small phones (480px and down) */ +@media (max-width: 480px) { + body { + padding: 8px; + } + h1 { + font-size: 20px; + margin-bottom: 15px; + } + .stats-grid { + grid-template-columns: 1fr; + gap: 10px; + margin-bottom: 15px; + } + .stat-value { + font-size: 24px; + } + .stat-card { + padding: 12px; + } + .stat-label { + font-size: 12px; + } + .table-container { + padding: 10px; + margin-bottom: 12px; + border-radius: 4px; + } + table { + font-size: 12px; + } + th, td { + padding: 8px 4px; + } + th { + position: relative; + } + th.sortable::after { + right: 4px; + font-size: 10px; + } + h2 { + font-size: 16px; + margin-bottom: 12px; + } + .tabs-container { + gap: 0; + } + .tab-button { + padding: 10px 12px; + font-size: 11px; + flex: 1; + } + .ip-row { + display: block; + margin-bottom: 10px; + background: #1c2128; + padding: 10px; + border-radius: 4px; + } + .ip-row td { + display: block; + padding: 4px 0; + border: none; + } + .ip-row td::before { + content: attr(data-label); + font-weight: bold; + color: #8b949e; + margin-right: 8px; + } + .ip-clickable { + display: inline-block; + } + .ip-stats-dropdown { + flex-direction: column; + gap: 12px; + font-size: 12px; + } + .stats-left { + flex: 1; + } + .stats-right { + flex: 0 0 auto; + width: 100%; + } + .radar-chart { + width: 140px; + height: 140px; + } + .radar-legend { + margin-top: 8px; + font-size: 10px; + } + .stat-row { + padding: 4px 0; + } + .stat-label-sm { + font-size: 12px; + } + .stat-value-sm { + font-size: 13px; + } + .category-badge { + padding: 3px 6px; + font-size: 10px; + } + .timeline-container { + flex-direction: column; + gap: 12px; + min-height: auto; + } + .timeline-column { + flex: 1 !important; + max-height: 250px; + font-size: 11px; + } + .timeline-header { + font-size: 12px; + margin-bottom: 8px; + } + .timeline-item { + padding-bottom: 10px; + font-size: 11px; + } + .timeline-marker { + left: -19px; + width: 12px; + height: 12px; + } + .reputation-badge { + display: block; + margin-bottom: 6px; + margin-right: 0; + font-size: 10px; + } + #attacker-map { + height: 300px !important; + } + .leaflet-popup-content { + min-width: 150px !important; + } + .ip-marker { + font-size: 7px; + } + .ip-detail-modal { + justify-content: flex-end; + align-items: flex-end; + } + .ip-detail-content { + padding: 15px; + max-width: 100%; + max-height: 90vh; + border-radius: 8px 8px 0 0; + width: 100%; + } + .download-btn { + padding: 6px 10px; + font-size: 11px; + } + .github-logo { + font-size: 12px; + } + .github-logo svg { + width: 24px; + height: 24px; + } +} + +/* Landscape mode optimization */ +@media (max-height: 600px) and (orientation: landscape) { + body { + padding: 8px; + } + h1 { + margin-bottom: 10px; + font-size: 18px; + } + .stats-grid { + margin-bottom: 10px; + gap: 8px; + } + .stat-value { + font-size: 20px; + } + .stat-card { + padding: 8px; + } + #attacker-map { + height: 250px !important; + } + .ip-stats-dropdown { + gap: 10px; + } + .radar-chart { + width: 120px; + height: 120px; + } +} + +/* Touch-friendly optimizations */ +@media (hover: none) and (pointer: coarse) { + .ip-clickable { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: rgba(88, 166, 255, 0.2); + } + .tab-button { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: rgba(88, 166, 255, 0.2); + padding: 14px 18px; + } + .download-btn { + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: rgba(36, 134, 54, 0.3); + } + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + } +} + +/* Dynamically injected button styles (previously in JS) */ +.view-btn { + padding: 4px 10px; + background: #21262d; + color: #58a6ff; + border: 1px solid #30363d; + border-radius: 4px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.view-btn:hover { + background: #30363d; + border-color: #58a6ff; +} +.pagination-btn { + padding: 6px 14px; + background: #21262d; + color: #c9d1d9; + border: 1px solid #30363d; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} +.pagination-btn:hover:not(:disabled) { + background: #30363d; + border-color: #58a6ff; + color: #58a6ff; +} +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.pagination-info { + color: #8b949e; + font-size: 12px; +} + +/* HTMX loading indicator */ +.htmx-indicator { + display: none; + color: #8b949e; + font-style: italic; + padding: 20px; + text-align: center; +} +.htmx-request .htmx-indicator { + display: block; +} +.htmx-request.htmx-indicator { + display: block; +} + +/* Alpine.js cloak */ +[x-cloak] { + display: none !important; +} diff --git a/src/templates/static/js/charts.js b/src/templates/static/js/charts.js new file mode 100644 index 0000000..93122bb --- /dev/null +++ b/src/templates/static/js/charts.js @@ -0,0 +1,167 @@ +// Chart.js Attack Types Chart +// Extracted from dashboard_template.py (lines ~3370-3550) + +let attackTypesChart = null; +let attackTypesChartLoaded = false; + +async function loadAttackTypesChart() { + const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || ''; + + try { + const canvas = document.getElementById('attack-types-chart'); + if (!canvas) return; + + const response = await fetch(DASHBOARD_PATH + '/api/attack-types-stats?limit=10', { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response.ok) throw new Error('Failed to fetch attack types'); + + const data = await response.json(); + const attackTypes = data.attack_types || []; + + if (attackTypes.length === 0) { + canvas.style.display = 'none'; + return; + } + + const labels = attackTypes.map(item => item.type); + const counts = attackTypes.map(item => item.count); + const maxCount = Math.max(...counts); + + // Hash function to generate consistent color from string + function hashCode(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } + + // Dynamic color generator based on hash + function generateColorFromHash(label) { + const hash = hashCode(label); + const hue = (hash % 360); // 0-360 for hue + const saturation = 70 + (hash % 20); // 70-90 for vibrant colors + const lightness = 50 + (hash % 10); // 50-60 for brightness + + const bgColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; + const borderColor = `hsl(${hue}, ${saturation + 5}%, ${lightness - 10}%)`; // Darker border + const hoverColor = `hsl(${hue}, ${saturation - 10}%, ${lightness + 8}%)`; // Lighter hover + + return { bg: bgColor, border: borderColor, hover: hoverColor }; + } + + // Generate colors dynamically for each attack type + const backgroundColors = labels.map(label => generateColorFromHash(label).bg); + const borderColors = labels.map(label => generateColorFromHash(label).border); + const hoverColors = labels.map(label => generateColorFromHash(label).hover); + + // Create or update chart + if (attackTypesChart) { + attackTypesChart.destroy(); + } + + const ctx = canvas.getContext('2d'); + attackTypesChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: labels, + datasets: [{ + data: counts, + backgroundColor: backgroundColors, + borderColor: '#0d1117', + borderWidth: 3, + hoverBorderColor: '#58a6ff', + hoverBorderWidth: 4, + hoverOffset: 10 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels: { + color: '#c9d1d9', + font: { + size: 12, + weight: '500', + family: "'Segoe UI', Tahoma, Geneva, Verdana" + }, + padding: 16, + usePointStyle: true, + pointStyle: 'circle', + generateLabels: (chart) => { + const data = chart.data; + return data.labels.map((label, i) => ({ + text: `${label} (${data.datasets[0].data[i]})`, + fillStyle: data.datasets[0].backgroundColor[i], + hidden: false, + index: i, + pointStyle: 'circle' + })); + } + } + }, + tooltip: { + enabled: true, + backgroundColor: 'rgba(22, 27, 34, 0.95)', + titleColor: '#58a6ff', + bodyColor: '#c9d1d9', + borderColor: '#58a6ff', + borderWidth: 2, + padding: 14, + titleFont: { + size: 14, + weight: 'bold', + family: "'Segoe UI', Tahoma, Geneva, Verdana" + }, + bodyFont: { + size: 13, + family: "'Segoe UI', Tahoma, Geneva, Verdana" + }, + caretSize: 8, + caretPadding: 12, + callbacks: { + label: function(context) { + const total = context.dataset.data.reduce((a, b) => a + b, 0); + const percentage = ((context.parsed / total) * 100).toFixed(1); + return `${context.label}: ${percentage}%`; + } + } + } + }, + animation: { + enabled: false + }, + onHover: (event, activeElements) => { + canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default'; + } + }, + plugins: [{ + id: 'customCanvasBackgroundColor', + beforeDraw: (chart) => { + if (chart.ctx) { + chart.ctx.save(); + chart.ctx.globalCompositeOperation = 'destination-over'; + chart.ctx.fillStyle = 'rgba(0,0,0,0)'; + chart.ctx.fillRect(0, 0, chart.width, chart.height); + chart.ctx.restore(); + } + } + }] + }); + + attackTypesChartLoaded = true; + } catch (err) { + console.error('Error loading attack types chart:', err); + } +} diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js new file mode 100644 index 0000000..b74a51d --- /dev/null +++ b/src/templates/static/js/dashboard.js @@ -0,0 +1,125 @@ +// Alpine.js Dashboard Application +document.addEventListener('alpine:init', () => { + Alpine.data('dashboardApp', () => ({ + // State + tab: 'overview', + dashboardPath: window.__DASHBOARD_PATH__ || '', + + // Banlist dropdown + banlistOpen: false, + + // Raw request modal + rawModal: { show: false, content: '', logId: null }, + + // Map state + mapInitialized: false, + + // Chart state + chartLoaded: false, + + init() { + // Handle hash-based tab routing + const hash = window.location.hash.slice(1); + if (hash === 'ip-stats' || hash === 'attacks') { + this.switchToAttacks(); + } + + window.addEventListener('hashchange', () => { + const h = window.location.hash.slice(1); + if (h === 'ip-stats' || h === 'attacks') { + this.switchToAttacks(); + } else { + this.switchToOverview(); + } + }); + }, + + switchToAttacks() { + this.tab = 'attacks'; + window.location.hash = '#ip-stats'; + + // Delay initialization to ensure the container is visible and + // the browser has reflowed after x-show removes display:none. + // Leaflet and Chart.js need visible containers with real dimensions. + this.$nextTick(() => { + setTimeout(() => { + if (!this.mapInitialized && typeof initializeAttackerMap === 'function') { + initializeAttackerMap(); + this.mapInitialized = true; + } + if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') { + loadAttackTypesChart(); + this.chartLoaded = true; + } + }, 200); + }); + }, + + switchToOverview() { + this.tab = 'overview'; + window.location.hash = '#overview'; + }, + + async viewRawRequest(logId) { + try { + const resp = await fetch( + `${this.dashboardPath}/api/raw-request/${logId}`, + { cache: 'no-store' } + ); + if (resp.status === 404) { + alert('Raw request not available'); + return; + } + const data = await resp.json(); + this.rawModal.content = data.raw_request || 'No content available'; + this.rawModal.logId = logId; + this.rawModal.show = true; + } catch (err) { + alert('Failed to load raw request'); + } + }, + + closeRawModal() { + this.rawModal.show = false; + this.rawModal.content = ''; + this.rawModal.logId = null; + }, + + downloadRawRequest() { + if (!this.rawModal.content) return; + const blob = new Blob([this.rawModal.content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `raw-request-${this.rawModal.logId || Date.now()}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + + toggleIpDetail(event) { + const row = event.target.closest('tr'); + if (!row) return; + const detailRow = row.nextElementSibling; + if (detailRow && detailRow.classList.contains('ip-stats-row')) { + detailRow.style.display = + detailRow.style.display === 'table-row' ? 'none' : 'table-row'; + } + }, + })); +}); + +// Utility function for formatting timestamps (used by map popups) +function formatTimestamp(isoTimestamp) { + if (!isoTimestamp) return 'N/A'; + try { + const date = new Date(isoTimestamp); + return date.toLocaleString('en-US', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + } catch { + return isoTimestamp; + } +} diff --git a/src/templates/static/js/map.js b/src/templates/static/js/map.js new file mode 100644 index 0000000..6dfaf02 --- /dev/null +++ b/src/templates/static/js/map.js @@ -0,0 +1,469 @@ +// IP Map Visualization +// Extracted from dashboard_template.py (lines ~2978-3348) + +let attackerMap = null; +let allIps = []; +let mapMarkers = []; +let markerLayers = {}; + +const categoryColors = { + attacker: '#f85149', + bad_crawler: '#f0883e', + good_crawler: '#3fb950', + regular_user: '#58a6ff', + unknown: '#8b949e' +}; + +async function initializeAttackerMap() { + const DASHBOARD_PATH = window.__DASHBOARD_PATH__ || ''; + const mapContainer = document.getElementById('attacker-map'); + if (!mapContainer || attackerMap) return; + + try { + // Initialize map + attackerMap = L.map('attacker-map', { + center: [20, 0], + zoom: 2, + layers: [ + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© CartoDB | © OpenStreetMap contributors', + maxZoom: 19, + subdomains: 'abcd' + }) + ] + }); + + // Fetch all IPs (not just attackers) + const response = await fetch(DASHBOARD_PATH + '/api/all-ips?page=1&page_size=100&sort_by=total_requests&sort_order=desc', { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (!response.ok) throw new Error('Failed to fetch IPs'); + + const data = await response.json(); + allIps = data.ips || []; + + if (allIps.length === 0) { + mapContainer.innerHTML = '
No IP location data available
'; + return; + } + + // Get max request count for scaling + const maxRequests = Math.max(...allIps.map(ip => ip.total_requests || 0)); + + // City coordinates database (major cities worldwide) + const cityCoordinates = { + // United States + 'New York': [40.7128, -74.0060], 'Los Angeles': [34.0522, -118.2437], + 'San Francisco': [37.7749, -122.4194], 'Chicago': [41.8781, -87.6298], + 'Seattle': [47.6062, -122.3321], 'Miami': [25.7617, -80.1918], + 'Boston': [42.3601, -71.0589], 'Atlanta': [33.7490, -84.3880], + 'Dallas': [32.7767, -96.7970], 'Houston': [29.7604, -95.3698], + 'Denver': [39.7392, -104.9903], 'Phoenix': [33.4484, -112.0740], + // Europe + 'London': [51.5074, -0.1278], 'Paris': [48.8566, 2.3522], + 'Berlin': [52.5200, 13.4050], 'Amsterdam': [52.3676, 4.9041], + 'Moscow': [55.7558, 37.6173], 'Rome': [41.9028, 12.4964], + 'Madrid': [40.4168, -3.7038], 'Barcelona': [41.3874, 2.1686], + 'Milan': [45.4642, 9.1900], 'Vienna': [48.2082, 16.3738], + 'Stockholm': [59.3293, 18.0686], 'Oslo': [59.9139, 10.7522], + 'Copenhagen': [55.6761, 12.5683], 'Warsaw': [52.2297, 21.0122], + 'Prague': [50.0755, 14.4378], 'Budapest': [47.4979, 19.0402], + 'Athens': [37.9838, 23.7275], 'Lisbon': [38.7223, -9.1393], + 'Brussels': [50.8503, 4.3517], 'Dublin': [53.3498, -6.2603], + 'Zurich': [47.3769, 8.5417], 'Geneva': [46.2044, 6.1432], + 'Helsinki': [60.1699, 24.9384], 'Bucharest': [44.4268, 26.1025], + 'Saint Petersburg': [59.9343, 30.3351], 'Manchester': [53.4808, -2.2426], + 'Roubaix': [50.6942, 3.1746], 'Frankfurt': [50.1109, 8.6821], + 'Munich': [48.1351, 11.5820], 'Hamburg': [53.5511, 9.9937], + // Asia + 'Tokyo': [35.6762, 139.6503], 'Beijing': [39.9042, 116.4074], + 'Shanghai': [31.2304, 121.4737], 'Singapore': [1.3521, 103.8198], + 'Mumbai': [19.0760, 72.8777], 'Delhi': [28.7041, 77.1025], + 'Bangalore': [12.9716, 77.5946], 'Seoul': [37.5665, 126.9780], + 'Hong Kong': [22.3193, 114.1694], 'Bangkok': [13.7563, 100.5018], + 'Jakarta': [6.2088, 106.8456], 'Manila': [14.5995, 120.9842], + 'Hanoi': [21.0285, 105.8542], 'Ho Chi Minh City': [10.8231, 106.6297], + 'Taipei': [25.0330, 121.5654], 'Kuala Lumpur': [3.1390, 101.6869], + 'Karachi': [24.8607, 67.0011], 'Islamabad': [33.6844, 73.0479], + 'Dhaka': [23.8103, 90.4125], 'Colombo': [6.9271, 79.8612], + // South America + 'S\u00e3o Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729], + 'Buenos Aires': [-34.6037, -58.3816], 'Bogot\u00e1': [4.7110, -74.0721], + 'Lima': [-12.0464, -77.0428], 'Santiago': [-33.4489, -70.6693], + // Middle East & Africa + 'Cairo': [30.0444, 31.2357], 'Dubai': [25.2048, 55.2708], + 'Istanbul': [41.0082, 28.9784], 'Tel Aviv': [32.0853, 34.7818], + 'Johannesburg': [26.2041, 28.0473], 'Lagos': [6.5244, 3.3792], + 'Nairobi': [-1.2921, 36.8219], 'Cape Town': [-33.9249, 18.4241], + // Australia & Oceania + 'Sydney': [-33.8688, 151.2093], 'Melbourne': [-37.8136, 144.9631], + 'Brisbane': [-27.4698, 153.0251], 'Perth': [-31.9505, 115.8605], + 'Auckland': [-36.8485, 174.7633], + // Additional cities + 'Unknown': null + }; + + // Country center coordinates (fallback when city not found) + const countryCoordinates = { + 'US': [37.1, -95.7], 'GB': [55.4, -3.4], 'CN': [35.9, 104.1], 'RU': [61.5, 105.3], + 'JP': [36.2, 138.3], 'DE': [51.2, 10.5], 'FR': [46.6, 2.2], 'IN': [20.6, 78.96], + 'BR': [-14.2, -51.9], 'CA': [56.1, -106.3], 'AU': [-25.3, 133.8], 'MX': [23.6, -102.6], + 'ZA': [-30.6, 22.9], 'KR': [35.9, 127.8], 'IT': [41.9, 12.6], 'ES': [40.5, -3.7], + 'NL': [52.1, 5.3], 'SE': [60.1, 18.6], 'CH': [46.8, 8.2], 'PL': [51.9, 19.1], + 'SG': [1.4, 103.8], 'HK': [22.4, 114.1], 'TW': [23.7, 120.96], 'TH': [15.9, 100.9], + 'VN': [14.1, 108.8], 'ID': [-0.8, 113.2], 'PH': [12.9, 121.8], 'MY': [4.2, 101.7], + 'PK': [30.4, 69.2], 'BD': [23.7, 90.4], 'NG': [9.1, 8.7], 'EG': [26.8, 30.8], + 'TR': [38.9, 35.2], 'IR': [32.4, 53.7], 'AE': [23.4, 53.8], 'KZ': [48.0, 66.9], + 'UA': [48.4, 31.2], 'BG': [42.7, 25.5], 'RO': [45.9, 24.97], 'CZ': [49.8, 15.5], + 'HU': [47.2, 19.5], 'AT': [47.5, 14.6], 'BE': [50.5, 4.5], 'DK': [56.3, 9.5], + 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2], + 'AR': [-38.4161, -63.6167], 'CO': [4.5709, -74.2973], 'CL': [-35.6751, -71.5430], + 'PE': [-9.1900, -75.0152], 'VE': [6.4238, -66.5897], 'LS': [40.0, -100.0] + }; + + // Helper function to get coordinates for an IP + function getIPCoordinates(ip) { + // Use actual latitude and longitude if available + if (ip.latitude != null && ip.longitude != null) { + return [ip.latitude, ip.longitude]; + } + // Fall back to city lookup + if (ip.city && cityCoordinates[ip.city]) { + return cityCoordinates[ip.city]; + } + // Fall back to country + if (ip.country_code && countryCoordinates[ip.country_code]) { + return countryCoordinates[ip.country_code]; + } + return null; + } + + // Track used coordinates to add small offsets for overlapping markers + const usedCoordinates = {}; + function getUniqueCoordinates(baseCoords) { + const key = `${baseCoords[0].toFixed(4)},${baseCoords[1].toFixed(4)}`; + if (!usedCoordinates[key]) { + usedCoordinates[key] = 0; + } + usedCoordinates[key]++; + + // If this is the first marker at this location, use exact coordinates + if (usedCoordinates[key] === 1) { + return baseCoords; + } + + // Add small random offset for subsequent markers + // Offset increases with each marker to create a spread pattern + const angle = (usedCoordinates[key] * 137.5) % 360; // Golden angle for even distribution + const distance = 0.05 * Math.sqrt(usedCoordinates[key]); // Increase distance with more markers + const latOffset = distance * Math.cos(angle * Math.PI / 180); + const lngOffset = distance * Math.sin(angle * Math.PI / 180); + + return [ + baseCoords[0] + latOffset, + baseCoords[1] + lngOffset + ]; + } + + // Create layer groups for each category + markerLayers = { + attacker: L.featureGroup(), + bad_crawler: L.featureGroup(), + good_crawler: L.featureGroup(), + regular_user: L.featureGroup(), + unknown: L.featureGroup() + }; + + // Add markers for each IP + allIps.slice(0, 100).forEach(ip => { + if (!ip.country_code || !ip.category) return; + + // Get coordinates (city first, then country) + const baseCoords = getIPCoordinates(ip); + if (!baseCoords) return; + + // Get unique coordinates with offset to prevent overlap + const coords = getUniqueCoordinates(baseCoords); + + const category = ip.category.toLowerCase(); + if (!markerLayers[category]) return; + + // Calculate marker size based on request count with more dramatic scaling + // Scale up to 10,000 requests, then cap it + const requestsForScale = Math.min(ip.total_requests, 10000); + const sizeRatio = Math.pow(requestsForScale / 10000, 0.5); // Square root for better visual scaling + const markerSize = Math.max(10, Math.min(30, 10 + (sizeRatio * 20))); + + // Create custom marker element with category-specific class + const markerElement = document.createElement('div'); + markerElement.className = `ip-marker marker-${category}`; + markerElement.style.width = markerSize + 'px'; + markerElement.style.height = markerSize + 'px'; + markerElement.style.fontSize = (markerSize * 0.5) + 'px'; + markerElement.textContent = '\u25CF'; + + const marker = L.marker(coords, { + icon: L.divIcon({ + html: markerElement.outerHTML, + iconSize: [markerSize, markerSize], + className: `ip-custom-marker category-${category}` + }) + }); + + // Create popup with category badge and chart + const categoryColor = categoryColors[category] || '#8b949e'; + const categoryLabels = { + attacker: 'Attacker', + bad_crawler: 'Bad Crawler', + good_crawler: 'Good Crawler', + regular_user: 'Regular User', + unknown: 'Unknown' + }; + + // Bind popup once when marker is created + marker.bindPopup('', { + maxWidth: 550, + className: 'ip-detail-popup' + }); + + // Add click handler to fetch data and show popup + marker.on('click', async function(e) { + // Show loading popup first + const loadingPopup = ` +
+
+ ${ip.ip} + + ${categoryLabels[category]} + +
+
+
Loading details...
+
+
+ `; + + marker.setPopupContent(loadingPopup); + marker.openPopup(); + + try { + console.log('Fetching IP stats for:', ip.ip); + const response = await fetch(`${DASHBOARD_PATH}/api/ip-stats/${ip.ip}`); + if (!response.ok) throw new Error('Failed to fetch IP stats'); + + const stats = await response.json(); + console.log('Received stats:', stats); + + // Build complete popup content with chart + let popupContent = ` +
+
+ ${ip.ip} + + ${categoryLabels[category]} + +
+ + ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')} +
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+ `; + + // Add chart if category scores exist + if (stats.category_scores && Object.keys(stats.category_scores).length > 0) { + console.log('Category scores found:', stats.category_scores); + const chartHtml = generateMapPanelRadarChart(stats.category_scores); + console.log('Generated chart HTML length:', chartHtml.length); + popupContent += ` +
+ ${chartHtml} +
+ `; + } + + popupContent += '
'; + + // Update popup content + console.log('Updating popup content'); + marker.setPopupContent(popupContent); + } catch (err) { + console.error('Error fetching IP stats:', err); + const errorPopup = ` +
+
+ ${ip.ip} + + ${categoryLabels[category]} + +
+ + ${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')} +
+
+
Requests: ${ip.total_requests}
+
First Seen: ${formatTimestamp(ip.first_seen)}
+
Last Seen: ${formatTimestamp(ip.last_seen)}
+
+
+ Failed to load chart: ${err.message} +
+
+ `; + marker.setPopupContent(errorPopup); + } + }); + + markerLayers[category].addLayer(marker); + }); + + // Add all marker layers to map initially + Object.values(markerLayers).forEach(layer => attackerMap.addLayer(layer)); + + // Fit map to all markers + const allMarkers = Object.values(markerLayers).reduce((acc, layer) => { + acc.push(...layer.getLayers()); + return acc; + }, []); + + if (allMarkers.length > 0) { + const bounds = L.featureGroup(allMarkers).getBounds(); + attackerMap.fitBounds(bounds, { padding: [50, 50] }); + } + + // Force Leaflet to recalculate container size after the tab becomes visible. + // Without this, tiles may not render correctly when the container was hidden. + setTimeout(() => { + if (attackerMap) attackerMap.invalidateSize(); + }, 300); + + } catch (err) { + console.error('Error initializing attacker map:', err); + mapContainer.innerHTML = '
Failed to load map: ' + err.message + '
'; + } +} + +// Update map filters based on checkbox selection +function updateMapFilters() { + if (!attackerMap) return; + + const filters = {}; + document.querySelectorAll('.map-filter').forEach(cb => { + const category = cb.getAttribute('data-category'); + if (category) filters[category] = cb.checked; + }); + + // Update marker and circle layers visibility + Object.entries(filters).forEach(([category, show]) => { + if (markerLayers[category]) { + if (show) { + if (!attackerMap.hasLayer(markerLayers[category])) { + attackerMap.addLayer(markerLayers[category]); + } + } else { + if (attackerMap.hasLayer(markerLayers[category])) { + attackerMap.removeLayer(markerLayers[category]); + } + } + } + }); +} + +// Generate radar chart SVG for map panel popups +function generateMapPanelRadarChart(categoryScores) { + if (!categoryScores || Object.keys(categoryScores).length === 0) { + return '
No category data available
'; + } + + let html = '
'; + html += ''; + + const scores = { + attacker: categoryScores.attacker || 0, + good_crawler: categoryScores.good_crawler || 0, + bad_crawler: categoryScores.bad_crawler || 0, + regular_user: categoryScores.regular_user || 0, + unknown: categoryScores.unknown || 0 + }; + + const maxScore = Math.max(...Object.values(scores), 1); + const minVisibleRadius = 0.15; + const normalizedScores = {}; + + Object.keys(scores).forEach(key => { + normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius); + }); + + const colors = { + attacker: '#f85149', + good_crawler: '#3fb950', + bad_crawler: '#f0883e', + regular_user: '#58a6ff', + unknown: '#8b949e' + }; + + const labels = { + attacker: 'Attacker', + good_crawler: 'Good Bot', + bad_crawler: 'Bad Bot', + regular_user: 'User', + unknown: 'Unknown' + }; + + const cx = 100, cy = 100, maxRadius = 75; + for (let i = 1; i <= 5; i++) { + const r = (maxRadius / 5) * i; + html += ``; + } + + const angles = [0, 72, 144, 216, 288]; + const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker']; + + angles.forEach((angle, i) => { + const rad = (angle - 90) * Math.PI / 180; + const x2 = cx + maxRadius * Math.cos(rad); + const y2 = cy + maxRadius * Math.sin(rad); + html += ``; + + const labelDist = maxRadius + 35; + const lx = cx + labelDist * Math.cos(rad); + const ly = cy + labelDist * Math.sin(rad); + html += `${labels[keys[i]]}`; + }); + + let points = []; + angles.forEach((angle, i) => { + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + points.push(`${x},${y}`); + }); + + const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b); + const dominantColor = colors[dominantKey]; + + html += ``; + + angles.forEach((angle, i) => { + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + html += ``; + }); + + html += ''; + html += '
'; + return html; +} diff --git a/src/templates/static/js/radar.js b/src/templates/static/js/radar.js new file mode 100644 index 0000000..f531046 --- /dev/null +++ b/src/templates/static/js/radar.js @@ -0,0 +1,127 @@ +// Radar chart generation for IP stats +// Used by map popups and IP detail partials +// Extracted from dashboard_template.py (lines ~2092-2181) + +/** + * Generate an SVG radar chart for category scores. + * This is a reusable function that can be called from: + * - Map popup panels (generateMapPanelRadarChart in map.js) + * - IP detail partials (server-side or client-side rendering) + * + * @param {Object} categoryScores - Object with keys: attacker, good_crawler, bad_crawler, regular_user, unknown + * @param {number} [size=200] - Width/height of the SVG in pixels + * @param {boolean} [showLegend=true] - Whether to show the legend below the chart + * @returns {string} HTML string containing the SVG radar chart + */ +function generateRadarChart(categoryScores, size, showLegend) { + size = size || 200; + if (showLegend === undefined) showLegend = true; + + if (!categoryScores || Object.keys(categoryScores).length === 0) { + return '
No category data available
'; + } + + const scores = { + attacker: categoryScores.attacker || 0, + good_crawler: categoryScores.good_crawler || 0, + bad_crawler: categoryScores.bad_crawler || 0, + regular_user: categoryScores.regular_user || 0, + unknown: categoryScores.unknown || 0 + }; + + const maxScore = Math.max(...Object.values(scores), 1); + const minVisibleRadius = 0.15; + const normalizedScores = {}; + + Object.keys(scores).forEach(key => { + normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius); + }); + + const colors = { + attacker: '#f85149', + good_crawler: '#3fb950', + bad_crawler: '#f0883e', + regular_user: '#58a6ff', + unknown: '#8b949e' + }; + + const labels = { + attacker: 'Attacker', + good_crawler: 'Good Bot', + bad_crawler: 'Bad Bot', + regular_user: 'User', + unknown: 'Unknown' + }; + + const cx = 100, cy = 100, maxRadius = 75; + + let html = '
'; + html += ``; + + // Draw concentric circles (grid) + for (let i = 1; i <= 5; i++) { + const r = (maxRadius / 5) * i; + html += ``; + } + + const angles = [0, 72, 144, 216, 288]; + const keys = ['good_crawler', 'regular_user', 'unknown', 'bad_crawler', 'attacker']; + + // Draw axis lines and labels + angles.forEach((angle, i) => { + const rad = (angle - 90) * Math.PI / 180; + const x2 = cx + maxRadius * Math.cos(rad); + const y2 = cy + maxRadius * Math.sin(rad); + html += ``; + + const labelDist = maxRadius + 35; + const lx = cx + labelDist * Math.cos(rad); + const ly = cy + labelDist * Math.sin(rad); + html += `${labels[keys[i]]}`; + }); + + // Calculate polygon points + let points = []; + angles.forEach((angle, i) => { + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + points.push(`${x},${y}`); + }); + + // Determine dominant category for color + const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b); + const dominantColor = colors[dominantKey]; + + // Draw filled polygon + html += ``; + + // Draw data point dots + angles.forEach((angle, i) => { + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + html += ``; + }); + + html += ''; + + // Optional legend + if (showLegend) { + html += '
'; + keys.forEach(key => { + html += '
'; + html += `
`; + html += `${labels[key]}: ${scores[key]} pt`; + html += '
'; + }); + html += '
'; + } + + html += '
'; + return html; +} diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py index 5f19530..3f3735f 100644 --- a/tests/test_insert_fake_ips.py +++ b/tests/test_insert_fake_ips.py @@ -541,7 +541,7 @@ def generate_fake_data( app_logger.info( "All IPs have API-fetched geolocation with reverse geocoded city names." ) - app_logger.info("Run: python server.py") + app_logger.info("Run: uvicorn app:app --app-dir src") app_logger.info("=" * 60)