From e4cf4702ebb0c36d9677f5cf0ddfb0461232bdf1 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 00:09:27 +0100 Subject: [PATCH 01/31] added api endpoint to list public malicious ips --- src/config.py | 3 ++ src/handler.py | 116 ++++++++++++++++++++++--------------------------- 2 files changed, 55 insertions(+), 64 deletions(-) diff --git a/src/config.py b/src/config.py index 71cef0e..97b4d70 100644 --- a/src/config.py +++ b/src/config.py @@ -43,6 +43,7 @@ class Config: # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 + exports_path: str = "data/exports" # Analyzer settings http_risky_methods_threshold: float = None @@ -153,6 +154,7 @@ class Config: canary = data.get("canary", {}) dashboard = data.get("dashboard", {}) api = data.get("api", {}) + exports = data.get("exports", {}) database = data.get("database", {}) behavior = data.get("behavior", {}) analyzer = data.get("analyzer") or {} @@ -191,6 +193,7 @@ class Config: api_server_port=api.get("server_port", 8080), api_server_path=api.get("server_path", "/api/v2/users"), probability_error_codes=behavior.get("probability_error_codes", 0), + exports_path = exports.get("path"), database_path=database.get("path", "data/krawl.db"), database_retention_days=database.get("retention_days", 30), http_risky_methods_threshold=analyzer.get( diff --git a/src/handler.py b/src/handler.py index b3c76e7..ab1f715 100644 --- a/src/handler.py +++ b/src/handler.py @@ -7,8 +7,11 @@ from datetime import datetime from http.server import BaseHTTPRequestHandler from typing import Optional, List from urllib.parse import urlparse, parse_qs +import json +import os -from config import Config +from database import get_database +from config import Config,get_config from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -26,6 +29,9 @@ from wordlists import get_wordlists from sql_errors import generate_sql_error_response, get_sql_response_with_data from xss_detector import detect_xss_pattern, generate_xss_response from server_errors import generate_server_error +from models import AccessLog +from ip_utils import is_valid_public_ip +from sqlalchemy import distinct class Handler(BaseHTTPRequestHandler): @@ -58,10 +64,6 @@ class Handler(BaseHTTPRequestHandler): # Fallback to direct connection IP return self.client_address[0] - def _get_user_agent(self) -> str: - """Extract user agent from request""" - return self.headers.get("User-Agent", "") - def _get_category_by_ip(self, client_ip: str) -> str: """Get the category of an IP from the database""" return self.tracker.get_category_by_ip(client_ip) @@ -92,10 +94,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _parse_query_string(self) -> str: - """Extract query string from the request path""" - parsed = urlparse(self.path) - return parsed.query def _handle_sql_endpoint(self, path: str) -> bool: """ @@ -111,21 +109,20 @@ class Handler(BaseHTTPRequestHandler): try: # Get query parameters - query_string = self._parse_query_string() # Log SQL injection attempt client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") # Always check for SQL injection patterns error_msg, content_type, status_code = generate_sql_error_response( - query_string or "" + request_query or "" ) if error_msg: # SQL injection detected - log and return error self.access_logger.warning( - f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(status_code) self.send_header("Content-type", content_type) @@ -134,13 +131,13 @@ class Handler(BaseHTTPRequestHandler): else: # No injection detected - return fake data self.access_logger.info( - f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(200) self.send_header("Content-type", "application/json") self.end_headers() response_data = get_sql_response_with_data( - base_path, query_string or "" + base_path, request_query or "" ) self.wfile.write(response_data.encode()) @@ -239,10 +236,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): """Handle POST requests (mainly login attempts)""" client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") post_data = "" - from urllib.parse import urlparse base_path = urlparse(self.path).path @@ -293,7 +289,6 @@ class Handler(BaseHTTPRequestHandler): for pair in post_data.split("&"): if "=" in pair: key, value = pair.split("=", 1) - from urllib.parse import unquote_plus parsed_data[unquote_plus(key)] = unquote_plus(value) @@ -486,12 +481,25 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): """Responds to webpage requests""" + client_ip = self._get_client_ip() + + # respond with HTTP error code if client is banned if self.tracker.is_banned_ip(client_ip): self.send_response(500) self.end_headers() return - user_agent = self._get_user_agent() + + # get request data + user_agent = self.headers.get("User-Agent", "") + request_path = urlparse(self.path).path + self.app_logger.info(f"request_query: {request_path}") + query_params = parse_qs(urlparse(self.path).query) + self.app_logger.info(f"query_params: {query_params}") + + # get database reference + db = get_database() + session = db.session if ( self.config.dashboard_secret_path @@ -502,8 +510,7 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - dashboard_path = self.config.dashboard_secret_path - self.wfile.write(generate_dashboard(stats, dashboard_path).encode()) + self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) except BrokenPipeError: pass except Exception as e: @@ -525,10 +532,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats_list = db.get_ip_stats(limit=500) self.wfile.write(json.dumps({"ips": ip_stats_list}).encode()) except BrokenPipeError: @@ -552,15 +556,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() - # Parse query parameters - parsed_url = urlparse(self.path) - query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -598,11 +595,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() # Parse query parameters parsed_url = urlparse(self.path) @@ -648,10 +641,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats = db.get_ip_stats_by_ip(ip_address) if ip_stats: self.wfile.write(json.dumps(ip_stats).encode()) @@ -678,11 +668,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -721,11 +707,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -764,11 +746,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -782,7 +760,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, - sort_by=sort_by, +pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -807,11 +785,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -850,11 +824,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -893,11 +863,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -922,13 +888,35 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(json.dumps({"error": str(e)}).encode()) return + # API endpoint for downloading malicious IPs blocklist file + if ( + self.config.dashboard_secret_path and + request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + ): + + + fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs + results = ( + session.query(distinct(AccessLog.ip)) + .filter(AccessLog.is_suspicious == True) + .all() + ) + + # Filter out local/private IPs and the server's own IP + config = get_config() + server_ip = config.get_server_ip() + + public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] + self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + return + # API endpoint for downloading malicious IPs file if ( self.config.dashboard_secret_path and self.path == f"{self.config.dashboard_secret_path}/api/download/malicious_ips.txt" ): - import os file_path = os.path.join( os.path.dirname(__file__), "exports", "malicious_ips.txt" From ace04c1f5ee208fdd4d86defdb5cd4c174c6f889 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:41:41 +0100 Subject: [PATCH 02/31] added firewall strategy pattern --- src/firewall/fwtype.py | 40 +++++++++++++++++++++++++++++++++++++ src/firewall/iptables.py | 43 ++++++++++++++++++++++++++++++++++++++++ src/firewall/raw.py | 20 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/firewall/fwtype.py create mode 100644 src/firewall/iptables.py create mode 100644 src/firewall/raw.py diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py new file mode 100644 index 0000000..2995489 --- /dev/null +++ b/src/firewall/fwtype.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Dict, Type + + +class FWType(ABC): + """Abstract base class for firewall types.""" + + # Registry to store child classes + _registry: Dict[str, Type['FWType']] = {} + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses with their class name.""" + super().__init_subclass__(**kwargs) + cls._registry[cls.__name__.lower()] = cls + + @classmethod + def create(cls, fw_type: str, **kwargs) -> 'FWType': + """ + Factory method to create instances of child classes. + + Args: + fw_type: String name of the firewall type class to instantiate + **kwargs: Arguments to pass to the child class constructor + + Returns: + Instance of the requested child class + + Raises: + ValueError: If fw_type is not registered + """ + fw_type = fw_type.lower() + if fw_type not in cls._registry: + available = ', '.join(cls._registry.keys()) + raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + + return cls._registry[fw_type](**kwargs) + + @abstractmethod + def getBanlist(self,ips): + """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py new file mode 100644 index 0000000..73ac623 --- /dev/null +++ b/src/firewall/iptables.py @@ -0,0 +1,43 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Iptables(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate iptables ban rules from an array of IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing iptables commands, one per line + """ + if not ips: + return "" + + rules = [] + chain = "INPUT" + target = "DROP" + rules.append("#!/bin/bash") + rules.append("# iptables ban rules") + rules.append("") + + for ip in ips: + + ip = ip.strip() + + # Build the iptables command + rule_parts = [ + "iptables", + "-A", chain, + "-s", ip + ] + + # Add target + rule_parts.extend(["-j", target]) + + rules.append(" ".join(rule_parts)) + + return "\n".join(rules) diff --git a/src/firewall/raw.py b/src/firewall/raw.py new file mode 100644 index 0000000..1e29e75 --- /dev/null +++ b/src/firewall/raw.py @@ -0,0 +1,20 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Raw(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate raw list of bad IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing raw ips, one per line + """ + if not ips: + return "" + + return "\n".join(ips) From 630293d55ce7dab90551c9eb9555d2d4b97e2b33 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:07 +0100 Subject: [PATCH 03/31] added endpoint for blocklist download api --- src/handler.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/handler.py b/src/handler.py index ab1f715..3c9f6f6 100644 --- a/src/handler.py +++ b/src/handler.py @@ -12,6 +12,12 @@ import os from database import get_database from config import Config,get_config +from firewall.fwtype import FWType + +# imports for the __init_subclass__ method, do not remove pls +from firewall.iptables import Iptables +from firewall.raw import Raw + from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -894,8 +900,9 @@ pathsort_by=sort_by, request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): - + # get fwtype from request params fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs results = ( session.query(distinct(AccessLog.ip)) @@ -906,9 +913,18 @@ pathsort_by=sort_by, # Filter out local/private IPs and the server's own IP config = get_config() server_ip = config.get_server_ip() - public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] - self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + + # get specific fwtype based on query parameter + fwtype_parser = FWType.create(fwtype) + banlist = fwtype_parser.getBanlist(public_ips) + + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header("Content-Length", str(len(banlist))) + self.end_headers() + self.wfile.write(banlist.encode()) return # API endpoint for downloading malicious IPs file From 2d27b02bc83a68706e484a15a8fd93b5c04ecaac Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:20 +0100 Subject: [PATCH 04/31] refactor form for blocklist download --- src/templates/dashboard_template.py | 85 ++++++++++++++++------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 3ef693f..a90fa10 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -9,6 +9,11 @@ import html from datetime import datetime from zoneinfo import ZoneInfo +# imports for the __init_subclass__ method, do not remove pls +from firewall import fwtype +from firewall.iptables import Iptables +from firewall.raw import Raw + def _escape(value) -> str: """Escape HTML special characters to prevent XSS attacks.""" @@ -590,11 +595,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
- +
+ + +

Krawl Dashboard

