""" Web Application Server for Bjorn Handles HTTP requests with optional authentication, gzip compression, and routing. OPTIMIZED FOR PI ZERO 2: Timeouts, Daemon Threads, Memory Protection, and Log Filtering. """ import gzip import http.server import io import json import logging import os import signal import socket import socketserver import sys import threading import time import urllib.parse from http import cookies from urllib.parse import unquote from init_shared import shared_data from logger import Logger from utils import WebUtils # ============================================================================ # INITIALIZATION # ============================================================================ logger = Logger(name="webapp.py", level=logging.DEBUG) favicon_path = os.path.join(shared_data.web_dir, '/images/favicon.ico') # Security limit to prevent RAM saturation on Pi Zero 2 MAX_POST_SIZE = 5 * 1024 * 1024 # 5 MB max # ============================================================================ # REQUEST HANDLER # ============================================================================ # Global WebUtils instance to prevent re-initialization per request web_utils_instance = WebUtils(shared_data) class CustomHandler(http.server.SimpleHTTPRequestHandler): """ Custom HTTP request handler with authentication, compression, and routing. Refactored to use dynamic routing maps and Pi Zero optimizations. """ # Routes built ONCE at class level (shared across all requests — saves RAM) _routes_initialized = False GET_ROUTES = {} POST_ROUTES_JSON = {} # handlers that take (data) only POST_ROUTES_JSON_H = {} # handlers that take (handler, data) — need the request handler POST_ROUTES_MULTIPART = {} def __init__(self, *args, **kwargs): self.shared_data = shared_data self.web_utils = web_utils_instance if not CustomHandler._routes_initialized: CustomHandler._register_routes_once() super().__init__(*args, **kwargs) @classmethod def _register_routes_once(cls): """Register all API routes ONCE at class level. Never per-request.""" if cls._routes_initialized: return wu = web_utils_instance debug_enabled = bool(shared_data.config.get("bjorn_debug_enabled", False)) # --- GET ROUTES --- # All GET handlers receive (handler) at call time via do_GET dispatch cls.GET_ROUTES = { # INDEX / DASHBOARD '/api/bjorn/stats': wu.index_utils.dashboard_stats, '/apple-touch-icon': wu.index_utils.serve_apple_touch_icon, '/favicon.ico': wu.index_utils.serve_favicon, '/manifest.json': wu.index_utils.serve_manifest, # C2 '/c2/agents': wu.c2.c2_agents, '/c2/events': wu.c2.c2_events_sse, '/c2/list_clients': wu.c2.c2_list_clients, '/c2/status': wu.c2.c2_status, # WEBENUM (handled via startswith) # NETWORK '/get_known_wifi': wu.network_utils.get_known_wifi, '/scan_wifi': wu.network_utils.scan_wifi, '/get_web_delay': '_serve_web_delay', # FILE '/list_directories': wu.file_utils.list_directories, '/loot_directories': wu.file_utils.loot_directories, # BACKUP '/check_update': wu.backup_utils.check_update, # SYSTEM '/bjorn_status': wu.system_utils.serve_bjorn_status, '/load_config': wu.system_utils.serve_current_config, '/get_logs': wu.system_utils.serve_logs, '/stream_logs': wu.system_utils.sse_log_stream, '/check_console_autostart': wu.system_utils.check_console_autostart, '/check_manual_mode': wu.system_utils.check_manual_mode, '/restore_default_config': wu.system_utils.restore_default_config, # BLUETOOTH '/scan_bluetooth': wu.bluetooth_utils.scan_bluetooth, '/get_sections': wu.action_utils.get_sections, # SCRIPTS '/get_running_scripts': '_serve_running_scripts', '/list_scripts': '_serve_list_scripts', '/get_action_args_schema': '_serve_action_args_schema', # ACTION / IMAGES / STUDIO '/get_actions': wu.action_utils.get_actions, '/list_static_images': wu.action_utils.list_static_images_with_dimensions, '/list_web_images': wu.action_utils.list_web_images_with_dimensions, '/list_actions_icons': wu.action_utils.list_actions_icons_with_dimensions, '/list_characters': wu.action_utils.list_characters, '/bjorn_say': getattr(wu.action_utils, 'serve_bjorn_say', None), '/api/vulns/fix': wu.vuln_utils.fix_vulns_data, '/api/vulns/stats': wu.vuln_utils.serve_vulns_stats, '/api/feeds/status': wu.vuln_utils.serve_feed_status, '/api/studio/actions_db': wu.studio_utils.studio_get_actions_db, '/api/studio/actions_studio': wu.studio_utils.studio_get_actions_studio, '/api/studio/edges': wu.studio_utils.studio_get_edges, # DB & NETKB '/api/db/catalog': wu.db_utils.db_catalog_endpoint, '/api/db/export_all': wu.db_utils.db_export_all_endpoint, '/api/db/tables': wu.db_utils.db_list_tables_endpoint, '/netkb_data': wu.netkb_utils.serve_netkb_data, '/netkb_data_json': wu.netkb_utils.serve_netkb_data_json, '/network_data': wu.netkb_utils.serve_network_data, '/list_credentials': wu.orchestrator_utils.serve_credentials_data, # AI / RL '/api/rl/stats': wu.rl.get_stats, '/api/rl/history': wu.rl.get_training_history, '/api/rl/experiences': wu.rl.get_recent_experiences, } if debug_enabled: cls.GET_ROUTES.update({ '/api/debug/snapshot': wu.debug_utils.get_snapshot, '/api/debug/history': wu.debug_utils.get_history, '/api/debug/gc': wu.debug_utils.get_gc_stats, }) # --- POST ROUTES (MULTIPART) --- cls.POST_ROUTES_MULTIPART = { '/action/create': wu.action_utils.create_action, '/add_attack': wu.action_utils.add_attack, '/replace_image': wu.action_utils.replace_image, '/resize_images': wu.action_utils.resize_images, '/restore_default_images': wu.action_utils.restore_default_images, '/delete_images': wu.action_utils.delete_images, '/upload_static_image': wu.action_utils.upload_static_image, '/upload_status_icon': wu.action_utils.upload_status_image, '/upload_status_image': wu.action_utils.upload_status_image, '/upload_character_images': wu.action_utils.upload_character_images, '/upload_web_image': wu.action_utils.upload_web_image, '/upload_actions_icon': wu.action_utils.upload_actions_icon, '/upload_files': wu.file_utils.handle_file_upload, '/upload_project': wu.script_utils.upload_project, '/upload_script': wu.script_utils.upload_script, '/clear_actions_file': wu.system_utils.clear_actions_file, '/clear_livestatus': wu.system_utils.clear_livestatus, '/clear_logs': wu.system_utils.clear_logs, '/clear_netkb': wu.system_utils.clear_netkb, '/erase_bjorn_memories': wu.system_utils.erase_bjorn_memories, '/create_preconfigured_file': wu.network_utils.create_preconfigured_file, '/delete_preconfigured_file': wu.network_utils.delete_preconfigured_file, '/clear_shared_config_json': wu.index_utils.clear_shared_config_json, '/reload_generate_actions_json': wu.index_utils.reload_generate_actions_json, } # --- POST ROUTES (JSON) — data-only handlers: fn(data) --- cls.POST_ROUTES_JSON = { # WEBENUM # NETWORK '/connect_known_wifi': lambda d: (wu.network_utils.connect_known_wifi(d), setattr(shared_data, 'wifichanged', True))[0], '/connect_wifi': lambda d: (wu.network_utils.connect_wifi(d), setattr(shared_data, 'wifichanged', True))[0], '/delete_known_wifi': wu.network_utils.delete_known_wifi, '/update_wifi_priority': wu.network_utils.update_wifi_priority, '/import_potfiles': wu.network_utils.import_potfiles, # FILE '/create_folder': wu.file_utils.create_folder, '/delete_file': wu.file_utils.delete_file, '/duplicate_file': wu.file_utils.duplicate_file, '/move_file': wu.file_utils.move_file, '/rename_file': wu.file_utils.rename_file, '/clear_output_folder': wu.file_utils.clear_output_folder, # BACKUP '/create_backup': wu.backup_utils.create_backup, '/delete_backup': wu.backup_utils.delete_backup, '/list_backups': wu.backup_utils.list_backups, '/restore_backup': wu.backup_utils.restore_backup, '/set_default_backup': wu.backup_utils.set_default_backup, '/update_application': wu.backup_utils.update_application, # SYSTEM '/save_config': wu.system_utils.save_configuration, # BLUETOOTH '/connect_bluetooth': lambda d: wu.bluetooth_utils.connect_bluetooth(d.get('address')), '/disconnect_bluetooth': lambda d: wu.bluetooth_utils.disconnect_bluetooth(d.get('address')), '/forget_bluetooth': lambda d: wu.bluetooth_utils.forget_bluetooth(d.get('address')), '/pair_bluetooth': lambda d: wu.bluetooth_utils.pair_bluetooth(d.get('address'), d.get('pin')), '/trust_bluetooth': lambda d: wu.bluetooth_utils.trust_bluetooth(d.get('address')), # SCRIPTS '/clear_script_output': wu.script_utils.clear_script_output, '/delete_script': wu.script_utils.delete_script, '/export_script_logs': wu.script_utils.export_script_logs, '/get_script_output': wu.script_utils.get_script_output, '/run_script': wu.script_utils.run_script, '/stop_script': wu.script_utils.stop_script, # CHARACTERS '/reload_fonts': getattr(wu.action_utils, 'reload_fonts', None), '/reload_images': getattr(wu.action_utils, 'reload_images', None), # COMMENTS '/delete_comment_section': wu.action_utils.delete_comment_section, '/restore_default_comments': wu.action_utils.restore_default_comments, '/save_comments': wu.action_utils.save_comments, # ATTACKS # STUDIO '/api/studio/action/replace': lambda d: wu.studio_utils.studio_replace_actions_with_db(), '/api/studio/action/update': wu.studio_utils.studio_update_action, '/api/studio/actions/sync': lambda d: wu.studio_utils.studio_sync_actions_studio(), '/api/studio/apply': lambda d: wu.studio_utils.studio_apply_to_runtime(), '/api/studio/edge/delete': wu.studio_utils.studio_delete_edge, '/api/studio/edge/upsert': wu.studio_utils.studio_upsert_edge, '/api/studio/host': wu.studio_utils.studio_upsert_host_flat, '/api/studio/host/delete': wu.studio_utils.studio_delete_host, '/api/studio/save': wu.studio_utils.studio_save_bundle, # ACTION # NETKB '/delete_all_actions': wu.netkb_utils.delete_all_actions, '/delete_netkb_action': wu.netkb_utils.delete_netkb_action, # ORCHESTRATOR '/manual_attack': wu.orchestrator_utils.execute_manual_attack, '/manual_scan': lambda d: wu.orchestrator_utils.execute_manual_scan(), '/start_orchestrator': lambda _: wu.orchestrator_utils.start_orchestrator(), '/stop_orchestrator': lambda _: wu.orchestrator_utils.stop_orchestrator(), } if debug_enabled: cls.POST_ROUTES_JSON.update({ '/api/debug/tracemalloc': wu.debug_utils.toggle_tracemalloc, '/api/debug/gc/collect': wu.debug_utils.force_gc, }) # --- POST ROUTES (JSON) — handler-aware: fn(handler, data) --- # These need the per-request handler instance (for send_response etc.) cls.POST_ROUTES_JSON_H = { '/api/bjorn/config': lambda h, d: wu.index_utils.set_config(h, d), '/api/bjorn/vulns/baseline': lambda h, _: wu.index_utils.mark_vuln_scan_baseline(h), '/api/rl/config': lambda h, d: wu.rl.set_mode(h, d), '/api/webenum/import': lambda h, d: wu.webenum_utils.import_webenum_results(h, d), # C2 '/c2/broadcast': lambda h, d: wu.c2.c2_broadcast(h, d), '/c2/command': lambda h, d: wu.c2.c2_command(h, d), '/c2/deploy': lambda h, d: wu.c2.c2_deploy(h, d), '/c2/generate_client': lambda h, d: wu.c2.c2_generate_client(h, d), '/c2/purge_agents': lambda h, d: wu.c2.c2_purge_agents(h, d), '/c2/remove_client': lambda h, d: wu.c2.c2_remove_client(h, d), '/c2/start': lambda h, d: wu.c2.c2_start(h, d), '/c2/stop': lambda h, d: wu.c2.c2_stop(h, d), # SYSTEM (need handler for response) '/restart_bjorn_service': lambda h, _: wu.system_utils.restart_bjorn_service(h), '/reboot_system': lambda h, _: wu.system_utils.reboot_system(h), '/shutdown_system': lambda h, _: wu.system_utils.shutdown_system(h), '/initialize_csv': lambda h, _: wu.system_utils.initialize_db(h), '/restore_default_config': lambda h, _: wu.system_utils.restore_default_config(h), # VULN '/api/cve/bulk': lambda h, d: (wu.vuln_utils.serve_cve_bulk(h, d) or {"status": "ok"}), '/api/cve/bulk_exploits': lambda h, d: wu.vuln_utils.serve_cve_bulk(h, d), # legacy alias '/api/feeds/sync': lambda h, _: wu.vuln_utils.serve_feed_sync(h), # DB (need handler for response) '/api/db/add_column': lambda h, d: wu.db_utils.db_add_column_endpoint(h, d), '/api/db/create_table': lambda h, d: wu.db_utils.db_create_table_endpoint(h, d), '/api/db/delete': lambda h, d: wu.db_utils.db_delete_rows_endpoint(h, d), '/api/db/insert': lambda h, d: wu.db_utils.db_insert_row_endpoint(h, d), '/api/db/rename_table': lambda h, d: wu.db_utils.db_rename_table_endpoint(h, d), '/api/db/update': lambda h, d: wu.db_utils.db_update_cells_endpoint(h, d), '/api/db/vacuum': lambda h, _: wu.db_utils.db_vacuum_endpoint(h), # ACTION '/create_character': lambda h, d: wu.action_utils.create_character(h, d), '/switch_character': lambda h, d: wu.action_utils.switch_character(h, d), '/delete_character': lambda h, d: wu.action_utils.delete_character(h, d), '/rename_image': lambda h, d: wu.action_utils.rename_image(h, d), '/remove_attack': lambda h, d: wu.action_utils.remove_attack(h, d), '/restore_attack': lambda h, d: wu.action_utils.restore_attack(h, d), '/save_attack': lambda h, d: wu.action_utils.save_attack(h, d), '/action/delete': lambda h, d: wu.action_utils.delete_action(h, d), '/actions/restore_defaults': lambda h, _: wu.action_utils.restore_defaults(h), '/actions/set_enabled': lambda h, d: wu.action_utils.set_action_enabled(h, d), # Legacy aliases 'reboot': lambda h, _: wu.system_utils.reboot_system(h), 'shutdown': lambda h, _: wu.system_utils.shutdown_system(h), } cls._routes_initialized = True if debug_enabled: logger.info("Routes registered (once). Bjorn Debug API enabled.") else: logger.info("Routes registered (once). Bjorn Debug API disabled.") # ------------------------------------------------------------------------ # HELPER HANDLERS # ------------------------------------------------------------------------ def _serve_web_delay(self, handler): self._send_json({"web_delay": self.shared_data.web_delay}) def _serve_running_scripts(self, handler): self._send_json(self.web_utils.script_utils.get_running_scripts()) def _serve_list_scripts(self, handler): self._send_json(self.web_utils.script_utils.list_scripts()) def _serve_action_args_schema(self, handler): from urllib.parse import parse_qs, urlparse query = parse_qs(urlparse(self.path).query) action_name = query.get('action_name', [''])[0] self._send_json(self.web_utils.script_utils.get_action_args_schema({"action_name": action_name})) def _send_json(self, data, status=200): self.send_response(status) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps(data).encode('utf-8')) # ... [Authentication helpers] ... def delete_cookie(self, key, path='/'): self.set_cookie(key, '', path=path, max_age=0) def get_cookie(self, key): if "Cookie" in self.headers: cookie = cookies.SimpleCookie(self.headers["Cookie"]) if key in cookie: return cookie[key].value return None def is_authenticated(self): if not self.shared_data.webauth: return True return self.get_cookie('authenticated') == '1' def set_cookie(self, key, value, path='/', max_age=None): cookie = cookies.SimpleCookie() cookie[key] = value cookie[key]['path'] = path if max_age is not None: cookie[key]['max-age'] = max_age self.send_header('Set-Cookie', cookie.output(header='', sep='')) # ... [Compression helpers] ... def gzip_encode(self, content): out = io.BytesIO() with gzip.GzipFile(fileobj=out, mode="w") as f: f.write(content) return out.getvalue() def send_gzipped_response(self, content, content_type): gzipped_content = self.gzip_encode(content) self.send_response(200) self.send_header("Content-type", content_type) self.send_header("Content-Encoding", "gzip") self.send_header("Content-Length", str(len(gzipped_content))) self.end_headers() self.wfile.write(gzipped_content) def serve_file_gzipped(self, file_path, content_type): if os.path.exists(file_path): with open(file_path, 'rb') as file: content = file.read() self.send_gzipped_response(content, content_type) else: self.send_error(404) # ... [Login/Logout handlers] ... def handle_login(self): if not self.shared_data.webauth: self.send_response(302); self.send_header('Location', '/'); self.end_headers(); return content_length = int(self.headers.get('Content-Length', 0)) # Protect against large POST payloads on login if content_length > MAX_POST_SIZE: self.send_error(413) return post_data = self.rfile.read(content_length).decode('utf-8') params = urllib.parse.parse_qs(post_data) username = params.get('username', [None])[0] password = params.get('password', [None])[0] try: with open(self.shared_data.webapp_json, 'r') as f: auth_config = json.load(f) expected_user = auth_config['username'] expected_pass = auth_config['password'] except Exception as e: logger.error(f"Error loading webapp.json: {e}") self.send_error(500) return if username == expected_user and password == expected_pass: always_auth = params.get('alwaysAuth', [None])[0] == 'on' try: with open(self.shared_data.webapp_json, 'r+') as f: config = json.load(f) config['always_require_auth'] = always_auth f.seek(0) json.dump(config, f, indent=4) f.truncate() except Exception as e: logger.error(f"Error saving auth preference: {e}") if not always_auth: self.set_cookie('authenticated', '1', max_age=30*24*60*60) else: self.set_cookie('authenticated', '1') self.send_response(302) self.send_header('Location', '/') self.end_headers() else: self.send_error(401, "Unauthorized") def handle_logout(self): if not self.shared_data.webauth: self.send_response(302); self.send_header('Location', '/'); self.end_headers(); return self.send_response(302) self.delete_cookie('authenticated') self.send_header('Location', '/login.html') self.end_headers() def serve_login_page(self): login_page_path = os.path.join(self.shared_data.web_dir, 'login.html') self.serve_file_gzipped(login_page_path, 'text/html') def log_message(self, format, *args): """ Intercepte et filtre les logs du serveur web. On supprime les requêtes répétitives qui polluent les logs. """ # [infinition] Check if web logging is enabled in config if not self.shared_data.config.get("web_logging_enabled", False): return msg = format % args # Liste des requêtes "bruyantes" à ne pas afficher dans les logs # Tu peux ajouter ici tout ce que tu veux masquer silent_routes = [ "/api/bjorn/stats", "/bjorn_status", "/bjorn_status_image", "/bjorn_character", "/bjorn_say", "/netkb_data", "/web/screen.png", "/action_queue", "/api/rl/stats", "/api/rl/config", "/api/rl/experiences", "/api/rl/history" "" ] # Si l'une des routes silencieuses est dans le message, on quitte la fonction sans rien écrire if any(route in msg for route in silent_routes): return # Pour tout le reste (erreurs, connexions, changements de config), on loggue normalement logger.info("%s - [%s] %s" % ( self.client_address[0], self.log_date_time_string(), msg )) # ------------------------------------------------------------------------ # DELETE REQUEST HANDLER # ------------------------------------------------------------------------ def do_DELETE(self): if self.shared_data.webauth and not self.is_authenticated(): self._send_json({"status": "error", "message": "Unauthorized"}, 401) return try: if self.path.startswith('/api/studio/host/'): mac = self.path.split('/api/studio/host/')[-1] elif self.path.startswith('/studio/host/'): mac = self.path.split('/studio/host/')[-1] else: super().do_GET() return resp = self.web_utils.studio_utils.studio_delete_host({"mac_address": mac}) status_code = 400 if resp.get("status") == "error" else 200 self._send_json(resp, status_code) except Exception as e: logger.error(f"DELETE error: {e}") self._send_json({"status": "error", "message": str(e)}, 500) # ------------------------------------------------------------------------ # GET REQUEST HANDLER # ------------------------------------------------------------------------ def do_GET(self): # Clean path for routing (strip query string) path_clean = self.path.split('?')[0] legacy_page_redirects = { '/index.html': '/#/dashboard', '/bjorn.html': '/#/bjorn', '/netkb.html': '/#/netkb', '/network.html': '/#/network', '/credentials.html': '/#/credentials', '/vulnerabilities.html': '/#/vulnerabilities', '/attacks.html': '/#/attacks', '/scheduler.html': '/#/scheduler', '/database.html': '/#/database', '/files_explorer.html': '/#/files', '/loot.html': '/#/loot', '/actions_launcher.html': '/#/actions', '/actions_studio.html': '/#/actions-studio', '/backup_update.html': '/#/backup', '/web_enum.html': '/#/web-enum', '/zombieland.html': '/#/zombieland', } if path_clean in legacy_page_redirects: self.send_response(302) self.send_header('Location', legacy_page_redirects[path_clean]) self.end_headers() return # Public assets public_paths = [ '/apple-touch-icon', '/favicon.ico', '/manifest.json', '/static/', '/web/css/', '/web/images/', '/web/js/', '/web/i18n/', '/web_old/', ] if self.shared_data.webauth: public_paths.extend(['/login', '/login.html', '/logout']) # Bypass auth for public paths if any(path_clean.startswith(p) for p in public_paths): if self.shared_data.webauth: if path_clean in ['/login', '/login.html']: self.serve_login_page() return elif path_clean == '/logout': self.handle_logout() return # Serve legacy files from an absolute path (independent of process CWD) if path_clean.startswith('/web_old/'): rel = path_clean.lstrip('/') file_path = os.path.join(self.shared_data.current_dir, rel) if os.path.isfile(file_path): content_type = self.guess_type(file_path) or 'application/octet-stream' self.serve_file_gzipped(file_path, content_type) return self.send_error(404, "File not found.") return super().do_GET() return # Enforce auth if self.shared_data.webauth and not self.is_authenticated(): self.send_response(302) self.send_header('Location', '/login.html') self.end_headers() return # Serve web/index.html for / if path_clean == '/': index_path = os.path.join(self.shared_data.web_dir, 'index.html') self.serve_file_gzipped(index_path, 'text/html') return # --- DYNAMIC ROUTING MATCHING --- # 1. Exact match if path_clean in self.GET_ROUTES: handler_or_name = self.GET_ROUTES[path_clean] # String = instance method name (resolved per-request, avoids lambda) if isinstance(handler_or_name, str): getattr(self, handler_or_name)(self) else: handler_or_name(self) return # 2. Prefix match (for routes with params in path) if self.path.startswith('/c2/download_client/'): filename = unquote(self.path.split('/c2/download_client/')[-1]) self.web_utils.c2.c2_download_client(self, filename) return elif self.path.startswith('/c2/stale_agents'): from urllib.parse import parse_qs, urlparse query = parse_qs(urlparse(self.path).query) threshold = int(query.get("threshold", [300])[0]) self.web_utils.c2.c2_stale_agents(self, threshold) return elif self.path.startswith('/api/webenum/results'): self.web_utils.webenum_utils.serve_webenum_data(self) return elif self.path.startswith('/download_file'): self.web_utils.file_utils.download_file(self) return elif self.path.startswith('/list_files'): self.web_utils.file_utils.list_files_endpoint(self) return elif self.path.startswith('/loot_download'): self.web_utils.file_utils.loot_download(self) return elif self.path.startswith('/download_backup'): self.web_utils.backup_utils.download_backup(self) return elif self.path.startswith('/get_script_output/'): script_name = unquote(self.path.split('/')[-1]) response = self.web_utils.script_utils.get_script_output({"script_name": script_name}) self._send_json(response) return elif self.path.startswith('/get_action_images?'): self.web_utils.action_utils.get_action_images(self) return elif self.path.startswith('/get_status_icon?'): self.web_utils.action_utils.get_status_icon(self) return elif self.path.startswith('/images/status/'): self.web_utils.action_utils.serve_status_image(self) return elif self.path.startswith('/list_static_images_with_dimensions'): self.web_utils.action_utils.list_static_images_with_dimensions(self) return elif self.path.startswith('/screen.png'): self.web_utils.action_utils.serve_image(self) return elif self.path.startswith('/static_images/'): self.web_utils.action_utils.serve_static_image(self) return elif self.path.startswith('/bjorn_status_image'): self.web_utils.action_utils.serve_bjorn_status_image(self) return elif self.path.startswith('/get_character_icon'): self.web_utils.action_utils.get_character_icon(self) return elif self.path.startswith('/get_character_image?'): self.web_utils.action_utils.get_character_image(self) return elif self.path.startswith('/bjorn_character'): fn = getattr(self.web_utils.action_utils, 'serve_bjorn_character', self.web_utils.action_utils.serve_bjorn_status_image) fn(self) return elif self.path.startswith('/get_comments?'): self.web_utils.action_utils.get_comments(self) return elif self.path.startswith('/get_attack_content'): self.web_utils.action_utils.get_attack_content(self) return elif self.path.startswith('/get_attacks'): self.web_utils.action_utils.get_attacks(self) return elif self.path.startswith('/actions_icons'): self.web_utils.action_utils.serve_actions_icons(self) return elif self.path.startswith('/list_vulnerabilities'): if '?' in self.path and 'page=' in self.path: self.web_utils.vuln_utils.serve_vulns_data_optimized(self) else: self.web_utils.vuln_utils.serve_vulns_data(self) return elif self.path.startswith('/vulnerabilities/history'): self.web_utils.vuln_utils.serve_vuln_history(self) return elif self.path.startswith('/api/cve/'): cve_id = self.path.split('/api/cve/')[-1].split('?')[0] self.web_utils.vuln_utils.serve_cve_details(self, cve_id) return elif self.path.startswith('/api/exploitdb/'): cve_id = self.path.split('/api/exploitdb/')[-1].split('?')[0] self.web_utils.vuln_utils.serve_exploitdb_by_cve(self, cve_id) return elif self.path.startswith('/api/studio/hosts'): self.web_utils.studio_utils.studio_get_hosts(self) return elif self.path.startswith('/api/studio/layout'): self.web_utils.studio_utils.studio_load_layout(self) return elif self.path.startswith('/api/db/export/'): table_name = unquote(self.path.split('/api/db/export/', 1)[1].split('?', 1)[0]) self.web_utils.db_utils.db_export_table_endpoint(self, table_name) return elif self.path.startswith('/api/db/schema/'): name = unquote(self.path.split('/api/db/schema/', 1)[1]) self.web_utils.db_utils.db_schema_endpoint(self, name) return elif self.path.startswith('/api/db/table/'): table_name = unquote(self.path.split('/api/db/table/', 1)[1].split('?', 1)[0]) self.web_utils.db_utils.db_get_table_endpoint(self, table_name) return elif self.path.startswith('/attempt_history'): self.web_utils.netkb_utils.serve_attempt_history(self) return elif self.path.startswith('/action_queue'): self.web_utils.netkb_utils.serve_action_queue(self) return super().do_GET() # ------------------------------------------------------------------------ # POST REQUEST HANDLER # ------------------------------------------------------------------------ def do_POST(self): # Handle Auth if self.path == '/login' and self.shared_data.webauth: self.handle_login() return elif self.path == '/logout' and self.shared_data.webauth: self.handle_logout() return if self.shared_data.webauth and not self.is_authenticated(): self.send_error(401) return # Special Route if self.path == '/queue_cmd': self.web_utils.netkb_utils.handle_queue_cmd(self) return try: # 1. MULTIPART ROUTES if self.path in self.POST_ROUTES_MULTIPART: self.POST_ROUTES_MULTIPART[self.path](self) return # 2. JSON ROUTES content_length = int(self.headers.get('Content-Length', 0)) # GUARD: Max size check for JSON payloads too if content_length > MAX_POST_SIZE: self.send_error(413) return body = self.rfile.read(content_length) if content_length > 0 else b'{}' # Guard against multipart mistakenly sent as generic post content_type = self.headers.get('Content-Type', '') if content_type.startswith('multipart/form-data'): self._send_json({"status": "error", "message": "Unexpected multipart/form-data"}, 400) return data = json.loads(body) # Special case for livestatus if self.path == '/clear_livestatus': restart = data.get("restart", True) self.web_utils.system_utils.clear_livestatus(self, restart=restart) return # Dynamic Dispatch for JSON — data-only handlers if self.path in self.POST_ROUTES_JSON: handler = self.POST_ROUTES_JSON[self.path] if callable(handler): response = handler(data) if response is not None: status_code = 400 if isinstance(response, dict) and response.get("status") == "error" else 200 self._send_json(response, status_code) return # Dynamic Dispatch for JSON — handler-aware: fn(handler, data) if self.path in self.POST_ROUTES_JSON_H: handler_fn = self.POST_ROUTES_JSON_H[self.path] if callable(handler_fn): response = handler_fn(self, data) if response is not None: status_code = 400 if isinstance(response, dict) and response.get("status") == "error" else 200 self._send_json(response, status_code) return # Path params routes (DB) if self.path.startswith('/api/db/drop/'): table_name = unquote(self.path.split('/api/db/drop/', 1)[1]) self.web_utils.db_utils.db_drop_table_endpoint(self, table_name) return elif self.path.startswith('/api/db/drop_view/'): view_name = unquote(self.path.split('/api/db/drop_view/', 1)[1]) self.web_utils.db_utils.db_drop_view_endpoint(self, view_name) return elif self.path.startswith('/api/db/truncate/'): table_name = unquote(self.path.split('/api/db/truncate/', 1)[1]) self.web_utils.db_utils.db_truncate_table_endpoint(self, table_name) return # 404 self._send_json({"status": "error", "message": "Route not found"}, 404) except json.JSONDecodeError: self._send_json({"status": "error", "message": "Invalid JSON format"}, 400) except Exception as e: logger.error(f"Error handling POST request: {e}") self._send_json({"status": "error", "message": str(e)}, 500) # ============================================================================ # WEB SERVER THREAD # ============================================================================ class WebThread(threading.Thread): """ Threaded web server with automatic port conflict resolution and timeouts. Handles graceful shutdown and server lifecycle. """ def __init__(self, port=8000): super().__init__(name="WebThread", daemon=True) self.shared_data = shared_data self.initial_port = port self.current_port = port self.httpd = None def setup_server(self): max_retries = 10 retry_count = 0 while retry_count < max_retries: try: # Define server class with timeout logic class ThreadedTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True # Prevents zombie processes request_queue_size = 16 # Limit pending connections backlog # Limit concurrent handler threads to prevent RAM exhaustion on Pi Zero 2 _max_threads = 20 _thread_semaphore = threading.BoundedSemaphore(_max_threads) def process_request(self, request, client_address): if not self._thread_semaphore.acquire(blocking=True, timeout=5.0): # All slots busy - reject to protect RAM try: request.close() except Exception: pass return super().process_request(request, client_address) def process_request_thread(self, request, client_address): try: super().process_request_thread(request, client_address) finally: self._thread_semaphore.release() # Timeout logic to kill hanging connections (critical for Pi Zero) def finish_request(self, request, client_address): request.settimeout(10.0) super().finish_request(request, client_address) # Instantiate server server = ThreadedTCPServer(("", self.current_port), CustomHandler) # Apply socket options server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if hasattr(socket, "SO_REUSEPORT"): try: server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except: pass return server except OSError as e: if e.errno == 98: # Address already in use retry_count += 1 logger.warning(f"Port {self.current_port} busy, trying next...") time.sleep(0.5) self.current_port += 1 else: raise raise RuntimeError(f"Unable to start server after {max_retries} attempts") def run(self): while not self.shared_data.webapp_should_exit: try: self.httpd = self.setup_server() logger.info(f"Server started on port {self.current_port}") self.httpd.serve_forever() except Exception as e: logger.error(f"Server error: {e}") if self.httpd: self.httpd.server_close() time.sleep(2) def shutdown(self): if self.httpd: self.httpd.shutdown() self.httpd.server_close() logger.info("Web server stopped.") def handle_exit_web(signum, frame): shared_data.webapp_should_exit = True if web_thread.is_alive(): web_thread.shutdown() sys.exit(0) web_thread = WebThread(port=8000) if __name__ == "__main__": try: signal.signal(signal.SIGINT, handle_exit_web) signal.signal(signal.SIGTERM, handle_exit_web) web_thread.start() logger.info("Web server thread started.") while True: time.sleep(1) except Exception as e: logger.error(f"An exception occurred during web server start: {e}") handle_exit_web(signal.SIGINT, None) sys.exit(1)