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

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