@@ -1040,7 +1047,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (stats.category_history && stats.category_history.length > 0) {{ html += '
'; html += '
'; - + // Timeline column html += '
'; html += '
Behavior Timeline
'; @@ -1051,18 +1058,18 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: const timestamp = formatTimestamp(change.timestamp); const oldClass = change.old_category ? 'category-' + change.old_category.toLowerCase().replace('_', '-') : ''; const newClass = 'category-' + categoryClass; - + html += '
'; html += `
`; html += '
'; - + if (change.old_category) {{ html += `${{change.old_category}}`; html += ''; }} else {{ html += 'Initial:'; }} - + html += `${{change.new_category}}`; html += `
${{timestamp}}
`; html += '
'; @@ -1071,14 +1078,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
'; html += '
'; - + // Reputation column html += '
'; - + if (stats.list_on && Object.keys(stats.list_on).length > 0) {{ html += '
Listed On
'; const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0])); - + sortedSources.forEach(([source, url]) => {{ if (url && url !== 'N/A') {{ html += `${{source}}`; @@ -1090,7 +1097,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
Reputation
'; html += '✓ Clean'; }} - + html += '
'; html += '
'; html += '
'; @@ -1203,23 +1210,23 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.querySelectorAll('.tab-content').forEach(tab => {{ tab.classList.remove('active'); }}); - + // Remove active class from all buttons document.querySelectorAll('.tab-button').forEach(btn => {{ btn.classList.remove('active'); }}); - + // Show selected tab const selectedTab = document.getElementById(tabName); const selectedButton = document.querySelector(`.tab-button[href="#${{tabName}}"]`); - + if (selectedTab) {{ selectedTab.classList.add('active'); }} if (selectedButton) {{ selectedButton.classList.add('active'); }} - + // Load data for this tab if (tabName === 'ip-stats') {{ loadIpStatistics(1); @@ -1261,7 +1268,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (e.target.classList.contains('sortable') && e.target.closest('#ip-stats-tbody')) {{ return; // Don't sort when inside tbody }} - + const sortHeader = e.target.closest('th.sortable'); if (!sortHeader) return; @@ -1269,7 +1276,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (!table || !table.classList.contains('ip-stats-table')) return; const sortField = sortHeader.getAttribute('data-sort'); - + // Toggle sort order if clicking the same field if (currentSortBy === sortField) {{ currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc'; @@ -1300,9 +1307,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: console.error('IP stats tbody not found'); return; }} - + tbody.innerHTML = 'Loading...'; - + try {{ console.log('Fetching attackers from page:', page, 'sort:', currentSortBy, currentSortOrder); const response = await fetch(DASHBOARD_PATH + '/api/attackers?page=' + page + '&page_size=' + PAGE_SIZE + '&sort_by=' + currentSortBy + '&sort_order=' + currentSortOrder, {{ @@ -1312,14 +1319,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + console.log('Response status:', response.status); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const data = await response.json(); console.log('Received data:', data); - + if (!data.attackers || data.attackers.length === 0) {{ tbody.innerHTML = 'No attackers on this page.'; currentPage = page; @@ -1327,7 +1334,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: updatePaginationControls(); return; }} - + // Update pagination info currentPage = data.pagination.page; totalPages = data.pagination.total_pages; @@ -1335,7 +1342,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.getElementById('total-pages').textContent = totalPages; document.getElementById('total-attackers').textContent = data.pagination.total_attackers; updatePaginationControls(); - + let html = ''; data.attackers.forEach((attacker, index) => {{ const rank = (currentPage - 1) * PAGE_SIZE + index + 1; @@ -1355,10 +1362,10 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: `; }}); - + tbody.innerHTML = html; console.log('Populated', data.attackers.length, 'attacker records'); - + // Re-attach click listeners for expandable rows attachAttackerClickListeners(); }} catch (err) {{ @@ -1370,7 +1377,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: function updatePaginationControls() {{ const prevBtn = document.getElementById('prev-page-btn'); const nextBtn = document.getElementById('next-page-btn'); - + if (prevBtn) prevBtn.disabled = currentPage <= 1; if (nextBtn) nextBtn.disabled = currentPage >= totalPages; }} @@ -1642,7 +1649,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function loadOverviewTable(tableId) {{ const config = tableConfig[tableId]; if (!config) return; - + const state = overviewState[tableId]; const tbody = document.getElementById(tableId + '-tbody'); if (!tbody) return; @@ -1676,7 +1683,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: let html = ''; items.forEach((item, index) => {{ const rank = (state.currentPage - 1) * 5 + index + 1; - + if (tableId === 'honeypot') {{ html += `${{rank}}${{item.ip}}${{item.paths.join(', ')}}${{item.count}}`; html += ` @@ -1822,12 +1829,12 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function showIpDetail(ip) {{ const modal = document.getElementById('ip-detail-modal'); const bodyDiv = document.getElementById('ip-detail-body'); - + if (!modal || !bodyDiv) return; - + bodyDiv.innerHTML = '
Loading IP details...
'; modal.classList.add('show'); - + try {{ const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{ cache: 'no-store', @@ -1836,9 +1843,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const stats = await response.json(); bodyDiv.innerHTML = '

' + stats.ip + ' - Detailed Statistics

