starting full refactor with FastAPI routes + HTMX and AlpineJS on client side

This commit is contained in:
Lorenzo Venerandi
2026-02-17 13:09:01 +01:00
parent 04823dab63
commit 5d38ea45a8
34 changed files with 4517 additions and 2 deletions

View File

@@ -26,4 +26,4 @@ EXPOSE 5000
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["python3", "src/server.py"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--app-dir", "src"]

View File

@@ -11,3 +11,9 @@ SQLAlchemy>=2.0.0,<3.0.0
APScheduler>=3.11.2 APScheduler>=3.11.2
requests>=2.32.5 requests>=2.32.5
# Web framework
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
jinja2>=3.1.0
python-multipart>=0.0.9

149
src/app.py Normal file
View File

@@ -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()

View File

@@ -359,6 +359,16 @@ class DatabaseManager:
sanitized_ip = sanitize_ip(ip) sanitized_ip = sanitize_ip(ip)
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() 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 # Check if category has changed and record it
old_category = ip_stats.category old_category = ip_stats.category
if old_category != category: if old_category != category:
@@ -390,6 +400,10 @@ class DatabaseManager:
sanitized_ip = sanitize_ip(ip) sanitized_ip = sanitize_ip(ip)
ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() 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 # Record the manual category change
old_category = ip_stats.category old_category = ip_stats.category
if old_category != category: if old_category != category:

95
src/dependencies.py Normal file
View File

@@ -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)})"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
"""
FastAPI middleware package for the Krawl honeypot.
"""

View File

@@ -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

View File

@@ -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 ("<!DOCTYPE" in body or "<!ENTITY" in body):
attack_type_log = "XXE_INJECTION"
elif any(
pattern in full_input
for pattern in [
"cmd=", "exec=", "command=", "execute=", "system=",
";", "|", "&&", "whoami", "id", "uname", "cat", "ls", "pwd",
]
):
attack_type_log = "COMMAND_INJECTION"
access_logger.warning(
f"[{attack_type_log} DETECTED] {client_ip} - {path[:100]} - Method: {method}"
)
# Record access
tracker = request.app.state.tracker
tracker.record_access(
ip=client_ip,
path=path,
user_agent=user_agent,
body=body,
method=method,
raw_request=build_raw_request(request, body),
)
return Response(
content=response_body,
status_code=status_code,
media_type=content_type,
)
response = await call_next(request)
return response

5
src/routes/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
"""
FastAPI routes package for the Krawl honeypot.
"""

