#!/usr/bin/env python3 import re import argparse import atexit import shutil import logging import sys import os import psutil import time import threading import signal import asyncio import websockets from collections import deque, defaultdict from datetime import datetime, timedelta from camoufox.server import launch_server # Global variables for resource tracking active_connections = defaultdict(int) # Track connections per endpoint max_connections = defaultdict(int) resource_stats = {} server_instances = {} # Track multiple server instances shutdown_requested = False endpoint_locks = defaultdict(threading.Lock) # Locks for each endpoint memory_restart_threshold = 1800 # MB - warn when exceeded restart_in_progress = False # Enhanced monitoring metrics connection_pool_metrics = { 'total_acquired': 0, 'total_released': 0, 'total_reused': 0, 'pool_size': 0, 'active_contexts': 0 } def parse_proxy_url(url): """Parse proxy URL in format proto://user:pass@host:port""" pattern = r'([^:]+)://(?:([^:]+):([^@]+)@)?([^:]+):(\d+)' match = re.match(pattern, url) if not match: raise ValueError('Invalid proxy URL format. Expected proto://[user:pass@]host:port') proto, username, password, host, port = match.groups() # Ensure username and password are strings, not None proxy_config = { 'server': f'{proto}://{host}:{port}', 'username': username or '', 'password': password or '' } # Remove empty credentials if not proxy_config['username']: del proxy_config['username'] if not proxy_config['password']: del proxy_config['password'] return proxy_config def monitor_resources(server_ports, proxy_url): """Monitor system resources and log warnings when thresholds are exceeded""" global active_connections, max_connections, resource_stats, shutdown_requested, restart_in_progress global connection_pool_metrics logging.info(f"Resource monitor started for proxy '{proxy_url}' on ports {server_ports}") log_counter = 0 while not shutdown_requested: log_counter += 1 try: # Get system resource usage cpu_percent = psutil.cpu_percent(interval=1) memory = psutil.virtual_memory() memory_percent = memory.percent # Get current process info current_process = psutil.Process() process_memory = current_process.memory_info() process_cpu = current_process.cpu_percent() # Update active connections using psutil all_connections = psutil.net_connections(kind='inet') new_active_connections = defaultdict(int) for conn in all_connections: if conn.status == psutil.CONN_ESTABLISHED and conn.laddr.port in server_ports: new_active_connections[conn.laddr.port] += 1 active_connections.clear() active_connections.update(new_active_connections) for port, count in active_connections.items(): max_connections[port] = max(max_connections.get(port, 0), count) connection_pool_metrics['active_contexts'] = sum(active_connections.values()) # Update resource stats resource_stats = { 'cpu_percent': cpu_percent, 'memory_percent': memory_percent, 'process_memory_mb': process_memory.rss / 1024 / 1024, 'process_cpu_percent': process_cpu, 'total_active_connections': sum(active_connections.values()), 'active_connections_per_endpoint': dict(active_connections), 'max_connections': dict(max_connections), 'connection_pool_metrics': dict(connection_pool_metrics) } # Log resource usage periodically if cpu_percent > 80 or memory_percent > 80: logging.info(f"RESOURCE STATS - CPU: {cpu_percent}%, Memory: {memory_percent}%, " f"Process Memory: {resource_stats['process_memory_mb']:.1f}MB, " f"Total Active Connections: {resource_stats['total_active_connections']}") # Log connection pool metrics pool_metrics = resource_stats['connection_pool_metrics'] logging.info(f"POOL METRICS - Acquired: {pool_metrics['total_acquired']}, " f"Released: {pool_metrics['total_released']}, " f"Reused: {pool_metrics['total_reused']}, " f"Pool Size: {pool_metrics['pool_size']}, " f"Active Contexts: {pool_metrics['active_contexts']}") # Warning thresholds if cpu_percent > 85: logging.warning(f"HIGH CPU USAGE: {cpu_percent}%") if memory_percent > 85: logging.warning(f"HIGH MEMORY USAGE: {memory_percent}%") if resource_stats['total_active_connections'] > 100: logging.warning(f"HIGH TOTAL CONNECTION COUNT: {resource_stats['total_active_connections']} active connections") if process_memory.rss > 2 * 1024 * 1024 * 1024: # 2GB logging.warning(f"HIGH PROCESS MEMORY: {process_memory.rss / 1024 / 1024:.1f}MB") # Safety net: Warn instead of restart if memory exceeds threshold if resource_stats['process_memory_mb'] > memory_restart_threshold: logging.warning(f"MEMORY THRESHOLD EXCEEDED: {resource_stats['process_memory_mb']}MB > {memory_restart_threshold}MB") logging.warning("Manual intervention required - memory usage critical but restart disabled") logging.warning("Consider adding new camoufox instances or reducing concurrent workers") # Add metric for monitoring instead of restart logging.info(f"MEMORY_ALERT: {resource_stats['process_memory_mb']}MB used on {sum(active_connections.values())} active connections") # Add a heartbeat log every minute (30s * 2) if log_counter % 2 == 0: logging.info( f"HEARTBEAT - Proxy: {proxy_url} | Ports: {server_ports} | " f"Memory: {resource_stats.get('process_memory_mb', 0):.1f}MB | " f"CPU: {resource_stats.get('cpu_percent', 0)}% | " f"Active Connections: {resource_stats.get('total_active_connections', 0)}" ) except Exception as e: logging.error(f"Error in resource monitoring: {e}") time.sleep(30) # Check every 30 seconds def graceful_shutdown(signum, frame): """Handle graceful shutdown""" global shutdown_requested, server_instances, restart_in_progress logging.info("Graceful shutdown requested") shutdown_requested = True # Log final resource stats if resource_stats: logging.info(f"Final resource stats: {resource_stats}") # Log final connection pool metrics logging.info(f"Final connection pool metrics: {connection_pool_metrics}") # The server instances are running in daemon threads and will be terminated # when the main process exits. No explicit shutdown call is needed. logging.info("Shutting down all Camoufox server instances...") # If restart was requested, exit with special code if restart_in_progress: logging.info("Restarting Camoufox server...") os.execv(sys.executable, [sys.executable] + sys.argv) sys.exit(0) def create_server_instance(port, base_config): """ Creates and runs a new Camoufox server instance on the specified port. NOTE: The `launch_server` function is a blocking call that runs an event loop and does not return. Therefore, any code after it in this function is unreachable. """ config = base_config.copy() config['port'] = port try: # This function blocks and runs the server indefinitely. launch_server(**config) except Exception as e: # If an error occurs, log it. The daemon thread will then terminate. logging.error(f'Error launching server on port {port}: {str(e)}', exc_info=True) def check_listening_ports(expected_ports, log_results=True): """Checks which of the expected ports are actively listening.""" successful_ports = [] failed_ports = [] try: # Check all system-wide connections, not just for the current process, # as the server may run in a child process. listening_ports = { conn.laddr.port for conn in psutil.net_connections(kind='inet') if conn.status == psutil.CONN_LISTEN } for port in expected_ports: if port in listening_ports: successful_ports.append(port) else: failed_ports.append(port) if log_results: logging.info("--- Verifying Listening Ports ---") if successful_ports: logging.info(f"Successfully listening on ports: {sorted(successful_ports)}") if failed_ports: logging.error(f"FAILED to listen on ports: {sorted(failed_ports)}") logging.info("---------------------------------") except Exception as e: if log_results: logging.error(f"Could not verify listening ports: {e}") return successful_ports, failed_ports def main(): parser = argparse.ArgumentParser(description='Launch Camoufox server with optional proxy support') parser.add_argument('--proxy-url', help='Optional proxy URL in format proto://user:pass@host:port (supports http, https, socks5)') parser.add_argument('--ws-host', default='0.0.0.0', help='WebSocket server host address (e.g., localhost, 0.0.0.0)') parser.add_argument('--port', type=int, default=12345, help='Base WebSocket server port') parser.add_argument('--num-instances', type=int, default=4, help='Number of server instances to create') parser.add_argument('--port-range', type=str, help='Port range in format start-end (e.g., 12345-12349)') parser.add_argument('--base-proxy-port', type=int, default=1080, help='Base proxy port for mapping to camoufox instances') parser.add_argument('--ws-path', default='camoufox', help='Base WebSocket server path') parser.add_argument('--headless', action='store_true', help='Run browser in headless mode') parser.add_argument('--geoip', nargs='?', const=True, default=False, help='Enable geo IP protection. Can specify IP address or use True for automatic detection') parser.add_argument('--locale', help='Locale(s) to use (e.g. "en-US" or "en-US,fr-FR")') parser.add_argument('--block-images', action='store_true', help='Block image requests to save bandwidth') parser.add_argument('--block-webrtc', action='store_true', help='Block WebRTC entirely') parser.add_argument('--humanize', nargs='?', const=True, type=float, help='Humanize cursor movements. Can specify max duration in seconds') parser.add_argument('--extensions', type=str, help='Comma-separated list of extension paths to enable (XPI files or extracted directories). Use quotes if paths contain spaces.') parser.add_argument('--persistent-context', action='store_true', help='Enable persistent browser context.') parser.add_argument('--user-data-dir', type=str, help='Directory to store persistent browser data.') parser.add_argument('--preferences', type=str, help='Comma-separated list of Firefox preferences (e.g. "key1=value1,key2=value2")') # Add resource monitoring arguments parser.add_argument('--monitor-resources', action='store_true', help='Enable resource monitoring') parser.add_argument('--max-connections-per-instance', type=int, default=50, help='Maximum concurrent connections per instance') parser.add_argument('--connection-timeout', type=int, default=300, help='Connection timeout in seconds') parser.add_argument('--memory-restart-threshold', type=int, default=1800, help='Memory threshold (MB) to trigger warning') args = parser.parse_args() # Set memory restart threshold global memory_restart_threshold memory_restart_threshold = args.memory_restart_threshold # Set up signal handlers for graceful shutdown signal.signal(signal.SIGTERM, graceful_shutdown) signal.signal(signal.SIGINT, graceful_shutdown) proxy_config = None if args.proxy_url: try: proxy_config = parse_proxy_url(args.proxy_url) print(f"Using proxy configuration: {args.proxy_url}") except ValueError as e: print(f'Error parsing proxy URL: {e}') return else: print("No proxy URL provided. Running without proxy.") # --- Basic Logging Configuration --- log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') log_handler = logging.StreamHandler(sys.stdout) log_handler.setFormatter(log_formatter) root_logger = logging.getLogger() for handler in root_logger.handlers[:]: root_logger.removeHandler(handler) root_logger.addHandler(log_handler) root_logger.setLevel(logging.DEBUG) logging.debug("DEBUG logging enabled. Starting Camoufox server setup...") # --- End Logging Configuration --- try: # --- Check DISPLAY environment variable --- display_var = os.environ.get('DISPLAY') logging.info(f"Value of DISPLAY environment variable: {display_var}") # --- End Check --- # Build base config dictionary base_config = { 'headless': False, # Force non-headless mode for VNC 'geoip': True, # Always enable GeoIP when a proxy is used 'host': args.ws_host, 'ws_path': args.ws_path, 'env': {'DISPLAY': os.environ.get('DISPLAY')} } # Add proxy to config only if it was successfully parsed if proxy_config: base_config['proxy'] = proxy_config # Add optional parameters if args.locale: base_config['locale'] = args.locale if args.block_images: base_config['block_images'] = True if args.block_webrtc: base_config['block_webrtc'] = True if args.humanize: base_config['humanize'] = args.humanize if isinstance(args.humanize, float) else True # Add persistent context options if args.persistent_context: base_config['persistent_context'] = True if args.user_data_dir: base_config['user_data_dir'] = args.user_data_dir # Add Firefox preferences if args.preferences: base_config['preferences'] = {} prefs_list = args.preferences.split(',') for pref in prefs_list: if '=' in pref: key, value = pref.split('=', 1) if value.lower() in ('true', 'false'): base_config['preferences'][key.strip()] = value.lower() == 'true' elif value.isdigit(): base_config['preferences'][key.strip()] = int(value) else: base_config['preferences'][key.strip()] = value.strip() print(f"Applied Firefox preferences: {base_config['preferences']}") # Exclude default addons including uBlock Origin base_config['exclude_addons'] = ['ublock_origin', 'default_addons'] print('Excluded default addons including uBlock Origin') # Add custom extensions if specified if args.extensions: from pathlib import Path valid_extensions = [] extensions_list = [ext.strip() for ext in args.extensions.split(',')] temp_dirs_to_cleanup = [] def cleanup_temp_dirs(): for temp_dir in temp_dirs_to_cleanup: try: shutil.rmtree(temp_dir) print(f"Cleaned up temporary extension directory: {temp_dir}") except Exception as e: print(f"Warning: Failed to clean up temp dir {temp_dir}: {e}") atexit.register(cleanup_temp_dirs) for ext_path in extensions_list: ext_path = Path(ext_path).absolute() if not ext_path.exists(): print(f"Warning: Extension path does not exist: {ext_path}") continue if ext_path.is_file() and ext_path.suffix == '.xpi': import tempfile import zipfile try: temp_dir = tempfile.mkdtemp(prefix=f"camoufox_ext_{ext_path.stem}_") temp_dirs_to_cleanup.append(temp_dir) with zipfile.ZipFile(ext_path, 'r') as zip_ref: zip_ref.extractall(temp_dir) valid_extensions.append(temp_dir) print(f"Successfully loaded extension: {ext_path.name} (extracted to {temp_dir})") except Exception as e: print(f"Error loading extension {ext_path}: {str(e)}") if temp_dir in temp_dirs_to_cleanup: temp_dirs_to_cleanup.remove(temp_dir) continue elif ext_path.is_dir(): if (ext_path / 'manifest.json').exists(): valid_extensions.append(str(ext_path)) print(f"Successfully loaded extension: {ext_path.name}") else: print(f"Warning: Directory is not a valid Firefox extension: {ext_path}") else: print(f"Warning: Invalid extension path: {ext_path}") if valid_extensions: base_config['addons'] = valid_extensions print(f"Loaded {len(valid_extensions)} extensions") else: print("Warning: No valid extensions were loaded") # Create multiple server instances ports_to_create = [] if args.port_range: start_port, end_port = map(int, args.port_range.split('-')) ports_to_create = list(range(start_port, end_port + 1)) else: # Create instances starting from base port ports_to_create = [args.port + i for i in range(args.num_instances)] # Start resource monitoring thread if enabled, passing it the ports to watch. if args.monitor_resources: # Pass the proxy URL to the monitor for more descriptive logging monitor_thread = threading.Thread(target=monitor_resources, args=(ports_to_create, args.proxy_url), daemon=True) monitor_thread.start() print(f"Attempting to launch {len(ports_to_create)} Camoufox server instances on ports: {ports_to_create}") for port in ports_to_create: # launch_server is blocking, so we run each instance in its own thread. thread = threading.Thread(target=create_server_instance, args=(port, base_config), daemon=True) thread.start() # Add a small delay between launching instances to avoid race conditions # in the underlying Playwright/Camoufox library. time.sleep(1) # The script's main purpose is now to launch the daemon threads and then wait. # The actual readiness is determined by the start_camoufox.sh script. print("Server threads launched. Main process will now wait for shutdown signal.") # Log startup resource usage process = psutil.Process() memory_info = process.memory_info() logging.info(f"Server started. Initial memory usage: {memory_info.rss / 1024 / 1024:.1f}MB") # Keep the main thread alive to host the daemon threads and handle shutdown signals try: while not shutdown_requested: time.sleep(1) except KeyboardInterrupt: logging.info("Received KeyboardInterrupt, shutting down...") except Exception as e: print(f'Error launching server: {str(e)}') logging.error(f'Error launching server: {str(e)}', exc_info=True) if 'Browser.setBrowserProxy' in str(e): print('Note: The browser may not support SOCKS5 proxy authentication') return if __name__ == '__main__': main()