' + formatIpStats(stats); }} catch (err) {{ @@ -2138,7 +2145,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: // Initialize map when Attacks tab is opened const originalSwitchTab = window.switchTab; let attackTypesChartLoaded = false; - + window.switchTab = function(tabName) {{ originalSwitchTab(tabName); if (tabName === 'ip-stats') {{ @@ -2171,7 +2178,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }}); if (!response.ok) throw new Error('Failed to fetch attack types'); - + const data = await response.json(); const attacks = data.attacks || []; From 320f6ffd36c0026c934061545ae39c265bdf8013 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:53:11 +0100 Subject: [PATCH 05/31] linted code --- src/firewall/fwtype.py | 12 +++++++----- src/firewall/iptables.py | 9 +++------ src/firewall/raw.py | 3 ++- src/handler.py | 25 ++++++++++++++----------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py index 2995489..0e0e421 100644 --- a/src/firewall/fwtype.py +++ b/src/firewall/fwtype.py @@ -6,7 +6,7 @@ class FWType(ABC): """Abstract base class for firewall types.""" # Registry to store child classes - _registry: Dict[str, Type['FWType']] = {} + _registry: Dict[str, Type["FWType"]] = {} def __init_subclass__(cls, **kwargs): """Automatically register subclasses with their class name.""" @@ -14,7 +14,7 @@ class FWType(ABC): cls._registry[cls.__name__.lower()] = cls @classmethod - def create(cls, fw_type: str, **kwargs) -> 'FWType': + def create(cls, fw_type: str, **kwargs) -> "FWType": """ Factory method to create instances of child classes. @@ -30,11 +30,13 @@ class FWType(ABC): """ fw_type = fw_type.lower() if fw_type not in cls._registry: - available = ', '.join(cls._registry.keys()) - raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + available = ", ".join(cls._registry.keys()) + raise ValueError( + f"Unknown firewall type: '{fw_type}'. Available: {available}" + ) return cls._registry[fw_type](**kwargs) @abstractmethod - def getBanlist(self,ips): + def getBanlist(self, ips): """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py index 73ac623..159171e 100644 --- a/src/firewall/iptables.py +++ b/src/firewall/iptables.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Iptables(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate iptables ban rules from an array of IP addresses. @@ -29,11 +30,7 @@ class Iptables(FWType): ip = ip.strip() # Build the iptables command - rule_parts = [ - "iptables", - "-A", chain, - "-s", ip - ] + rule_parts = ["iptables", "-A", chain, "-s", ip] # Add target rule_parts.extend(["-j", target]) diff --git a/src/firewall/raw.py b/src/firewall/raw.py index 1e29e75..e0c82fe 100644 --- a/src/firewall/raw.py +++ b/src/firewall/raw.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Raw(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate raw list of bad IP addresses. diff --git a/src/handler.py b/src/handler.py index 3c9f6f6..38a15d1 100644 --- a/src/handler.py +++ b/src/handler.py @@ -11,7 +11,7 @@ import json import os from database import get_database -from config import Config,get_config +from config import Config, get_config from firewall.fwtype import FWType # imports for the __init_subclass__ method, do not remove pls @@ -100,7 +100,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _handle_sql_endpoint(self, path: str) -> bool: """ Handle SQL injection honeypot endpoints. @@ -245,7 +244,6 @@ class Handler(BaseHTTPRequestHandler): user_agent = self.headers.get("User-Agent", "") post_data = "" - base_path = urlparse(self.path).path if base_path in ["/api/search", "/api/sql", "/api/database"]: @@ -516,7 +514,11 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) + self.wfile.write( + generate_dashboard( + stats, self.config.dashboard_secret_path + ).encode() + ) except BrokenPipeError: pass except Exception as e: @@ -563,7 +565,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -602,7 +603,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - # Parse query parameters parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) @@ -766,7 +766,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, -pathsort_by=sort_by, + pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -896,12 +896,12 @@ pathsort_by=sort_by, # API endpoint for downloading malicious IPs blocklist file if ( - self.config.dashboard_secret_path and - request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + self.config.dashboard_secret_path + and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): # get fwtype from request params - fwtype = query_params.get("fwtype",["iptables"])[0] + fwtype = query_params.get("fwtype", ["iptables"])[0] # Query distinct suspicious IPs results = ( @@ -921,7 +921,10 @@ pathsort_by=sort_by, self.send_response(200) self.send_header("Content-type", "text/plain") - self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header( + "Content-Disposition", + f'attachment; filename="{fwtype}.txt"', + ) self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) From 323c7f31a2ab6fa0d63d086c6515c1df47791dac Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 00:09:27 +0100 Subject: [PATCH 06/31] added api endpoint to list public malicious ips --- src/config.py | 3 ++ src/handler.py | 116 ++++++++++++++++++++++--------------------------- 2 files changed, 55 insertions(+), 64 deletions(-) diff --git a/src/config.py b/src/config.py index 71cef0e..97b4d70 100644 --- a/src/config.py +++ b/src/config.py @@ -43,6 +43,7 @@ class Config: # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 + exports_path: str = "data/exports" # Analyzer settings http_risky_methods_threshold: float = None @@ -153,6 +154,7 @@ class Config: canary = data.get("canary", {}) dashboard = data.get("dashboard", {}) api = data.get("api", {}) + exports = data.get("exports", {}) database = data.get("database", {}) behavior = data.get("behavior", {}) analyzer = data.get("analyzer") or {} @@ -191,6 +193,7 @@ class Config: api_server_port=api.get("server_port", 8080), api_server_path=api.get("server_path", "/api/v2/users"), probability_error_codes=behavior.get("probability_error_codes", 0), + exports_path = exports.get("path"), database_path=database.get("path", "data/krawl.db"), database_retention_days=database.get("retention_days", 30), http_risky_methods_threshold=analyzer.get( diff --git a/src/handler.py b/src/handler.py index b3c76e7..ab1f715 100644 --- a/src/handler.py +++ b/src/handler.py @@ -7,8 +7,11 @@ from datetime import datetime from http.server import BaseHTTPRequestHandler from typing import Optional, List from urllib.parse import urlparse, parse_qs +import json +import os -from config import Config +from database import get_database +from config import Config,get_config from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -26,6 +29,9 @@ from wordlists import get_wordlists from sql_errors import generate_sql_error_response, get_sql_response_with_data from xss_detector import detect_xss_pattern, generate_xss_response from server_errors import generate_server_error +from models import AccessLog +from ip_utils import is_valid_public_ip +from sqlalchemy import distinct class Handler(BaseHTTPRequestHandler): @@ -58,10 +64,6 @@ class Handler(BaseHTTPRequestHandler): # Fallback to direct connection IP return self.client_address[0] - def _get_user_agent(self) -> str: - """Extract user agent from request""" - return self.headers.get("User-Agent", "") - def _get_category_by_ip(self, client_ip: str) -> str: """Get the category of an IP from the database""" return self.tracker.get_category_by_ip(client_ip) @@ -92,10 +94,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _parse_query_string(self) -> str: - """Extract query string from the request path""" - parsed = urlparse(self.path) - return parsed.query def _handle_sql_endpoint(self, path: str) -> bool: """ @@ -111,21 +109,20 @@ class Handler(BaseHTTPRequestHandler): try: # Get query parameters - query_string = self._parse_query_string() # Log SQL injection attempt client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") # Always check for SQL injection patterns error_msg, content_type, status_code = generate_sql_error_response( - query_string or "" + request_query or "" ) if error_msg: # SQL injection detected - log and return error self.access_logger.warning( - f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(status_code) self.send_header("Content-type", content_type) @@ -134,13 +131,13 @@ class Handler(BaseHTTPRequestHandler): else: # No injection detected - return fake data self.access_logger.info( - f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(200) self.send_header("Content-type", "application/json") self.end_headers() response_data = get_sql_response_with_data( - base_path, query_string or "" + base_path, request_query or "" ) self.wfile.write(response_data.encode()) @@ -239,10 +236,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): """Handle POST requests (mainly login attempts)""" client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") post_data = "" - from urllib.parse import urlparse base_path = urlparse(self.path).path @@ -293,7 +289,6 @@ class Handler(BaseHTTPRequestHandler): for pair in post_data.split("&"): if "=" in pair: key, value = pair.split("=", 1) - from urllib.parse import unquote_plus parsed_data[unquote_plus(key)] = unquote_plus(value) @@ -486,12 +481,25 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): """Responds to webpage requests""" + client_ip = self._get_client_ip() + + # respond with HTTP error code if client is banned if self.tracker.is_banned_ip(client_ip): self.send_response(500) self.end_headers() return - user_agent = self._get_user_agent() + + # get request data + user_agent = self.headers.get("User-Agent", "") + request_path = urlparse(self.path).path + self.app_logger.info(f"request_query: {request_path}") + query_params = parse_qs(urlparse(self.path).query) + self.app_logger.info(f"query_params: {query_params}") + + # get database reference + db = get_database() + session = db.session if ( self.config.dashboard_secret_path @@ -502,8 +510,7 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - dashboard_path = self.config.dashboard_secret_path - self.wfile.write(generate_dashboard(stats, dashboard_path).encode()) + self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) except BrokenPipeError: pass except Exception as e: @@ -525,10 +532,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats_list = db.get_ip_stats(limit=500) self.wfile.write(json.dumps({"ips": ip_stats_list}).encode()) except BrokenPipeError: @@ -552,15 +556,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() - # Parse query parameters - parsed_url = urlparse(self.path) - query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -598,11 +595,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() # Parse query parameters parsed_url = urlparse(self.path) @@ -648,10 +641,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats = db.get_ip_stats_by_ip(ip_address) if ip_stats: self.wfile.write(json.dumps(ip_stats).encode()) @@ -678,11 +668,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -721,11 +707,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -764,11 +746,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -782,7 +760,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, - sort_by=sort_by, +pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -807,11 +785,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -850,11 +824,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -893,11 +863,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -922,13 +888,35 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(json.dumps({"error": str(e)}).encode()) return + # API endpoint for downloading malicious IPs blocklist file + if ( + self.config.dashboard_secret_path and + request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + ): + + + fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs + results = ( + session.query(distinct(AccessLog.ip)) + .filter(AccessLog.is_suspicious == True) + .all() + ) + + # Filter out local/private IPs and the server's own IP + config = get_config() + server_ip = config.get_server_ip() + + public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] + self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + return + # API endpoint for downloading malicious IPs file if ( self.config.dashboard_secret_path and self.path == f"{self.config.dashboard_secret_path}/api/download/malicious_ips.txt" ): - import os file_path = os.path.join( os.path.dirname(__file__), "exports", "malicious_ips.txt" From 8fb24b1db4ff30bbc6f00ea74018192bfba9472f Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:41:41 +0100 Subject: [PATCH 07/31] added firewall strategy pattern --- src/firewall/fwtype.py | 40 +++++++++++++++++++++++++++++++++++++ src/firewall/iptables.py | 43 ++++++++++++++++++++++++++++++++++++++++ src/firewall/raw.py | 20 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/firewall/fwtype.py create mode 100644 src/firewall/iptables.py create mode 100644 src/firewall/raw.py diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py new file mode 100644 index 0000000..2995489 --- /dev/null +++ b/src/firewall/fwtype.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Dict, Type + + +class FWType(ABC): + """Abstract base class for firewall types.""" + + # Registry to store child classes + _registry: Dict[str, Type['FWType']] = {} + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses with their class name.""" + super().__init_subclass__(**kwargs) + cls._registry[cls.__name__.lower()] = cls + + @classmethod + def create(cls, fw_type: str, **kwargs) -> 'FWType': + """ + Factory method to create instances of child classes. + + Args: + fw_type: String name of the firewall type class to instantiate + **kwargs: Arguments to pass to the child class constructor + + Returns: + Instance of the requested child class + + Raises: + ValueError: If fw_type is not registered + """ + fw_type = fw_type.lower() + if fw_type not in cls._registry: + available = ', '.join(cls._registry.keys()) + raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + + return cls._registry[fw_type](**kwargs) + + @abstractmethod + def getBanlist(self,ips): + """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py new file mode 100644 index 0000000..73ac623 --- /dev/null +++ b/src/firewall/iptables.py @@ -0,0 +1,43 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Iptables(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate iptables ban rules from an array of IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing iptables commands, one per line + """ + if not ips: + return "" + + rules = [] + chain = "INPUT" + target = "DROP" + rules.append("#!/bin/bash") + rules.append("# iptables ban rules") + rules.append("") + + for ip in ips: + + ip = ip.strip() + + # Build the iptables command + rule_parts = [ + "iptables", + "-A", chain, + "-s", ip + ] + + # Add target + rule_parts.extend(["-j", target]) + + rules.append(" ".join(rule_parts)) + + return "\n".join(rules) diff --git a/src/firewall/raw.py b/src/firewall/raw.py new file mode 100644 index 0000000..1e29e75 --- /dev/null +++ b/src/firewall/raw.py @@ -0,0 +1,20 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Raw(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate raw list of bad IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing raw ips, one per line + """ + if not ips: + return "" + + return "\n".join(ips) From b09fea7dfe6ce6ad89d939187d90f347b2c8d764 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:07 +0100 Subject: [PATCH 08/31] added endpoint for blocklist download api --- src/handler.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/handler.py b/src/handler.py index ab1f715..3c9f6f6 100644 --- a/src/handler.py +++ b/src/handler.py @@ -12,6 +12,12 @@ import os from database import get_database from config import Config,get_config +from firewall.fwtype import FWType + +# imports for the __init_subclass__ method, do not remove pls +from firewall.iptables import Iptables +from firewall.raw import Raw + from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -894,8 +900,9 @@ pathsort_by=sort_by, request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): - + # get fwtype from request params fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs results = ( session.query(distinct(AccessLog.ip)) @@ -906,9 +913,18 @@ pathsort_by=sort_by, # Filter out local/private IPs and the server's own IP config = get_config() server_ip = config.get_server_ip() - public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] - self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + + # get specific fwtype based on query parameter + fwtype_parser = FWType.create(fwtype) + banlist = fwtype_parser.getBanlist(public_ips) + + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header("Content-Length", str(len(banlist))) + self.end_headers() + self.wfile.write(banlist.encode()) return # API endpoint for downloading malicious IPs file From ebbf12ecaef3b9ff3f231129e10043da2a5fac2f Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:20 +0100 Subject: [PATCH 09/31] refactor form for blocklist download --- src/templates/dashboard_template.py | 85 ++++++++++++++++------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 667de3d..288a34d 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -9,6 +9,11 @@ import html from datetime import datetime from zoneinfo import ZoneInfo +# imports for the __init_subclass__ method, do not remove pls +from firewall import fwtype +from firewall.iptables import Iptables +from firewall.raw import Raw + def _escape(value) -> str: """Escape HTML special characters to prevent XSS attacks.""" @@ -614,11 +619,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
- +
+ + +

Krawl Dashboard

@@ -1064,7 +1071,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (stats.category_history && stats.category_history.length > 0) {{ html += '
'; html += '
'; - + // Timeline column html += '
'; html += '
Behavior Timeline
'; @@ -1075,18 +1082,18 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: const timestamp = formatTimestamp(change.timestamp); const oldClass = change.old_category ? 'category-' + change.old_category.toLowerCase().replace('_', '-') : ''; const newClass = 'category-' + categoryClass; - + html += '
'; html += `
`; html += '
'; - + if (change.old_category) {{ html += `${{change.old_category}}`; html += ''; }} else {{ html += 'Initial:'; }} - + html += `${{change.new_category}}`; html += `
${{timestamp}}
`; html += '
'; @@ -1095,14 +1102,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
'; html += '
'; - + // Reputation column html += '
'; - + if (stats.list_on && Object.keys(stats.list_on).length > 0) {{ html += '
Listed On
'; const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0])); - + sortedSources.forEach(([source, url]) => {{ if (url && url !== 'N/A') {{ html += `${{source}}`; @@ -1114,7 +1121,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
Reputation
'; html += '✓ Clean'; }} - + html += '
'; html += '
'; html += '
'; @@ -1227,23 +1234,23 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.querySelectorAll('.tab-content').forEach(tab => {{ tab.classList.remove('active'); }}); - + // Remove active class from all buttons document.querySelectorAll('.tab-button').forEach(btn => {{ btn.classList.remove('active'); }}); - + // Show selected tab const selectedTab = document.getElementById(tabName); const selectedButton = document.querySelector(`.tab-button[href="#${{tabName}}"]`); - + if (selectedTab) {{ selectedTab.classList.add('active'); }} if (selectedButton) {{ selectedButton.classList.add('active'); }} - + // Load data for this tab if (tabName === 'ip-stats') {{ loadIpStatistics(1); @@ -1285,7 +1292,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (e.target.classList.contains('sortable') && e.target.closest('#ip-stats-tbody')) {{ return; // Don't sort when inside tbody }} - + const sortHeader = e.target.closest('th.sortable'); if (!sortHeader) return; @@ -1293,7 +1300,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (!table || !table.classList.contains('ip-stats-table')) return; const sortField = sortHeader.getAttribute('data-sort'); - + // Toggle sort order if clicking the same field if (currentSortBy === sortField) {{ currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc'; @@ -1324,9 +1331,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: console.error('IP stats tbody not found'); return; }} - + tbody.innerHTML = 'Loading...'; - + try {{ console.log('Fetching attackers from page:', page, 'sort:', currentSortBy, currentSortOrder); const response = await fetch(DASHBOARD_PATH + '/api/attackers?page=' + page + '&page_size=' + PAGE_SIZE + '&sort_by=' + currentSortBy + '&sort_order=' + currentSortOrder, {{ @@ -1336,14 +1343,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + console.log('Response status:', response.status); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const data = await response.json(); console.log('Received data:', data); - + if (!data.attackers || data.attackers.length === 0) {{ tbody.innerHTML = 'No attackers on this page.'; currentPage = page; @@ -1351,7 +1358,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: updatePaginationControls(); return; }} - + // Update pagination info currentPage = data.pagination.page; totalPages = data.pagination.total_pages; @@ -1359,7 +1366,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.getElementById('total-pages').textContent = totalPages; document.getElementById('total-attackers').textContent = data.pagination.total_attackers; updatePaginationControls(); - + let html = ''; data.attackers.forEach((attacker, index) => {{ const rank = (currentPage - 1) * PAGE_SIZE + index + 1; @@ -1379,10 +1386,10 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: `; }}); - + tbody.innerHTML = html; console.log('Populated', data.attackers.length, 'attacker records'); - + // Re-attach click listeners for expandable rows attachAttackerClickListeners(); }} catch (err) {{ @@ -1394,7 +1401,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: function updatePaginationControls() {{ const prevBtn = document.getElementById('prev-page-btn'); const nextBtn = document.getElementById('next-page-btn'); - + if (prevBtn) prevBtn.disabled = currentPage <= 1; if (nextBtn) nextBtn.disabled = currentPage >= totalPages; }} @@ -1666,7 +1673,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function loadOverviewTable(tableId) {{ const config = tableConfig[tableId]; if (!config) return; - + const state = overviewState[tableId]; const tbody = document.getElementById(tableId + '-tbody'); if (!tbody) return; @@ -1700,7 +1707,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: let html = ''; items.forEach((item, index) => {{ const rank = (state.currentPage - 1) * 5 + index + 1; - + if (tableId === 'honeypot') {{ html += `${{rank}}${{item.ip}}${{item.paths.join(', ')}}${{item.count}}`; html += ` @@ -1846,12 +1853,12 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function showIpDetail(ip) {{ const modal = document.getElementById('ip-detail-modal'); const bodyDiv = document.getElementById('ip-detail-body'); - + if (!modal || !bodyDiv) return; - + bodyDiv.innerHTML = '
Loading IP details...
'; modal.classList.add('show'); - + try {{ const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{ cache: 'no-store', @@ -1860,9 +1867,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const stats = await response.json(); bodyDiv.innerHTML = '