321
src/routes/api.py Normal file
View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Dashboard JSON API routes.
Migrated from handler.py dashboard API endpoints.
All endpoints are prefixed with the secret dashboard path.
"""
import os
import json
from fastapi import APIRouter, Request, Response, Query
from fastapi.responses import JSONResponse, PlainTextResponse
from dependencies import get_db
from logger import get_app_logger
router = APIRouter()
def _no_cache_headers() -> 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)

38
src/routes/dashboard.py Normal file
View File

@@ -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,
},
)

450
src/routes/honeypot.py Normal file
View File

@@ -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, "<p>Crawl limit reached.</p>"
)
num_pages = random.randint(*config.links_per_page_range)
content = ""
if app.state.counter <= 0 and config.canary_token_url:
content += f"""
<div class="link-box canary-token">
<a href="{config.canary_token_url}">{config.canary_token_url}</a>
</div>
"""
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"""
<div class="link-box">
<a href="{address}">{address}</a>
</div>
"""
else:
for _ in range(num_pages):
address = random.choice(webpages)
content += f"""
<div class="link-box">
<a href="{address}">{address}</a>
</div>
"""
return html_templates.main_page(app.state.counter, content)

307
src/routes/htmx.py Normal file
View File

@@ -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,
},
)

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krawl Dashboard</title>
<link rel="icon" type="image/svg+xml" href="{{ dashboard_path }}/static/krawl-svg.svg" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" integrity="sha512-Zcn6bjR/8RZbLEDCR/+3GDRsl0DGPhkLoMo8gbyEVYMlkNaPKtePPkWOxjPQa28ZO1JXDUQWKCE3LR24w4IXw==" crossorigin="anonymous" />
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" integrity="sha512-puJW3E/qXDqYp9IfhAI54BJEaWIfloJ7JWs7OeD5i6ruC9JZL1gERT1wjfqERvhY7Io5heNHGE27F2MUOTM4A==" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js" defer></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script>window.__DASHBOARD_PATH__ = '{{ dashboard_path }}';</script>
</head>
<body>
{% block content %}{% endblock %}
<script src="{{ dashboard_path }}/static/js/radar.js"></script>
<script src="{{ dashboard_path }}/static/js/dashboard.js"></script>
<script src="{{ dashboard_path }}/static/js/map.js"></script>
<script src="{{ dashboard_path }}/static/js/charts.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block content %}
<div class="container" x-data="dashboardApp()" x-init="init()">
{# GitHub logo #}
<a href="https://github.com/BlessedRebuS/Krawl" target="_blank" class="github-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
<span class="github-logo-text">Krawl</span>
</a>
{# Banlist export dropdown - Alpine.js #}
<div class="download-section">
<div class="banlist-dropdown" @click.outside="banlistOpen = false">
<button class="banlist-dropdown-btn" @click="banlistOpen = !banlistOpen">
Export IPs Banlist ▾
</button>
<div class="banlist-dropdown-menu" :class="{ 'show': banlistOpen }">
<a :href="dashboardPath + '/api/get_banlist?fwtype=raw'" download>
<span class="banlist-icon">📄</span> Raw IPs List
</a>
<a :href="dashboardPath + '/api/get_banlist?fwtype=iptables'" download>
<span class="banlist-icon">🔥</span> IPTables Rules
</a>
</div>
</div>
</div>
<h1>Krawl Dashboard</h1>
{# Stats cards - server-rendered #}
{% include "dashboard/partials/stats_cards.html" %}
{# Tab navigation - Alpine.js #}
<div class="tabs-container">
<a class="tab-button" :class="{ active: tab === 'overview' }" @click.prevent="switchToOverview()" href="#overview">Overview</a>
<a class="tab-button" :class="{ active: tab === 'attacks' }" @click.prevent="switchToAttacks()" href="#ip-stats">Attacks</a>
</div>
{# ==================== OVERVIEW TAB ==================== #}
<div x-show="tab === 'overview'">
{# Suspicious Activity - server-rendered #}
{% include "dashboard/partials/suspicious_table.html" %}
{# Honeypot Triggers - HTMX loaded #}
<div class="table-container alert-section">
<h2>Honeypot Triggers by IP</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Top IPs + Top User-Agents side by side #}
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="table-container" style="flex: 1; min-width: 300px;">
<h2>Top IP Addresses</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/top-ips?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
<div class="table-container" style="flex: 1; min-width: 300px;">
<h2>Top User-Agents</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/top-ua?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
</div>
{# Top Paths #}
<div class="table-container">
<h2>Top Paths</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/top-paths?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
</div>
{# ==================== ATTACKS TAB ==================== #}
<div x-show="tab === 'attacks'" x-cloak>
{# Map section #}
{% include "dashboard/partials/map_section.html" %}
{# Attackers table - HTMX loaded #}
<div class="table-container alert-section">
<h2>Attackers by Total Requests</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/attackers?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Credentials table #}
<div class="table-container alert-section">
<h2>Captured Credentials</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/credentials?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Attack Types table #}
<div class="table-container alert-section">
<h2>Detected Attack Types</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/attacks?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
{# Charts + Patterns side by side #}
<div class="charts-container">
<div class="table-container chart-section">
<h2>Most Recurring Attack Types</h2>
<div class="chart-wrapper">
<canvas id="attack-types-chart"></canvas>
</div>
</div>
<div class="table-container chart-section">
<h2>Most Recurring Attack Patterns</h2>
<div class="htmx-container"
hx-get="{{ dashboard_path }}/htmx/patterns?page=1"
hx-trigger="revealed"
hx-swap="innerHTML">
<div class="htmx-indicator">Loading...</div>
</div>
</div>
</div>
</div>
{# Raw request modal - Alpine.js #}
{% include "dashboard/partials/raw_request_modal.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{# HTMX fragment: Detected Attack Types table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attacks?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Path</th>
<th>Attack Types</th>
<th>User-Agent</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/attacks?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Time
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for attack in items %}
<tr class="ip-row" data-ip="{{ attack.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ attack.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ attack.ip | e }}
</td>
<td>
<div class="path-cell-container">
<span class="path-truncated">{{ attack.path | e }}</span>
{% if attack.path | length > 30 %}
<div class="path-tooltip">{{ attack.path | e }}</div>
{% endif %}
</div>
</td>
<td>
<div class="attack-types-cell">
<span class="attack-types-truncated">{{ attack.attack_type | e }}</span>
{% if attack.attack_type | length > 30 %}
<div class="attack-types-tooltip">{{ attack.attack_type | e }}</div>
{% endif %}
</div>
</td>
<td>{{ (attack.user_agent | default(''))[:50] | e }}</td>
<td>{{ attack.timestamp | format_ts }}</td>
<td>
{% if attack.log_id %}
<button class="view-btn" @click="viewRawRequest({{ attack.log_id }})">View Request</button>
{% endif %}
</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="7" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7" style="text-align: center;">No attacks detected</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,60 @@
{# HTMX fragment: Attackers table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} attackers</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attackers?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/attackers?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table class="ip-stats-table">
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th class="sortable {% if sort_by == 'total_requests' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/attackers?page=1&sort_by=total_requests&sort_order={% if sort_by == 'total_requests' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Total Requests
</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Location</th>
</tr>
</thead>
<tbody>
{% for ip in items %}
<tr class="ip-row" data-ip="{{ ip.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ ip.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ ip.ip | e }}
</td>
<td>{{ ip.total_requests }}</td>
<td>{{ ip.first_seen | format_ts }}</td>
<td>{{ ip.last_seen | format_ts }}</td>
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="text-align: center;">No attackers found</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,60 @@
{# HTMX fragment: Captured Credentials table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/credentials?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/credentials?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th>Username</th>
<th>Password</th>
<th>Path</th>
<th class="sortable {% if sort_by == 'timestamp' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/credentials?page=1&sort_by=timestamp&sort_order={% if sort_by == 'timestamp' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Time
</th>
</tr>
</thead>
<tbody>
{% for cred in items %}
<tr class="ip-row" data-ip="{{ cred.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ cred.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ cred.ip | e }}
</td>
<td>{{ cred.username | default('N/A') | e }}</td>
<td>{{ cred.password | default('N/A') | e }}</td>
<td>{{ cred.path | default('') | e }}</td>
<td>{{ cred.timestamp | format_ts }}</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="6" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="text-align: center;">No credentials captured</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,54 @@
{# HTMX fragment: Honeypot triggers table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/honeypot?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/honeypot?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/honeypot?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Honeypot Triggers
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="ip-row" data-ip="{{ item.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ item.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ item.ip | e }}
</td>
<td>{{ item.count }}</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,138 @@
{# HTMX fragment: IP detail expansion row content #}
{# Replaces the ~250 line formatIpStats() JavaScript function #}
<div class="stats-left">
<div class="stat-row">
<span class="stat-label-sm">Total Requests:</span>
<span class="stat-value-sm">{{ stats.total_requests | default('N/A') }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">First Seen:</span>
<span class="stat-value-sm">{{ stats.first_seen | format_ts }}</span>
</div>
<div class="stat-row">
<span class="stat-label-sm">Last Seen:</span>
<span class="stat-value-sm">{{ stats.last_seen | format_ts }}</span>
</div>
{% if stats.city or stats.country_code %}
<div class="stat-row">
<span class="stat-label-sm">Location:</span>
<span class="stat-value-sm">{{ stats.city | default('') }}{% if stats.city and stats.country_code %}, {% endif %}{{ stats.country_code | default('') }}</span>
</div>
{% endif %}
{% if stats.reverse_dns %}
<div class="stat-row">
<span class="stat-label-sm">Reverse DNS:</span>
<span class="stat-value-sm">{{ stats.reverse_dns | e }}</span>
</div>
{% endif %}
{% if stats.asn_org %}
<div class="stat-row">
<span class="stat-label-sm">ASN Org:</span>
<span class="stat-value-sm">{{ stats.asn_org | e }}</span>
</div>
{% endif %}
{% if stats.asn %}
<div class="stat-row">
<span class="stat-label-sm">ASN:</span>
<span class="stat-value-sm">{{ stats.asn | e }}</span>
</div>
{% endif %}
{% if stats.isp %}
<div class="stat-row">
<span class="stat-label-sm">ISP:</span>
<span class="stat-value-sm">{{ stats.isp | e }}</span>
</div>
{% endif %}
{# Flags #}
{% set flags = [] %}
{% if stats.is_proxy %}{% set _ = flags.append('Proxy') %}{% endif %}
{% if stats.is_hosting %}{% set _ = flags.append('Hosting') %}{% endif %}
{% if flags %}
<div class="stat-row">
<span class="stat-label-sm">Flags:</span>
<span class="stat-value-sm">{{ flags | join(', ') }}</span>
</div>
{% endif %}
{% if stats.reputation_score is not none %}
<div class="stat-row">
<span class="stat-label-sm">Reputation Score:</span>
<span class="stat-value-sm" style="color: {% if stats.reputation_score <= 30 %}#f85149{% elif stats.reputation_score <= 60 %}#f0883e{% else %}#3fb950{% endif %}">
{{ stats.reputation_score }}/100
</span>
</div>
{% endif %}
{% if stats.category %}
<div class="stat-row">
<span class="stat-label-sm">Category:</span>
<span class="category-badge category-{{ stats.category | lower | replace('_', '-') }}">
{{ stats.category | replace('_', ' ') | title }}
</span>
</div>
{% endif %}
{# Timeline + Reputation section #}
{% if stats.category_history or stats.blocklist_memberships %}
<div class="timeline-section">
<div class="timeline-container">
{# Behavior Timeline #}
{% if stats.category_history %}
<div class="timeline-column">
<div class="timeline-header">Behavior Timeline</div>
<div class="timeline">
{% for entry in stats.category_history %}
<div class="timeline-item">
<div class="timeline-marker {{ entry.new_category | default('unknown') | replace('_', '-') }}"></div>
<div>
<strong>{{ entry.new_category | default('unknown') | replace('_', ' ') | title }}</strong>
{% if entry.old_category %}<span style="color: #8b949e;"> from {{ entry.old_category | replace('_', ' ') | title }}</span>{% endif %}
<br><span style="color: #8b949e; font-size: 11px;">{{ entry.timestamp | format_ts }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Reputation / Listed On #}
<div class="timeline-column">
<div class="timeline-header">Reputation</div>
{% if stats.blocklist_memberships %}
<div class="reputation-title">Listed On</div>
{% for bl in stats.blocklist_memberships %}
<span class="reputation-badge">{{ bl | e }}</span>
{% endfor %}
{% else %}
<span class="reputation-clean">Clean - Not listed on any blocklists</span>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{# Radar chart (right side) #}
{% if stats.category_scores %}
<div class="stats-right">
<div class="radar-chart" id="radar-{{ stats.ip | default('') | replace('.', '-') | replace(':', '-') }}">
<script>
(function() {
const scores = {{ stats.category_scores | tojson }};
const container = document.getElementById('radar-{{ stats.ip | default("") | replace(".", "-") | replace(":", "-") }}');
if (container && typeof generateRadarChart === 'function') {
container.innerHTML = generateRadarChart(scores, 200);
}
})();
</script>
</div>
<div class="radar-legend">
<div class="radar-legend-item"><div class="radar-legend-color" style="background: #f85149;"></div> Attacker</div>
<div class="radar-legend-item"><div class="radar-legend-color" style="background: #3fb950;"></div> Good Bot</div>
<div class="radar-legend-item"><div class="radar-legend-color" style="background: #f0883e;"></div> Bad Bot</div>
<div class="radar-legend-item"><div class="radar-legend-color" style="background: #58a6ff;"></div> Regular</div>
<div class="radar-legend-item"><div class="radar-legend-color" style="background: #8b949e;"></div> Unknown</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,27 @@
{# Map section with filter checkboxes #}
<div class="table-container">
<h2>IP Origins Map</h2>
<div style="margin-bottom: 10px; display: flex; gap: 15px; flex-wrap: wrap; align-items: center;">
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" checked onchange="if(typeof updateMapFilters==='function') updateMapFilters()" class="map-filter" data-category="attacker">
<span style="color: #f85149;">Attackers</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" checked onchange="if(typeof updateMapFilters==='function') updateMapFilters()" class="map-filter" data-category="bad_crawler">
<span style="color: #f0883e;">Bad Crawlers</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" checked onchange="if(typeof updateMapFilters==='function') updateMapFilters()" class="map-filter" data-category="good_crawler">
<span style="color: #3fb950;">Good Crawlers</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" checked onchange="if(typeof updateMapFilters==='function') updateMapFilters()" class="map-filter" data-category="regular_user">
<span style="color: #58a6ff;">Regular Users</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; cursor: pointer;">
<input type="checkbox" checked onchange="if(typeof updateMapFilters==='function') updateMapFilters()" class="map-filter" data-category="unknown">
<span style="color: #8b949e;">Unknown</span>
</label>
</div>
<div id="attacker-map" style="height: 450px; border-radius: 6px; border: 1px solid #30363d;"></div>
</div>

View File

@@ -0,0 +1,43 @@
{# HTMX fragment: Attack Patterns table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} patterns</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/patterns?page={{ pagination.page - 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/patterns?page={{ pagination.page + 1 }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Attack Pattern</th>
<th>Occurrences</th>
</tr>
</thead>
<tbody>
{% for pattern in items %}
<tr>
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>
<div class="attack-types-cell">
<span class="attack-types-truncated">{{ pattern.pattern | e }}</span>
{% if pattern.pattern | length > 40 %}
<div class="attack-types-tooltip">{{ pattern.pattern | e }}</div>
{% endif %}
</div>
</td>
<td>{{ pattern.count }}</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No patterns found</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,20 @@
{# Raw request viewer modal - Alpine.js controlled #}
<div class="raw-request-modal"
x-show="rawModal.show"
x-cloak
@click.self="closeRawModal()"
@keydown.escape.window="closeRawModal()"
:style="rawModal.show ? 'display: block' : 'display: none'">
<div class="raw-request-modal-content">
<div class="raw-request-modal-header">
<h3>Raw HTTP Request</h3>
<span class="raw-request-modal-close" @click="closeRawModal()">&times;</span>
</div>
<div class="raw-request-modal-body">
<pre class="raw-request-content" x-text="rawModal.content"></pre>
</div>
<div class="raw-request-modal-footer">
<button class="raw-request-download-btn" @click="downloadRawRequest()">Download as .txt</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
{# Stats cards - server-rendered on initial page load #}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_accesses }}</div>
<div class="stat-label">Total Accesses</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.unique_ips }}</div>
<div class="stat-label">Unique IPs</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.unique_paths }}</div>
<div class="stat-label">Unique Paths</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{{ stats.suspicious_accesses }}</div>
<div class="stat-label">Suspicious Accesses</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{{ stats.honeypot_ips | default(0) }}</div>
<div class="stat-label">Honeypot Caught</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{{ stats.credential_count | default(0) }}</div>
<div class="stat-label">Credentials Captured</div>
</div>
<div class="stat-card alert">
<div class="stat-value alert">{{ stats.unique_attackers | default(0) }}</div>
<div class="stat-label">Unique Attackers</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
{# Recent Suspicious Activity - server-rendered on page load #}
<div class="table-container alert-section">
<h2>Recent Suspicious Activity</h2>
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Path</th>
<th>User-Agent</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for activity in suspicious_activities %}
<tr class="ip-row" data-ip="{{ activity.ip | e }}">
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ activity.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ activity.ip | e }}
</td>
<td>{{ activity.path | e }}</td>
<td style="word-break: break-all;">{{ (activity.user_agent | default(''))[:80] | e }}</td>
<td>{{ activity.timestamp | format_ts(time_only=True) }}</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="4" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;">No suspicious activity detected</td></tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,54 @@
{# HTMX fragment: Top IPs table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-ips?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-ips?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>IP Address</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/top-ips?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Access Count
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="ip-row" data-ip="{{ item.ip | e }}">
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td class="ip-clickable"
hx-get="{{ dashboard_path }}/htmx/ip-detail/{{ item.ip | e }}"
hx-target="next .ip-stats-row .ip-stats-dropdown"
hx-swap="innerHTML"
@click="toggleIpDetail($event)">
{{ item.ip | e }}
</td>
<td>{{ item.count }}</td>
</tr>
<tr class="ip-stats-row" style="display: none;">
<td colspan="3" class="ip-stats-cell">
<div class="ip-stats-dropdown">
<div class="loading">Loading stats...</div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,41 @@
{# HTMX fragment: Top Paths table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-paths?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-paths?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Path</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/top-paths?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Access Count
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>{{ item.path | e }}</td>
<td>{{ item.count }}</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,41 @@
{# HTMX fragment: Top User-Agents table #}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} &mdash; {{ pagination.total }} total</span>
<div style="display: flex; gap: 8px;">
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-ua?page={{ pagination.page - 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
<button class="pagination-btn"
hx-get="{{ dashboard_path }}/htmx/top-ua?page={{ pagination.page + 1 }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
hx-target="closest .htmx-container"
hx-swap="innerHTML"
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
</div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>User-Agent</th>
<th class="sortable {% if sort_by == 'count' %}{{ sort_order }}{% endif %}"
hx-get="{{ dashboard_path }}/htmx/top-ua?page=1&sort_by=count&sort_order={% if sort_by == 'count' and sort_order == 'desc' %}asc{% else %}desc{% endif %}"
hx-target="closest .htmx-container"
hx-swap="innerHTML">
Count
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
<td>{{ item.user_agent | e }}</td>
<td>{{ item.count }}</td>
</tr>
{% else %}
<tr><td colspan="3" style="text-align: center;">No data</td></tr>
{% endfor %}
</tbody>
</table>

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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: '&copy; CartoDB | &copy; 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 = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #8b949e;">No IP location data available</div>';
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 = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]}
</span>
</div>
<div style="text-align: center; padding: 20px; color: #8b949e;">
<div style="font-size: 12px;">Loading details...</div>
</div>
</div>
`;
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 = `
<div style="padding: 12px; min-width: 200px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]}
</span>
</div>
<span style="color: #8b949e; font-size: 12px;">
${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
</span><br/>
<div style="margin-top: 8px; border-top: 1px solid #30363d; padding-top: 8px;">
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Requests:</span> <span style="color: ${categoryColor}; font-weight: bold;">${ip.total_requests}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">First Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${formatTimestamp(ip.first_seen)}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Last Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${formatTimestamp(ip.last_seen)}</span></div>
</div>
`;
// 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 += `
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px;">
${chartHtml}
</div>
`;
}
popupContent += '</div>';
// Update popup content
console.log('Updating popup content');
marker.setPopupContent(popupContent);
} catch (err) {
console.error('Error fetching IP stats:', err);
const errorPopup = `
<div style="padding: 12px; min-width: 280px; max-width: 320px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
<strong style="color: #58a6ff; font-size: 14px;">${ip.ip}</strong>
<span style="background: ${categoryColor}1a; color: ${categoryColor}; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600;">
${categoryLabels[category]}
</span>
</div>
<span style="color: #8b949e; font-size: 12px;">
${ip.city ? (ip.country_code ? `${ip.city}, ${ip.country_code}` : ip.city) : (ip.country_code || 'Unknown')}
</span><br/>
<div style="margin-top: 8px; border-top: 1px solid #30363d; padding-top: 8px;">
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Requests:</span> <span style="color: ${categoryColor}; font-weight: bold;">${ip.total_requests}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">First Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${formatTimestamp(ip.first_seen)}</span></div>
<div style="margin-bottom: 4px;"><span style="color: #8b949e;">Last Seen:</span> <span style="color: #58a6ff; font-size: 11px;">${formatTimestamp(ip.last_seen)}</span></div>
</div>
<div style="margin-top: 12px; border-top: 1px solid #30363d; padding-top: 12px; text-align: center; color: #f85149; font-size: 11px;">
Failed to load chart: ${err.message}
</div>
</div>
`;
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 = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #f85149;">Failed to load map: ' + err.message + '</div>';
}
}
// 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 '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>';
}
let html = '<div style="display: flex; flex-direction: column; align-items: center;">';
html += '<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: 160px; height: 160px;">';
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 += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
}
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 += `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="#30363d" stroke-width="0.5"/>`;
const labelDist = maxRadius + 35;
const lx = cx + labelDist * Math.cos(rad);
const ly = cy + labelDist * Math.sin(rad);
html += `<text x="${lx}" y="${ly}" fill="#8b949e" font-size="12" text-anchor="middle" dominant-baseline="middle">${labels[keys[i]]}</text>`;
});
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 += `<polygon points="${points.join(' ')}" fill="${dominantColor}" fill-opacity="0.4" stroke="${dominantColor}" stroke-width="2.5"/>`;
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 += `<circle cx="${x}" cy="${y}" r="4.5" fill="${colors[keys[i]]}" stroke="#0d1117" stroke-width="2"/>`;
});
html += '</svg>';
html += '</div>';
return html;
}

View File

@@ -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 '<div style="color: #8b949e; text-align: center; padding: 20px;">No category data available</div>';
}
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 = '<div style="display: flex; flex-direction: column; align-items: center;">';
html += `<svg class="radar-chart" viewBox="-30 -30 260 260" preserveAspectRatio="xMidYMid meet" style="width: ${size}px; height: ${size}px;">`;
// Draw concentric circles (grid)
for (let i = 1; i <= 5; i++) {
const r = (maxRadius / 5) * i;
html += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#30363d" stroke-width="0.5"/>`;
}
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 += `<line x1="${cx}" y1="${cy}" x2="${x2}" y2="${y2}" stroke="#30363d" stroke-width="0.5"/>`;
const labelDist = maxRadius + 35;
const lx = cx + labelDist * Math.cos(rad);
const ly = cy + labelDist * Math.sin(rad);
html += `<text x="${lx}" y="${ly}" fill="#8b949e" font-size="12" text-anchor="middle" dominant-baseline="middle">${labels[keys[i]]}</text>`;
});
// 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 += `<polygon points="${points.join(' ')}" fill="${dominantColor}" fill-opacity="0.4" stroke="${dominantColor}" stroke-width="2.5"/>`;
// 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 += `<circle cx="${x}" cy="${y}" r="4.5" fill="${colors[keys[i]]}" stroke="#0d1117" stroke-width="2"/>`;
});
html += '</svg>';
// Optional legend
if (showLegend) {
html += '<div class="radar-legend">';
keys.forEach(key => {
html += '<div class="radar-legend-item">';
html += `<div class="radar-legend-color" style="background: ${colors[key]};"></div>`;
html += `<span style="color: #8b949e;">${labels[key]}: ${scores[key]} pt</span>`;
html += '</div>';
});
html += '</div>';
}
html += '</div>';
return html;
}

View File

@@ -541,7 +541,7 @@ def generate_fake_data(
app_logger.info( app_logger.info(
"All IPs have API-fetched geolocation with reverse geocoded city names." "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) app_logger.info("=" * 60)