' + stats.ip + ' - Detailed Statistics

' + formatIpStats(stats); }} catch (err) {{ @@ -2206,7 +2213,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: // Initialize map when Attacks tab is opened const originalSwitchTab = window.switchTab; let attackTypesChartLoaded = false; - + window.switchTab = function(tabName) {{ originalSwitchTab(tabName); if (tabName === 'ip-stats') {{ @@ -2239,7 +2246,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }}); if (!response.ok) throw new Error('Failed to fetch attack types'); - + const data = await response.json(); const attacks = data.attacks || []; From c730ba2bfda741cddee9135f738ae3af9b7dc98b Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:53:11 +0100 Subject: [PATCH 10/31] linted code --- src/firewall/fwtype.py | 12 +++++++----- src/firewall/iptables.py | 9 +++------ src/firewall/raw.py | 3 ++- src/handler.py | 25 ++++++++++++++----------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py index 2995489..0e0e421 100644 --- a/src/firewall/fwtype.py +++ b/src/firewall/fwtype.py @@ -6,7 +6,7 @@ class FWType(ABC): """Abstract base class for firewall types.""" # Registry to store child classes - _registry: Dict[str, Type['FWType']] = {} + _registry: Dict[str, Type["FWType"]] = {} def __init_subclass__(cls, **kwargs): """Automatically register subclasses with their class name.""" @@ -14,7 +14,7 @@ class FWType(ABC): cls._registry[cls.__name__.lower()] = cls @classmethod - def create(cls, fw_type: str, **kwargs) -> 'FWType': + def create(cls, fw_type: str, **kwargs) -> "FWType": """ Factory method to create instances of child classes. @@ -30,11 +30,13 @@ class FWType(ABC): """ fw_type = fw_type.lower() if fw_type not in cls._registry: - available = ', '.join(cls._registry.keys()) - raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + available = ", ".join(cls._registry.keys()) + raise ValueError( + f"Unknown firewall type: '{fw_type}'. Available: {available}" + ) return cls._registry[fw_type](**kwargs) @abstractmethod - def getBanlist(self,ips): + def getBanlist(self, ips): """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py index 73ac623..159171e 100644 --- a/src/firewall/iptables.py +++ b/src/firewall/iptables.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Iptables(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate iptables ban rules from an array of IP addresses. @@ -29,11 +30,7 @@ class Iptables(FWType): ip = ip.strip() # Build the iptables command - rule_parts = [ - "iptables", - "-A", chain, - "-s", ip - ] + rule_parts = ["iptables", "-A", chain, "-s", ip] # Add target rule_parts.extend(["-j", target]) diff --git a/src/firewall/raw.py b/src/firewall/raw.py index 1e29e75..e0c82fe 100644 --- a/src/firewall/raw.py +++ b/src/firewall/raw.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Raw(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate raw list of bad IP addresses. diff --git a/src/handler.py b/src/handler.py index 3c9f6f6..38a15d1 100644 --- a/src/handler.py +++ b/src/handler.py @@ -11,7 +11,7 @@ import json import os from database import get_database -from config import Config,get_config +from config import Config, get_config from firewall.fwtype import FWType # imports for the __init_subclass__ method, do not remove pls @@ -100,7 +100,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _handle_sql_endpoint(self, path: str) -> bool: """ Handle SQL injection honeypot endpoints. @@ -245,7 +244,6 @@ class Handler(BaseHTTPRequestHandler): user_agent = self.headers.get("User-Agent", "") post_data = "" - base_path = urlparse(self.path).path if base_path in ["/api/search", "/api/sql", "/api/database"]: @@ -516,7 +514,11 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) + self.wfile.write( + generate_dashboard( + stats, self.config.dashboard_secret_path + ).encode() + ) except BrokenPipeError: pass except Exception as e: @@ -563,7 +565,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -602,7 +603,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - # Parse query parameters parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) @@ -766,7 +766,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, -pathsort_by=sort_by, + pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -896,12 +896,12 @@ pathsort_by=sort_by, # API endpoint for downloading malicious IPs blocklist file if ( - self.config.dashboard_secret_path and - request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + self.config.dashboard_secret_path + and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): # get fwtype from request params - fwtype = query_params.get("fwtype",["iptables"])[0] + fwtype = query_params.get("fwtype", ["iptables"])[0] # Query distinct suspicious IPs results = ( @@ -921,7 +921,10 @@ pathsort_by=sort_by, self.send_response(200) self.send_header("Content-type", "text/plain") - self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header( + "Content-Disposition", + f'attachment; filename="{fwtype}.txt"', + ) self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) From 04549cc57cff6878b013deb2c176487ed5deeb54 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 00:09:27 +0100 Subject: [PATCH 11/31] added api endpoint to list public malicious ips --- src/config.py | 3 ++ src/handler.py | 116 ++++++++++++++++++++++--------------------------- 2 files changed, 55 insertions(+), 64 deletions(-) diff --git a/src/config.py b/src/config.py index 3e5983f..99ab54f 100644 --- a/src/config.py +++ b/src/config.py @@ -40,6 +40,7 @@ class Config: # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 + exports_path: str = "data/exports" # Analyzer settings http_risky_methods_threshold: float = None @@ -150,6 +151,7 @@ class Config: canary = data.get("canary", {}) dashboard = data.get("dashboard", {}) api = data.get("api", {}) + exports = data.get("exports", {}) database = data.get("database", {}) behavior = data.get("behavior", {}) analyzer = data.get("analyzer") or {} @@ -185,6 +187,7 @@ class Config: canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, probability_error_codes=behavior.get("probability_error_codes", 0), + exports_path = exports.get("path"), database_path=database.get("path", "data/krawl.db"), database_retention_days=database.get("retention_days", 30), http_risky_methods_threshold=analyzer.get( diff --git a/src/handler.py b/src/handler.py index 0a6abb2..b014dca 100644 --- a/src/handler.py +++ b/src/handler.py @@ -7,8 +7,11 @@ from datetime import datetime from http.server import BaseHTTPRequestHandler from typing import Optional, List from urllib.parse import urlparse, parse_qs +import json +import os -from config import Config +from database import get_database +from config import Config,get_config from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -26,6 +29,9 @@ from wordlists import get_wordlists from sql_errors import generate_sql_error_response, get_sql_response_with_data from xss_detector import detect_xss_pattern, generate_xss_response from server_errors import generate_server_error +from models import AccessLog +from ip_utils import is_valid_public_ip +from sqlalchemy import distinct class Handler(BaseHTTPRequestHandler): @@ -58,10 +64,6 @@ class Handler(BaseHTTPRequestHandler): # Fallback to direct connection IP return self.client_address[0] - def _get_user_agent(self) -> str: - """Extract user agent from request""" - return self.headers.get("User-Agent", "") - def _get_category_by_ip(self, client_ip: str) -> str: """Get the category of an IP from the database""" return self.tracker.get_category_by_ip(client_ip) @@ -92,10 +94,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _parse_query_string(self) -> str: - """Extract query string from the request path""" - parsed = urlparse(self.path) - return parsed.query def _handle_sql_endpoint(self, path: str) -> bool: """ @@ -111,21 +109,20 @@ class Handler(BaseHTTPRequestHandler): try: # Get query parameters - query_string = self._parse_query_string() # Log SQL injection attempt client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") # Always check for SQL injection patterns error_msg, content_type, status_code = generate_sql_error_response( - query_string or "" + request_query or "" ) if error_msg: # SQL injection detected - log and return error self.access_logger.warning( - f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(status_code) self.send_header("Content-type", content_type) @@ -134,13 +131,13 @@ class Handler(BaseHTTPRequestHandler): else: # No injection detected - return fake data self.access_logger.info( - f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {request_query[:100] if request_query else 'empty'}" ) self.send_response(200) self.send_header("Content-type", "application/json") self.end_headers() response_data = get_sql_response_with_data( - base_path, query_string or "" + base_path, request_query or "" ) self.wfile.write(response_data.encode()) @@ -239,10 +236,9 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): """Handle POST requests (mainly login attempts)""" client_ip = self._get_client_ip() - user_agent = self._get_user_agent() + user_agent = self.headers.get("User-Agent", "") post_data = "" - from urllib.parse import urlparse base_path = urlparse(self.path).path @@ -293,7 +289,6 @@ class Handler(BaseHTTPRequestHandler): for pair in post_data.split("&"): if "=" in pair: key, value = pair.split("=", 1) - from urllib.parse import unquote_plus parsed_data[unquote_plus(key)] = unquote_plus(value) @@ -486,12 +481,25 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): """Responds to webpage requests""" + client_ip = self._get_client_ip() + + # respond with HTTP error code if client is banned if self.tracker.is_banned_ip(client_ip): self.send_response(500) self.end_headers() return - user_agent = self._get_user_agent() + + # get request data + user_agent = self.headers.get("User-Agent", "") + request_path = urlparse(self.path).path + self.app_logger.info(f"request_query: {request_path}") + query_params = parse_qs(urlparse(self.path).query) + self.app_logger.info(f"query_params: {query_params}") + + # get database reference + db = get_database() + session = db.session # Handle static files for dashboard if self.config.dashboard_secret_path and self.path.startswith( @@ -543,8 +551,7 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - dashboard_path = self.config.dashboard_secret_path - self.wfile.write(generate_dashboard(stats, dashboard_path).encode()) + self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) except BrokenPipeError: pass except Exception as e: @@ -566,10 +573,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats_list = db.get_ip_stats(limit=500) self.wfile.write(json.dumps({"ips": ip_stats_list}).encode()) except BrokenPipeError: @@ -593,15 +597,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() - # Parse query parameters - parsed_url = urlparse(self.path) - query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -639,11 +636,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() # Parse query parameters parsed_url = urlparse(self.path) @@ -689,10 +682,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - db = get_database() ip_stats = db.get_ip_stats_by_ip(ip_address) if ip_stats: self.wfile.write(json.dumps(ip_stats).encode()) @@ -719,11 +709,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -762,11 +748,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -805,11 +787,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -823,7 +801,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, - sort_by=sort_by, +pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -848,11 +826,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -891,11 +865,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -934,11 +904,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Expires", "0") self.end_headers() try: - from database import get_database - import json - from urllib.parse import urlparse, parse_qs - db = get_database() parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) page = int(query_params.get("page", ["1"])[0]) @@ -963,13 +929,35 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(json.dumps({"error": str(e)}).encode()) return + # API endpoint for downloading malicious IPs blocklist file + if ( + self.config.dashboard_secret_path and + request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + ): + + + fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs + results = ( + session.query(distinct(AccessLog.ip)) + .filter(AccessLog.is_suspicious == True) + .all() + ) + + # Filter out local/private IPs and the server's own IP + config = get_config() + server_ip = config.get_server_ip() + + public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] + self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + return + # API endpoint for downloading malicious IPs file if ( self.config.dashboard_secret_path and self.path == f"{self.config.dashboard_secret_path}/api/download/malicious_ips.txt" ): - import os file_path = os.path.join( os.path.dirname(__file__), "exports", "malicious_ips.txt" From 639ce9c1328403fdccd48ae36194f7b4322caf11 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:41:41 +0100 Subject: [PATCH 12/31] added firewall strategy pattern --- src/firewall/fwtype.py | 40 +++++++++++++++++++++++++++++++++++++ src/firewall/iptables.py | 43 ++++++++++++++++++++++++++++++++++++++++ src/firewall/raw.py | 20 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/firewall/fwtype.py create mode 100644 src/firewall/iptables.py create mode 100644 src/firewall/raw.py diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py new file mode 100644 index 0000000..2995489 --- /dev/null +++ b/src/firewall/fwtype.py @@ -0,0 +1,40 @@ +from abc import ABC, abstractmethod +from typing import Dict, Type + + +class FWType(ABC): + """Abstract base class for firewall types.""" + + # Registry to store child classes + _registry: Dict[str, Type['FWType']] = {} + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses with their class name.""" + super().__init_subclass__(**kwargs) + cls._registry[cls.__name__.lower()] = cls + + @classmethod + def create(cls, fw_type: str, **kwargs) -> 'FWType': + """ + Factory method to create instances of child classes. + + Args: + fw_type: String name of the firewall type class to instantiate + **kwargs: Arguments to pass to the child class constructor + + Returns: + Instance of the requested child class + + Raises: + ValueError: If fw_type is not registered + """ + fw_type = fw_type.lower() + if fw_type not in cls._registry: + available = ', '.join(cls._registry.keys()) + raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + + return cls._registry[fw_type](**kwargs) + + @abstractmethod + def getBanlist(self,ips): + """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py new file mode 100644 index 0000000..73ac623 --- /dev/null +++ b/src/firewall/iptables.py @@ -0,0 +1,43 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Iptables(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate iptables ban rules from an array of IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing iptables commands, one per line + """ + if not ips: + return "" + + rules = [] + chain = "INPUT" + target = "DROP" + rules.append("#!/bin/bash") + rules.append("# iptables ban rules") + rules.append("") + + for ip in ips: + + ip = ip.strip() + + # Build the iptables command + rule_parts = [ + "iptables", + "-A", chain, + "-s", ip + ] + + # Add target + rule_parts.extend(["-j", target]) + + rules.append(" ".join(rule_parts)) + + return "\n".join(rules) diff --git a/src/firewall/raw.py b/src/firewall/raw.py new file mode 100644 index 0000000..1e29e75 --- /dev/null +++ b/src/firewall/raw.py @@ -0,0 +1,20 @@ +from typing_extensions import override +from firewall.fwtype import FWType + +class Raw(FWType): + + @override + def getBanlist(self,ips) -> str: + """ + Generate raw list of bad IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing raw ips, one per line + """ + if not ips: + return "" + + return "\n".join(ips) From cadf77da4407f411f71c21ab165f6c155846bad2 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:07 +0100 Subject: [PATCH 13/31] added endpoint for blocklist download api --- src/handler.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/handler.py b/src/handler.py index b014dca..8214dab 100644 --- a/src/handler.py +++ b/src/handler.py @@ -12,6 +12,12 @@ import os from database import get_database from config import Config,get_config +from firewall.fwtype import FWType + +# imports for the __init_subclass__ method, do not remove pls +from firewall.iptables import Iptables +from firewall.raw import Raw + from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -935,8 +941,9 @@ pathsort_by=sort_by, request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): - + # get fwtype from request params fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs results = ( session.query(distinct(AccessLog.ip)) @@ -947,9 +954,18 @@ pathsort_by=sort_by, # Filter out local/private IPs and the server's own IP config = get_config() server_ip = config.get_server_ip() - public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] - self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) + + # get specific fwtype based on query parameter + fwtype_parser = FWType.create(fwtype) + banlist = fwtype_parser.getBanlist(public_ips) + + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header("Content-Length", str(len(banlist))) + self.end_headers() + self.wfile.write(banlist.encode()) return # API endpoint for downloading malicious IPs file From 7e74896dfd30abfb5edb5518e3c15dac47aeb874 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:20 +0100 Subject: [PATCH 14/31] refactor form for blocklist download --- src/templates/dashboard_template.py | 85 ++++++++++++++++------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 89ca4fb..b8db40c 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -9,6 +9,11 @@ import html from datetime import datetime from zoneinfo import ZoneInfo +# imports for the __init_subclass__ method, do not remove pls +from firewall import fwtype +from firewall.iptables import Iptables +from firewall.raw import Raw + def _escape(value) -> str: """Escape HTML special characters to prevent XSS attacks.""" @@ -653,11 +658,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: BlessedRebuS/Krawl - +
+ + +

Krawl Dashboard

@@ -1106,7 +1113,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (stats.category_history && stats.category_history.length > 0) {{ html += '
'; html += '
'; - + // Timeline column html += '
'; html += '
Behavior Timeline
'; @@ -1117,18 +1124,18 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: const timestamp = formatTimestamp(change.timestamp); const oldClass = change.old_category ? 'category-' + change.old_category.toLowerCase().replace('_', '-') : ''; const newClass = 'category-' + categoryClass; - + html += '
'; html += `
`; html += '
'; - + if (change.old_category) {{ html += `${{change.old_category}}`; html += ''; }} else {{ html += 'Initial:'; }} - + html += `${{change.new_category}}`; html += `
${{timestamp}}
`; html += '
'; @@ -1137,14 +1144,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
'; html += '
'; - + // Reputation column html += '
'; - + if (stats.list_on && Object.keys(stats.list_on).length > 0) {{ html += '
Listed On
'; const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0])); - + sortedSources.forEach(([source, url]) => {{ if (url && url !== 'N/A') {{ html += `${{source}}`; @@ -1156,7 +1163,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: html += '
Reputation
'; html += '✓ Clean'; }} - + html += '
'; html += '
'; html += '
'; @@ -1360,23 +1367,23 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.querySelectorAll('.tab-content').forEach(tab => {{ tab.classList.remove('active'); }}); - + // Remove active class from all buttons document.querySelectorAll('.tab-button').forEach(btn => {{ btn.classList.remove('active'); }}); - + // Show selected tab const selectedTab = document.getElementById(tabName); const selectedButton = document.querySelector(`.tab-button[href="#${{tabName}}"]`); - + if (selectedTab) {{ selectedTab.classList.add('active'); }} if (selectedButton) {{ selectedButton.classList.add('active'); }} - + // Load data for this tab if (tabName === 'ip-stats') {{ loadIpStatistics(1); @@ -1418,7 +1425,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (e.target.classList.contains('sortable') && e.target.closest('#ip-stats-tbody')) {{ return; // Don't sort when inside tbody }} - + const sortHeader = e.target.closest('th.sortable'); if (!sortHeader) return; @@ -1426,7 +1433,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (!table || !table.classList.contains('ip-stats-table')) return; const sortField = sortHeader.getAttribute('data-sort'); - + // Toggle sort order if clicking the same field if (currentSortBy === sortField) {{ currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc'; @@ -1457,9 +1464,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: console.error('IP stats tbody not found'); return; }} - + tbody.innerHTML = 'Loading...'; - + try {{ console.log('Fetching attackers from page:', page, 'sort:', currentSortBy, currentSortOrder); const response = await fetch(DASHBOARD_PATH + '/api/attackers?page=' + page + '&page_size=' + PAGE_SIZE + '&sort_by=' + currentSortBy + '&sort_order=' + currentSortOrder, {{ @@ -1469,14 +1476,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + console.log('Response status:', response.status); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const data = await response.json(); console.log('Received data:', data); - + if (!data.attackers || data.attackers.length === 0) {{ tbody.innerHTML = 'No attackers on this page.'; currentPage = page; @@ -1484,7 +1491,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: updatePaginationControls(); return; }} - + // Update pagination info currentPage = data.pagination.page; totalPages = data.pagination.total_pages; @@ -1492,7 +1499,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: document.getElementById('total-pages').textContent = totalPages; document.getElementById('total-attackers').textContent = data.pagination.total_attackers; updatePaginationControls(); - + let html = ''; data.attackers.forEach((attacker, index) => {{ const rank = (currentPage - 1) * PAGE_SIZE + index + 1; @@ -1512,10 +1519,10 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: `; }}); - + tbody.innerHTML = html; console.log('Populated', data.attackers.length, 'attacker records'); - + // Re-attach click listeners for expandable rows attachAttackerClickListeners(); }} catch (err) {{ @@ -1527,7 +1534,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: function updatePaginationControls() {{ const prevBtn = document.getElementById('prev-page-btn'); const nextBtn = document.getElementById('next-page-btn'); - + if (prevBtn) prevBtn.disabled = currentPage <= 1; if (nextBtn) nextBtn.disabled = currentPage >= totalPages; }} @@ -1799,7 +1806,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function loadOverviewTable(tableId) {{ const config = tableConfig[tableId]; if (!config) return; - + const state = overviewState[tableId]; const tbody = document.getElementById(tableId + '-tbody'); if (!tbody) return; @@ -1833,7 +1840,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: let html = ''; items.forEach((item, index) => {{ const rank = (state.currentPage - 1) * 5 + index + 1; - + if (tableId === 'honeypot') {{ html += `${{rank}}${{item.ip}}${{item.paths.join(', ')}}${{item.count}}`; html += ` @@ -1979,12 +1986,12 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: async function showIpDetail(ip) {{ const modal = document.getElementById('ip-detail-modal'); const bodyDiv = document.getElementById('ip-detail-body'); - + if (!modal || !bodyDiv) return; - + bodyDiv.innerHTML = '
Loading IP details...
'; modal.classList.add('show'); - + try {{ const response = await fetch(`${{DASHBOARD_PATH}}/api/ip-stats/${{ip}}`, {{ cache: 'no-store', @@ -1993,9 +2000,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'Pragma': 'no-cache' }} }}); - + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); - + const stats = await response.json(); bodyDiv.innerHTML = '

' + stats.ip + ' - Detailed Statistics

' + formatIpStats(stats); }} catch (err) {{ @@ -2422,7 +2429,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: // Initialize map when Attacks tab is opened const originalSwitchTab = window.switchTab; let attackTypesChartLoaded = false; - + window.switchTab = function(tabName) {{ originalSwitchTab(tabName); if (tabName === 'ip-stats') {{ @@ -2455,7 +2462,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }}); if (!response.ok) throw new Error('Failed to fetch attack types'); - + const data = await response.json(); const attacks = data.attacks || []; From 2e2e4d65d0fb741b2ad59d9cfef0e7b678aed400 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:53:11 +0100 Subject: [PATCH 15/31] linted code --- src/firewall/fwtype.py | 12 +++++++----- src/firewall/iptables.py | 9 +++------ src/firewall/raw.py | 3 ++- src/handler.py | 25 ++++++++++++++----------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py index 2995489..0e0e421 100644 --- a/src/firewall/fwtype.py +++ b/src/firewall/fwtype.py @@ -6,7 +6,7 @@ class FWType(ABC): """Abstract base class for firewall types.""" # Registry to store child classes - _registry: Dict[str, Type['FWType']] = {} + _registry: Dict[str, Type["FWType"]] = {} def __init_subclass__(cls, **kwargs): """Automatically register subclasses with their class name.""" @@ -14,7 +14,7 @@ class FWType(ABC): cls._registry[cls.__name__.lower()] = cls @classmethod - def create(cls, fw_type: str, **kwargs) -> 'FWType': + def create(cls, fw_type: str, **kwargs) -> "FWType": """ Factory method to create instances of child classes. @@ -30,11 +30,13 @@ class FWType(ABC): """ fw_type = fw_type.lower() if fw_type not in cls._registry: - available = ', '.join(cls._registry.keys()) - raise ValueError(f"Unknown firewall type: '{fw_type}'. Available: {available}") + available = ", ".join(cls._registry.keys()) + raise ValueError( + f"Unknown firewall type: '{fw_type}'. Available: {available}" + ) return cls._registry[fw_type](**kwargs) @abstractmethod - def getBanlist(self,ips): + def getBanlist(self, ips): """Return the ruleset for the specific server""" diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py index 73ac623..159171e 100644 --- a/src/firewall/iptables.py +++ b/src/firewall/iptables.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Iptables(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate iptables ban rules from an array of IP addresses. @@ -29,11 +30,7 @@ class Iptables(FWType): ip = ip.strip() # Build the iptables command - rule_parts = [ - "iptables", - "-A", chain, - "-s", ip - ] + rule_parts = ["iptables", "-A", chain, "-s", ip] # Add target rule_parts.extend(["-j", target]) diff --git a/src/firewall/raw.py b/src/firewall/raw.py index 1e29e75..e0c82fe 100644 --- a/src/firewall/raw.py +++ b/src/firewall/raw.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Raw(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate raw list of bad IP addresses. diff --git a/src/handler.py b/src/handler.py index 8214dab..3f895b1 100644 --- a/src/handler.py +++ b/src/handler.py @@ -11,7 +11,7 @@ import json import os from database import get_database -from config import Config,get_config +from config import Config, get_config from firewall.fwtype import FWType # imports for the __init_subclass__ method, do not remove pls @@ -100,7 +100,6 @@ class Handler(BaseHTTPRequestHandler): error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) - def _handle_sql_endpoint(self, path: str) -> bool: """ Handle SQL injection honeypot endpoints. @@ -245,7 +244,6 @@ class Handler(BaseHTTPRequestHandler): user_agent = self.headers.get("User-Agent", "") post_data = "" - base_path = urlparse(self.path).path if base_path in ["/api/search", "/api/sql", "/api/database"]: @@ -557,7 +555,11 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) + self.wfile.write( + generate_dashboard( + stats, self.config.dashboard_secret_path + ).encode() + ) except BrokenPipeError: pass except Exception as e: @@ -604,7 +606,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - page = int(query_params.get("page", ["1"])[0]) page_size = int(query_params.get("page_size", ["25"])[0]) sort_by = query_params.get("sort_by", ["total_requests"])[0] @@ -643,7 +644,6 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - # Parse query parameters parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) @@ -807,7 +807,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, -pathsort_by=sort_by, + pathsort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -937,12 +937,12 @@ pathsort_by=sort_by, # API endpoint for downloading malicious IPs blocklist file if ( - self.config.dashboard_secret_path and - request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + self.config.dashboard_secret_path + and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): # get fwtype from request params - fwtype = query_params.get("fwtype",["iptables"])[0] + fwtype = query_params.get("fwtype", ["iptables"])[0] # Query distinct suspicious IPs results = ( @@ -962,7 +962,10 @@ pathsort_by=sort_by, self.send_response(200) self.send_header("Content-type", "text/plain") - self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header( + "Content-Disposition", + f'attachment; filename="{fwtype}.txt"', + ) self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) From b7e26f1735e0f58754fae021077a393c83411861 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 00:09:27 +0100 Subject: [PATCH 16/31] added api endpoint to list public malicious ips --- src/handler.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/handler.py b/src/handler.py index 3f895b1..89fa796 100644 --- a/src/handler.py +++ b/src/handler.py @@ -18,6 +18,9 @@ from firewall.fwtype import FWType from firewall.iptables import Iptables from firewall.raw import Raw + +from database import get_database +from config import Config,get_config from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -560,6 +563,7 @@ class Handler(BaseHTTPRequestHandler): stats, self.config.dashboard_secret_path ).encode() ) + self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) except BrokenPipeError: pass except Exception as e: @@ -944,6 +948,12 @@ class Handler(BaseHTTPRequestHandler): # get fwtype from request params fwtype = query_params.get("fwtype", ["iptables"])[0] + self.config.dashboard_secret_path and + request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + ): + + + fwtype = query_params.get("fwtype",["iptables"])[0] # Query distinct suspicious IPs results = ( session.query(distinct(AccessLog.ip)) @@ -969,6 +979,9 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) + + public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] + self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) return # API endpoint for downloading malicious IPs file From 95dfa53e18d74c5516bf1bf49da6878323257e53 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:41:41 +0100 Subject: [PATCH 17/31] added firewall strategy pattern --- src/firewall/fwtype.py | 2 +- src/firewall/iptables.py | 9 ++++++--- src/firewall/raw.py | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py index 0e0e421..8617b4d 100644 --- a/src/firewall/fwtype.py +++ b/src/firewall/fwtype.py @@ -14,7 +14,7 @@ class FWType(ABC): cls._registry[cls.__name__.lower()] = cls @classmethod - def create(cls, fw_type: str, **kwargs) -> "FWType": + def create(cls, fw_type: str, **kwargs) -> 'FWType': """ Factory method to create instances of child classes. diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py index 159171e..73ac623 100644 --- a/src/firewall/iptables.py +++ b/src/firewall/iptables.py @@ -1,11 +1,10 @@ from typing_extensions import override from firewall.fwtype import FWType - class Iptables(FWType): @override - def getBanlist(self, ips) -> str: + def getBanlist(self,ips) -> str: """ Generate iptables ban rules from an array of IP addresses. @@ -30,7 +29,11 @@ class Iptables(FWType): ip = ip.strip() # Build the iptables command - rule_parts = ["iptables", "-A", chain, "-s", ip] + rule_parts = [ + "iptables", + "-A", chain, + "-s", ip + ] # Add target rule_parts.extend(["-j", target]) diff --git a/src/firewall/raw.py b/src/firewall/raw.py index e0c82fe..1e29e75 100644 --- a/src/firewall/raw.py +++ b/src/firewall/raw.py @@ -1,11 +1,10 @@ from typing_extensions import override from firewall.fwtype import FWType - class Raw(FWType): @override - def getBanlist(self, ips) -> str: + def getBanlist(self,ips) -> str: """ Generate raw list of bad IP addresses. From 9306fe641e54170d0f335125b704a92bb5be15d1 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:42:07 +0100 Subject: [PATCH 18/31] added endpoint for blocklist download api --- src/handler.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/handler.py b/src/handler.py index 89fa796..f780835 100644 --- a/src/handler.py +++ b/src/handler.py @@ -21,6 +21,12 @@ from firewall.raw import Raw from database import get_database from config import Config,get_config +from firewall.fwtype import FWType + +# imports for the __init_subclass__ method, do not remove pls +from firewall.iptables import Iptables +from firewall.raw import Raw + from tracker import AccessTracker from analyzer import Analyzer from templates import html_templates @@ -952,8 +958,9 @@ class Handler(BaseHTTPRequestHandler): request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): - + # get fwtype from request params fwtype = query_params.get("fwtype",["iptables"])[0] + # Query distinct suspicious IPs results = ( session.query(distinct(AccessLog.ip)) @@ -972,16 +979,10 @@ class Handler(BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-type", "text/plain") - self.send_header( - "Content-Disposition", - f'attachment; filename="{fwtype}.txt"', - ) + self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) - - public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] - self.wfile.write(f"asdasdd {fwtype} {public_ips}".encode()) return # API endpoint for downloading malicious IPs file From 831497f80ac45330f955418053d617f580578c0e Mon Sep 17 00:00:00 2001 From: carnivuth Date: Tue, 27 Jan 2026 17:53:11 +0100 Subject: [PATCH 19/31] linted code --- src/firewall/fwtype.py | 2 +- src/firewall/iptables.py | 9 +++------ src/firewall/raw.py | 3 ++- src/handler.py | 20 +++++--------------- 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/firewall/fwtype.py b/src/firewall/fwtype.py index 8617b4d..0e0e421 100644 --- a/src/firewall/fwtype.py +++ b/src/firewall/fwtype.py @@ -14,7 +14,7 @@ class FWType(ABC): cls._registry[cls.__name__.lower()] = cls @classmethod - def create(cls, fw_type: str, **kwargs) -> 'FWType': + def create(cls, fw_type: str, **kwargs) -> "FWType": """ Factory method to create instances of child classes. diff --git a/src/firewall/iptables.py b/src/firewall/iptables.py index 73ac623..159171e 100644 --- a/src/firewall/iptables.py +++ b/src/firewall/iptables.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Iptables(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate iptables ban rules from an array of IP addresses. @@ -29,11 +30,7 @@ class Iptables(FWType): ip = ip.strip() # Build the iptables command - rule_parts = [ - "iptables", - "-A", chain, - "-s", ip - ] + rule_parts = ["iptables", "-A", chain, "-s", ip] # Add target rule_parts.extend(["-j", target]) diff --git a/src/firewall/raw.py b/src/firewall/raw.py index 1e29e75..e0c82fe 100644 --- a/src/firewall/raw.py +++ b/src/firewall/raw.py @@ -1,10 +1,11 @@ from typing_extensions import override from firewall.fwtype import FWType + class Raw(FWType): @override - def getBanlist(self,ips) -> str: + def getBanlist(self, ips) -> str: """ Generate raw list of bad IP addresses. diff --git a/src/handler.py b/src/handler.py index f780835..07c31b5 100644 --- a/src/handler.py +++ b/src/handler.py @@ -12,12 +12,6 @@ import os from database import get_database from config import Config, get_config -from firewall.fwtype import FWType - -# imports for the __init_subclass__ method, do not remove pls -from firewall.iptables import Iptables -from firewall.raw import Raw - from database import get_database from config import Config,get_config @@ -947,19 +941,12 @@ class Handler(BaseHTTPRequestHandler): # API endpoint for downloading malicious IPs blocklist file if ( - self.config.dashboard_secret_path - and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" - ): - - # get fwtype from request params - fwtype = query_params.get("fwtype", ["iptables"])[0] - self.config.dashboard_secret_path and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): # get fwtype from request params - fwtype = query_params.get("fwtype",["iptables"])[0] + fwtype = query_params.get("fwtype", ["iptables"])[0] # Query distinct suspicious IPs results = ( @@ -979,7 +966,10 @@ class Handler(BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-type", "text/plain") - self.send_header("Content-Disposition", f'attachment; filename="{fwtype}.txt"',) + self.send_header( + "Content-Disposition", + f'attachment; filename="{fwtype}.txt"', + ) self.send_header("Content-Length", str(len(banlist))) self.end_headers() self.wfile.write(banlist.encode()) From 5b9414259995e19fe598e47ba9b87b46a4a27ee0 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Wed, 28 Jan 2026 13:27:37 +0100 Subject: [PATCH 20/31] linted code --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index 99ab54f..0882188 100644 --- a/src/config.py +++ b/src/config.py @@ -187,7 +187,7 @@ class Config: canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, probability_error_codes=behavior.get("probability_error_codes", 0), - exports_path = exports.get("path"), + exports_path=exports.get("path"), database_path=database.get("path", "data/krawl.db"), database_retention_days=database.get("retention_days", 30), http_risky_methods_threshold=analyzer.get( From 845dd26bf43c9ec9ecfed95eacf529fb01d919a4 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Fri, 30 Jan 2026 16:11:24 +0100 Subject: [PATCH 21/31] added tmux panel configuration --- .tmux.conf | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .tmux.conf diff --git a/.tmux.conf b/.tmux.conf new file mode 100644 index 0000000..701ea12 --- /dev/null +++ b/.tmux.conf @@ -0,0 +1,6 @@ +splitw -v -p 10 +neww -n worker +select-window -t 1 +select-pane -t 0 +send-keys -t 0 "nvim" C-m +send-keys -t 1 "docker compose watch" C-m From 2118396dacef17f0bd758de984873ca544906bd6 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Fri, 30 Jan 2026 16:12:23 +0100 Subject: [PATCH 22/31] changed data mount to work with test scripts --- docker-compose.yaml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 233692b..44b534d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,5 @@ --- +# THIS IS FOR DEVELOPMENT PURPOSES services: krawl: build: @@ -16,17 +17,13 @@ services: - ./config.yaml:/app/config.yaml:ro - ./logs:/app/logs - ./exports:/app/exports - - data:/app/data + - ./data:/app/data restart: unless-stopped develop: watch: - path: ./Dockerfile action: rebuild - path: ./src/ - action: sync+restart - target: /app/src + action: rebuild - path: ./docker-compose.yaml action: rebuild - -volumes: - data: From 09b986f1b0ed196b86325025c1f23cb5b941599b Mon Sep 17 00:00:00 2001 From: carnivuth Date: Fri, 30 Jan 2026 16:13:45 +0100 Subject: [PATCH 23/31] changed workflow from live computation to scheduled job to support previous structure --- src/handler.py | 62 ++++++++++++++++++---------------- src/tasks/top_attacking_ips.py | 26 +++++++++----- 2 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/handler.py b/src/handler.py index 07c31b5..feb32b4 100644 --- a/src/handler.py +++ b/src/handler.py @@ -14,7 +14,7 @@ from database import get_database from config import Config, get_config from database import get_database -from config import Config,get_config +from config import Config, get_config from firewall.fwtype import FWType # imports for the __init_subclass__ method, do not remove pls @@ -512,7 +512,6 @@ class Handler(BaseHTTPRequestHandler): if self.config.dashboard_secret_path and self.path.startswith( f"{self.config.dashboard_secret_path}/static/" ): - import os file_path = self.path.replace( f"{self.config.dashboard_secret_path}/static/", "" @@ -563,7 +562,6 @@ class Handler(BaseHTTPRequestHandler): stats, self.config.dashboard_secret_path ).encode() ) - self.wfile.write(generate_dashboard(stats, self.config.dashboard_secret_path).encode()) except BrokenPipeError: pass except Exception as e: @@ -811,7 +809,7 @@ class Handler(BaseHTTPRequestHandler): result = db.get_top_ips_paginated( page=page, page_size=page_size, - pathsort_by=sort_by, + sort_by=sort_by, sort_order=sort_order, ) self.wfile.write(json.dumps(result).encode()) @@ -941,38 +939,42 @@ class Handler(BaseHTTPRequestHandler): # API endpoint for downloading malicious IPs blocklist file if ( - self.config.dashboard_secret_path and - request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" + self.config.dashboard_secret_path + and request_path == f"{self.config.dashboard_secret_path}/api/get_banlist" ): # get fwtype from request params fwtype = query_params.get("fwtype", ["iptables"])[0] - # Query distinct suspicious IPs - results = ( - session.query(distinct(AccessLog.ip)) - .filter(AccessLog.is_suspicious == True) - .all() + file_path = os.path.join( + os.path.dirname(__file__), "exports", f"{fwtype}.txt" ) - - # Filter out local/private IPs and the server's own IP - config = get_config() - server_ip = config.get_server_ip() - public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] - - # get specific fwtype based on query parameter - fwtype_parser = FWType.create(fwtype) - banlist = fwtype_parser.getBanlist(public_ips) - - self.send_response(200) - self.send_header("Content-type", "text/plain") - self.send_header( - "Content-Disposition", - f'attachment; filename="{fwtype}.txt"', - ) - self.send_header("Content-Length", str(len(banlist))) - self.end_headers() - self.wfile.write(banlist.encode()) + try: + if os.path.exists(file_path): + with open(file_path, "rb") as f: + content = f.read() + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.send_header( + "Content-Disposition", + f'attachment; filename="{fwtype}.txt"', + ) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + else: + self.send_response(404) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"File not found") + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error serving malicious IPs file: {e}") + self.send_response(500) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Internal server error") return # API endpoint for downloading malicious IPs file diff --git a/src/tasks/top_attacking_ips.py b/src/tasks/top_attacking_ips.py index c0cfbec..ebdf3aa 100644 --- a/src/tasks/top_attacking_ips.py +++ b/src/tasks/top_attacking_ips.py @@ -4,8 +4,12 @@ import os from logger import get_app_logger from database import get_database from config import get_config -from models import IpStats +from models import IpStats, AccessLog from ip_utils import is_valid_public_ip +from sqlalchemy import distinct +from firewall.fwtype import FWType +from firewall.iptables import Iptables +from firewall.raw import Raw app_logger = get_app_logger() @@ -61,14 +65,20 @@ def main(): os.makedirs(EXPORTS_DIR, exist_ok=True) # Write IPs to file (one per line) - with open(OUTPUT_FILE, "w") as f: - for ip in public_ips: - f.write(f"{ip}\n") + for fwname in FWType._registry: - app_logger.info( - f"[Background Task] {task_name} exported {len(public_ips)} attacker IPs " - f"(filtered {len(attackers) - len(public_ips)} local/private IPs) to {OUTPUT_FILE}" - ) + # get banlist for specific ip + fw = FWType.create(fwname) + banlist = fw.getBanlist(public_ips) + + output_file = os.path.join(EXPORTS_DIR, f"{fwname}.txt") + with open(output_file, "w") as f: + f.write(f"{banlist}\n") + + app_logger.info( + f"[Background Task] {task_name} exported {len(public_ips)} in {fwname} public IPs" + f"(filtered {len(attackers) - len(public_ips)} local/private IPs) to {output_file}" + ) except Exception as e: app_logger.error(f"[Background Task] {task_name} failed: {e}") From 35c8a900130751c95d4a3a02e54a3198c8f53ca1 Mon Sep 17 00:00:00 2001 From: carnivuth Date: Mon, 2 Feb 2026 14:19:40 +0100 Subject: [PATCH 24/31] addd exports path configuration variable and default values, restored old filename --- config.yaml | 5 ++++- src/config.py | 2 ++ src/tasks/top_attacking_ips.py | 10 +++++++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/config.yaml b/config.yaml index c29ebe4..40246db 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,9 @@ dashboard: # secret_path: super-secret-dashboard-path secret_path: test +exports: + path: "exports" + database: path: "data/krawl.db" retention_days: 30 @@ -43,4 +46,4 @@ analyzer: crawl: infinite_pages_for_malicious: true max_pages_limit: 250 - ban_duration_seconds: 600 \ No newline at end of file + ban_duration_seconds: 600 diff --git a/src/config.py b/src/config.py index 0882188..b98a750 100644 --- a/src/config.py +++ b/src/config.py @@ -37,6 +37,8 @@ class Config: infinite_pages_for_malicious: bool = True # Infinite pages for malicious crawlers ban_duration_seconds: int = 600 # Ban duration in seconds for IPs exceeding limits + # exporter settings + exports_path: str = "exports" # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 diff --git a/src/tasks/top_attacking_ips.py b/src/tasks/top_attacking_ips.py index ebdf3aa..69d417b 100644 --- a/src/tasks/top_attacking_ips.py +++ b/src/tasks/top_attacking_ips.py @@ -11,6 +11,7 @@ from firewall.fwtype import FWType from firewall.iptables import Iptables from firewall.raw import Raw +config = get_config() app_logger = get_app_logger() # ---------------------- @@ -24,7 +25,7 @@ TASK_CONFIG = { } EXPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "exports") -OUTPUT_FILE = os.path.join(EXPORTS_DIR, "malicious_ips.txt") +EXPORTS_DIR = config.exports_path # ---------------------- @@ -52,7 +53,6 @@ def main(): ) # Filter out local/private IPs and the server's own IP - config = get_config() server_ip = config.get_server_ip() public_ips = [ @@ -71,7 +71,11 @@ def main(): fw = FWType.create(fwname) banlist = fw.getBanlist(public_ips) - output_file = os.path.join(EXPORTS_DIR, f"{fwname}.txt") + output_file = os.path.join(EXPORTS_DIR, f"{fwname}_banlist.txt") + + if fwname == "raw": + output_file = os.path.join(EXPORTS_DIR, f"malicious_ips.txt") + with open(output_file, "w") as f: f.write(f"{banlist}\n") From ee468877612d37e6ce2d5904b7446dd56d65583f Mon Sep 17 00:00:00 2001 From: carnivuth Date: Mon, 2 Feb 2026 14:44:33 +0100 Subject: [PATCH 25/31] added documentation on firewall structure --- docs/firewall-exporters.md | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/firewall-exporters.md diff --git a/docs/firewall-exporters.md b/docs/firewall-exporters.md new file mode 100644 index 0000000..566cc57 --- /dev/null +++ b/docs/firewall-exporters.md @@ -0,0 +1,48 @@ +# Firewall exporters documentation + +Firewall export feature is implemented trough a strategy pattern with an abstract class and a series of subclasses that implement the specific export logic for each firewall specific system: + +```mermaid + classDiagram + class FWType{ + +getBanlist() +} +FWType <|-- Raw +class Raw{ } +FWType <|-- Iptables +class Iptables{ } +note for Iptables "implements the getBanlist method for iptables rules" +``` + +## Adding firewalls exporters + +To add a firewall exporter create a new python class in `src/firewall` that implements `FWType` class + +> example with `Yourfirewall` class in the `yourfirewall.py` file +```python +from typing_extensions import override +from firewall.fwtype import FWType + +class Yourfirewall(FWType): + + @override + def getBanlist(self, ips) -> str: + """ + Generate raw list of bad IP addresses. + + Args: + ips: List of IP addresses to ban + + Returns: + String containing raw ips, one per line + """ + if not ips: + return "" + # Add here code implementation +``` + +Then add the following to the `src/server.py` and `src/tasks/top_attacking_ips.py` + +```python +from firewall.yourfirewall import Yourfirewall +``` From 813c4b80d0865d6025280dc9b8bffc560949420d Mon Sep 17 00:00:00 2001 From: carnivuth Date: Mon, 2 Feb 2026 14:54:36 +0100 Subject: [PATCH 26/31] added configuration variable documentation and filename documentation --- README.md | 19 +++++++++++-------- docs/firewall-exporters.md | 2 ++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index aa1aee6..8e48631 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ services: - TZ="Europe/Rome" volumes: - ./config.yaml:/app/config.yaml:ro + # bind mount for firewall exporters + - ./exports:/app/exports - krawl-data:/app/data restart: unless-stopped @@ -204,6 +206,7 @@ Krawl uses a **configuration hierarchy** in which **environment variables take p | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | +| `KRAWL_EXPORTS_PATH` | Path where firewalls rule sets are exported | `exports` | | `KRAWL_DATABASE_RETENTION_DAYS` | Days to retain data in database | `30` | | `KRAWL_HTTP_RISKY_METHODS_THRESHOLD` | Threshold for risky HTTP methods detection | `0.1` | | `KRAWL_VIOLATED_ROBOTS_THRESHOLD` | Threshold for robots.txt violations | `0.1` | @@ -219,7 +222,7 @@ For example ```bash # Set canary token -export CONFIG_LOCATION="config.yaml" +export CONFIG_LOCATION="config.yaml" export KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" # Set number of pages range (min,max format) @@ -252,7 +255,7 @@ You can use the [config.yaml](config.yaml) file for more advanced configurations Below is a complete overview of the Krawl honeypot’s capabilities ## robots.txt -The actual (juicy) robots.txt configuration [is the following](src/templates/html/robots.txt). +The actual (juicy) robots.txt configuration [is the following](src/templates/html/robots.txt). ## Honeypot pages Requests to common admin endpoints (`/admin/`, `/wp-admin/`, `/phpMyAdmin/`) return a fake login page. Any login attempt triggers a 1-second delay to simulate real processing and is fully logged in the dashboard (credentials, IP, headers, timing). @@ -274,11 +277,11 @@ The pages `/api/v1/users` and `/api/v2/secrets` show fake users and random secre ![users and secrets](img/users-and-secrets.png) -The pages `/credentials.txt` and `/passwords.txt` show fake users and random secrets +The pages `/credentials.txt` and `/passwords.txt` show fake users and random secrets ![credentials and passwords](img/credentials-and-passwords.png) -Pages such as `/users`, `/search`, `/contact`, `/info`, `/input`, and `/feedback`, along with APIs like `/api/sql` and `/api/database`, are designed to lure attackers into performing attacks such as **SQL injection** or **XSS**. +Pages such as `/users`, `/search`, `/contact`, `/info`, `/input`, and `/feedback`, along with APIs like `/api/sql` and `/api/database`, are designed to lure attackers into performing attacks such as **SQL injection** or **XSS**. ![sql injection](img/sql_injection.png) @@ -294,7 +297,7 @@ This optional token is triggered when a crawler fully traverses the webpage unti To enable this feature, set the canary token URL [using the environment variable](#configuration-via-environment-variables) `CANARY_TOKEN_URL`. -## Customizing the wordlist +## Customizing the wordlist Edit `wordlists.json` to customize fake data for your use case @@ -327,7 +330,7 @@ The dashboard shows: - Top IPs, paths, user-agents and GeoIP localization - Real-time monitoring -The attackers’ access to the honeypot endpoint and related suspicious activities (such as failed login attempts) are logged. +The attackers’ access to the honeypot endpoint and related suspicious activities (such as failed login attempts) are logged. Krawl also implements a scoring system designed to distinguish between malicious and legitimate behavior on the website. @@ -352,8 +355,8 @@ Contributions welcome! Please: ## ⚠️ Disclaimer -**This is a deception/honeypot system.** -Deploy in isolated environments and monitor carefully for security events. +**This is a deception/honeypot system.** +Deploy in isolated environments and monitor carefully for security events. Use responsibly and in compliance with applicable laws and regulations. ## Star History diff --git a/docs/firewall-exporters.md b/docs/firewall-exporters.md index 566cc57..85d88cd 100644 --- a/docs/firewall-exporters.md +++ b/docs/firewall-exporters.md @@ -14,6 +14,8 @@ class Iptables{ } note for Iptables "implements the getBanlist method for iptables rules" ``` +Rule sets are generated trough the `top_attacking_ips__export-malicious-ips` that writes down the files in the `exports_path` configuration path. Files are named after the specific firewall that they implement as `[firewall]_banlist.txt` except for raw file that is called `malicious_ips.txt` to support legacy + ## Adding firewalls exporters To add a firewall exporter create a new python class in `src/firewall` that implements `FWType` class From c0a60811c5e1fd024cdf4f1742053b4706df15ac Mon Sep 17 00:00:00 2001 From: carnivuth Date: Mon, 2 Feb 2026 22:06:36 +0100 Subject: [PATCH 27/31] fixed filename after naming refactor --- src/handler.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/handler.py b/src/handler.py index feb32b4..6fa158d 100644 --- a/src/handler.py +++ b/src/handler.py @@ -13,11 +13,9 @@ import os from database import get_database from config import Config, get_config -from database import get_database -from config import Config, get_config -from firewall.fwtype import FWType # imports for the __init_subclass__ method, do not remove pls +from firewall.fwtype import FWType from firewall.iptables import Iptables from firewall.raw import Raw @@ -945,10 +943,14 @@ class Handler(BaseHTTPRequestHandler): # get fwtype from request params fwtype = query_params.get("fwtype", ["iptables"])[0] + filename = f"{fwtype}_banlist.txt" + if fwtype == "raw": + filename = f"malicious_ips.txt" file_path = os.path.join( - os.path.dirname(__file__), "exports", f"{fwtype}.txt" + self.config.exports_path, f"{filename}" ) + try: if os.path.exists(file_path): with open(file_path, "rb") as f: @@ -957,7 +959,7 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-type", "text/plain") self.send_header( "Content-Disposition", - f'attachment; filename="{fwtype}.txt"', + f'attachment; filename="{filename}"', ) self.send_header("Content-Length", str(len(content))) self.end_headers() From a33e03563f639cb1897897697f79a718646a3ada Mon Sep 17 00:00:00 2001 From: carnivuth Date: Mon, 2 Feb 2026 22:06:53 +0100 Subject: [PATCH 28/31] linted code --- src/handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/handler.py b/src/handler.py index 6fa158d..e6da601 100644 --- a/src/handler.py +++ b/src/handler.py @@ -13,7 +13,6 @@ import os from database import get_database from config import Config, get_config - # imports for the __init_subclass__ method, do not remove pls from firewall.fwtype import FWType from firewall.iptables import Iptables @@ -947,9 +946,7 @@ class Handler(BaseHTTPRequestHandler): if fwtype == "raw": filename = f"malicious_ips.txt" - file_path = os.path.join( - self.config.exports_path, f"{filename}" - ) + file_path = os.path.join(self.config.exports_path, f"{filename}") try: if os.path.exists(file_path): From 3c8fa703ebbb86c71996564292f1bf15cc398045 Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Mon, 2 Feb 2026 22:29:34 +0100 Subject: [PATCH 29/31] added button style --- src/templates/dashboard_template.py | 121 +++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 9 deletions(-) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index b8db40c..6971f31 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -11,8 +11,6 @@ from zoneinfo import ZoneInfo # imports for the __init_subclass__ method, do not remove pls from firewall import fwtype -from firewall.iptables import Iptables -from firewall.raw import Raw def _escape(value) -> str: @@ -142,6 +140,68 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: .download-btn:active {{ background: #1f7a2f; }} + .banlist-dropdown {{ + position: relative; + display: inline-block; + width: 100%; + }} + .banlist-dropdown-btn {{ + display: block; + width: 100%; + padding: 8px 14px; + background: #238636; + color: #ffffff; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + font-size: 13px; + transition: background 0.2s; + border: 1px solid #2ea043; + cursor: pointer; + text-align: left; + box-sizing: border-box; + }} + .banlist-dropdown-btn:hover {{ + background: #2ea043; + }} + .banlist-dropdown-menu {{ + display: none; + position: absolute; + right: 0; + left: 0; + background-color: #161b22; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.3); + z-index: 1; + border: 1px solid #30363d; + border-radius: 6px; + margin-top: 4px; + overflow: hidden; + }} + .banlist-dropdown-menu.show {{ + display: block; + }} + .banlist-dropdown-menu a {{ + color: #c9d1d9; + padding: 6px 12px; + text-decoration: none; + display: flex; + align-items: center; + gap: 6px; + transition: background 0.2s; + font-size: 12px; + }} + .banlist-dropdown-menu a:hover {{ + background-color: #1c2128; + color: #58a6ff; + }} + .banlist-dropdown-menu a.disabled {{ + color: #6e7681; + cursor: not-allowed; + pointer-events: none; + }} + .banlist-icon {{ + font-size: 14px; + }} .stats-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); @@ -658,13 +718,19 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: BlessedRebuS/Krawl -
- - -
+
+
+ + +
+

Krawl Dashboard

@@ -952,6 +1018,43 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: