diff --git a/pyproxy/handlers/client.py b/pyproxy/handlers/client.py index bb3bac7..2612b65 100644 --- a/pyproxy/handlers/client.py +++ b/pyproxy/handlers/client.py @@ -90,24 +90,25 @@ def handle_client(self, client_socket): Args: client_socket (socket): The socket object for the client connection. """ - request = client_socket.recv(4096) + try: + client_socket.settimeout(10) + request = client_socket.recv(4096) - if not request: - self.console_logger.debug("No request received, closing connection.") + if not request: + return + + first_line = request.decode(errors="ignore").split("\n")[0] + if first_line.startswith("CONNECT"): + https_handler = self._create_handler( + HttpsHandler, + ssl_config=self.ssl_config, + cancel_inspect_queue=self.cancel_inspect_queue, + cancel_inspect_result_queue=self.cancel_inspect_result_queue, + ) + https_handler.handle_https_connection(client_socket, first_line) + else: + http_handler = self._create_handler(HttpHandler) + http_handler.handle_http_request(client_socket, request) + finally: client_socket.close() self.active_connections.pop(threading.get_ident(), None) - return - - first_line = request.decode(errors="ignore").split("\n")[0] - - if first_line.startswith("CONNECT"): - https_handler = self._create_handler( - HttpsHandler, - ssl_config=self.ssl_config, - cancel_inspect_queue=self.cancel_inspect_queue, - cancel_inspect_result_queue=self.cancel_inspect_result_queue, - ) - https_handler.handle_https_connection(client_socket, first_line) - else: - http_handler = self._create_handler(HttpHandler) - http_handler.handle_http_request(client_socket, request) diff --git a/pyproxy/handlers/http.py b/pyproxy/handlers/http.py index 9451f43..7ab193d 100644 --- a/pyproxy/handlers/http.py +++ b/pyproxy/handlers/http.py @@ -202,15 +202,24 @@ def forward_request_to_server(self, client_socket, request, url, first_line): server_port = parsed_url.port or ( 443 if parsed_url.scheme == "https" else 80 ) + + try: + ip_address = socket.gethostbyname(server_host) + except socket.gaierror: + ip_address = server_host + thread_id = threading.get_ident() if thread_id in self.active_connections: - self.active_connections[thread_id] = { - "target_ip": server_host, - "target_port": server_port, - "bytes_sent": 0, - "bytes_received": 0, - } + self.active_connections[thread_id].update( + { + "target_ip": ip_address, + "target_domain": server_host, + "target_port": server_port, + "bytes_sent": 0, + "bytes_received": 0, + } + ) try: server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/pyproxy/handlers/https.py b/pyproxy/handlers/https.py index e2b3deb..17ec5ce 100644 --- a/pyproxy/handlers/https.py +++ b/pyproxy/handlers/https.py @@ -206,6 +206,21 @@ def _process_first_ssl_request(self, ssl_client_socket, server_host, first_line) if self._is_blocked(f"{server_host}{path}"): return None, full_url, True + if not self.logger_config.no_logging_access: + method, domain_port, protocol = first_line.split(" ") + domain, port = domain_port.split(":") + self.logger_config.access_logger.info( + "", + extra={ + "ip_src": ssl_client_socket.getpeername()[0], + "url": full_url, + "method": method, + "domain": server_host, + "port": port, + "protocol": protocol, + }, + ) + return first_request, full_url, False except Exception as e: self.logger_config.error_logger.error(f"SSL request processing error : {e}") @@ -231,10 +246,13 @@ def handle_https_connection(self, client_socket, first_line): not_inspect = self._should_skip_inspection(server_host) thread_id = threading.get_ident() - self.active_connections[thread_id] = { - "bytes_sent": 0, - "bytes_received": 0, - } + self.active_connections[thread_id].update( + { + "target_domain": server_host, + "bytes_sent": 0, + "bytes_received": 0, + } + ) if self.ssl_config.ssl_inspect and not not_inspect: try: @@ -317,13 +335,13 @@ def handle_https_connection(self, client_socket, first_line): ) if not self.logger_config.no_logging_access: - _, _, protocol = first_line.split(" ") + method, _, protocol = first_line.split(" ") self.logger_config.access_logger.info( "", extra={ "ip_src": client_ip, "url": target, - "method": "CONNECT", + "method": method, "domain": server_host, "port": server_port, "protocol": protocol, @@ -397,6 +415,7 @@ def transfer_data_between_sockets(self, client_socket, server_socket): data ) except (socket.error, OSError): + self.logger_config.console_logger("error") client_socket.close() server_socket.close() self.active_connections.pop(threading.get_ident(), None) diff --git a/pyproxy/monitoring/__init__.py b/pyproxy/monitoring/__init__.py index e69de29..4111fb3 100644 --- a/pyproxy/monitoring/__init__.py +++ b/pyproxy/monitoring/__init__.py @@ -0,0 +1,38 @@ +""" +pyproxy.monitoring.__init__.py + +Provides a monitoring system for the ProxyServer, exposing information about +processes, threads, active connections, and subprocesses. Implements an HTTP +server using Flask to provide monitoring endpoints. +""" + +import logging +from flask import Flask +from .monitor import ProxyMonitor +from .auth import create_basic_auth +from .routes import register_routes + + +def start_flask_server(proxy_server, flask_port, flask_pass, debug) -> None: + """ + Launches a Flask HTTP server to monitor the ProxyServer. + + The server exposes endpoints that provide status information about + the proxy server, including process details, thread information, + subprocess statuses, and active connections. + + Args: + proxy_server (ProxyServer): The ProxyServer instance to monitor. + flask_port (int): The port number on which the Flask server will listen. + flask_pass (str): The password used for basic HTTP authentication. + debug (bool): Flag to enable or disable Flask debug mode. + """ + auth = create_basic_auth(flask_pass) + + app = Flask(__name__, static_folder="static") + if not debug: + log = logging.getLogger("werkzeug") + log.setLevel(logging.ERROR) + + register_routes(app, auth, proxy_server, ProxyMonitor) + app.run(host="0.0.0.0", port=flask_port) # nosec diff --git a/pyproxy/monitoring/auth.py b/pyproxy/monitoring/auth.py new file mode 100644 index 0000000..5efede1 --- /dev/null +++ b/pyproxy/monitoring/auth.py @@ -0,0 +1,31 @@ +""" +pyproxy.monitoring.auth.py + +Provides HTTP Basic Authentication setup for the monitoring Flask server, +using a single hardcoded user 'admin' with a hashed password. +""" + +from flask_httpauth import HTTPBasicAuth +from werkzeug.security import check_password_hash, generate_password_hash + + +def create_basic_auth(password: str): + """ + Creates and configures an HTTPBasicAuth instance with a single user. + + Args: + password (str): The password for the 'admin' user. + + Returns: + HTTPBasicAuth: Configured HTTPBasicAuth instance for use in Flask. + """ + auth = HTTPBasicAuth() + users = {"admin": generate_password_hash(password)} + + @auth.verify_password + def verify_password(username, passwd): + if username in users and check_password_hash(users.get(username), passwd): + return username + return None + + return auth diff --git a/pyproxy/monitoring/monitor.py b/pyproxy/monitoring/monitor.py new file mode 100644 index 0000000..0a33423 --- /dev/null +++ b/pyproxy/monitoring/monitor.py @@ -0,0 +1,196 @@ +""" +pyproxy.monitoring.monitor + +This module defines the ProxyMonitor class, which provides monitoring capabilities +for the ProxyServer. It collects and exposes real-time information about the +main process, threads, subprocesses, and active client connections using `psutil`, +`threading`, and `multiprocessing` libraries. +""" + +import os +import threading +import multiprocessing +from datetime import datetime +from typing import List, Dict, Union +import psutil + + +class ProxyMonitor: + """ + Monitors the status of the ProxyServer, including details about + the main process, threads, subprocesses, and active client connections. + + Args: + proxy_server (ProxyServer): The instance of the ProxyServer to monitor. + """ + + def __init__(self, proxy_server): + self.proxy_server = proxy_server + + def get_process_info( + self, + ) -> Dict[str, Union[int, str, List[Dict[str, Union[int, str]]]]]: + """ + Retrieves overall process information for the ProxyServer, + including the PID, name, status, and details about threads, + subprocesses, and active connections. + + Returns: + dict: A dictionary containing the process information. + """ + process_info = { + "pid": os.getpid(), + "name": "ProxyServer", + "status": "running", + "start_time": datetime.fromtimestamp( + psutil.Process(os.getpid()).create_time() + ).strftime("%Y-%m-%d %H:%M:%S"), + "threads": self.get_threads_info(), + "subprocesses": self.get_subprocesses_info(), + "active_connections": self.get_active_connections(), + } + return process_info + + def get_threads_info(self) -> List[Dict[str, Union[int, str]]]: + """ + Retrieves information about the threads running in the ProxyServer. + + Returns: + list: A list of dictionaries, each containing information + about a thread. + """ + threads_info = [] + for thread in threading.enumerate(): + threads_info.append( + { + "thread_id": thread.ident, + "name": thread.name, + "status": self.get_thread_status(thread), + } + ) + return threads_info + + def get_thread_status(self, thread: threading.Thread) -> str: + """ + Gets the status of a given thread. + + Args: + thread (threading.Thread): The thread whose status is to be retrieved. + + Returns: + str: The status of the thread ('running', 'terminated', or 'unknown'). + """ + try: + if thread.is_alive(): + return "running" + return "terminated" + except AttributeError: + return "unknown" + + def get_subprocesses_info( + self, + ) -> Dict[str, Dict[str, Union[str, List[Dict[str, Union[int, str]]]]]]: + """ + Retrieves the status of the ProxyServer's subprocesses, including + filtering, shortcuts, cancel inspection, and custom header processes. + + Returns: + dict: A dictionary containing subprocess statuses. + """ + subprocesses_info = {} + + subprocesses = { + "filter": self.proxy_server.filter_proc, + "shortcuts": self.proxy_server.shortcuts_proc, + "cancel_inspect": self.proxy_server.cancel_inspect_proc, + "custom_header": self.proxy_server.custom_header_proc, + } + + for name, process in subprocesses.items(): + if process is not None and process.is_alive(): + subprocesses_info[name] = self.get_subprocess_status(process, name) + return subprocesses_info + + def get_subprocess_status( + self, process: multiprocessing.Process, name: str + ) -> Dict[str, Union[str, None, List[Dict[str, Union[int, str]]]]]: + """ + Retrieves the status of a subprocess. + + Args: + process (multiprocessing.Process): The subprocess to check. + name (str): The name of the subprocess. + + Returns: + dict: A dictionary containing the subprocess status. + """ + if process is None: + return {"status": "not started", "name": name, "threads": []} + try: + status = "running" if process.is_alive() else "terminated" + threads_info = self.get_subprocess_threads_info(process) + except AttributeError: + status = "terminated" + threads_info = [] + return { + "pid": process.pid if hasattr(process, "pid") else None, + "status": status, + "name": name, + "threads": threads_info, + } + + def get_subprocess_threads_info( + self, process: multiprocessing.Process + ) -> List[Dict[str, Union[int, str]]]: + """ + Retrieves the threads associated with a subprocess. + + Args: + process (multiprocessing.Process): The subprocess to check. + + Returns: + list: A list of dictionaries containing thread information. + """ + threads_info = [] + try: + for proc_thread in psutil.Process(process.pid).threads(): + threads_info.append( + { + "thread_id": proc_thread.id, + "name": f"Thread-{proc_thread.id}", + "status": self.get_thread_status_by_pid(proc_thread.id), + } + ) + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + return threads_info + + def get_thread_status_by_pid(self, thread_id: int) -> str: + """ + Attempts to retrieve the status of a thread by its PID. + + Args: + thread_id (int): The thread's ID. + + Returns: + str: The status of the thread ('running' or 'terminated'). + """ + try: + process = psutil.Process(thread_id) + if process.is_running(): + return "running" + return "terminated" + except psutil.NoSuchProcess: + return "terminated" + + def get_active_connections(self) -> List[Dict[str, Union[int, Dict]]]: + """ + Retrieves information about the active client connections to the ProxyServer. + + Returns: + list: A list of dictionaries containing information about active connections. + """ + return [ + {"thread_id": thread_id, **conn} + for thread_id, conn in self.proxy_server.active_connections.items() + ] diff --git a/pyproxy/monitoring/routes.py b/pyproxy/monitoring/routes.py new file mode 100644 index 0000000..d5aeb6c --- /dev/null +++ b/pyproxy/monitoring/routes.py @@ -0,0 +1,78 @@ +""" +pyproxy.monitoring.routes.py + +Defines and registers monitoring-related routes for the Flask application, +including endpoints for system information, configuration, and a secured +HTML-based index page. +""" + +from flask import jsonify, render_template + + +def register_routes(app, auth, proxy_server, ProxyMonitor): + """ + Registers the monitoring routes to the Flask app, secured with HTTP Basic Auth. + + Args: + app (Flask): The Flask application instance. + auth (HTTPBasicAuth): The HTTP Basic Auth instance used to secure routes. + proxy_server (ProxyServer): The running ProxyServer instance to monitor. + ProxyMonitor (class): The monitoring class used to gather runtime information. + """ + + @app.route("/") + @auth.login_required + def index(): + """ + Serves the main index HTML page for the monitoring dashboard. + + Returns: + Response: Rendered HTML page. + """ + return render_template("index.html") + + @app.route("/monitoring", methods=["GET"]) + @auth.login_required + def monitoring(): + """ + Provides real-time monitoring information about the ProxyServer, + including process, thread, and connection status. + + Returns: + Response: JSON object containing monitoring data. + """ + monitor = ProxyMonitor(proxy_server) + return jsonify(monitor.get_process_info()) + + @app.route("/config", methods=["GET"]) + @auth.login_required + def config(): + """ + Returns the current configuration of the ProxyServer, including + host, port, debug mode, and optional components like logger, filter, + and SSL configuration. + + Returns: + Response: JSON object containing configuration data. + """ + config_data = { + "host": proxy_server.host_port[0], + "port": proxy_server.host_port[1], + "debug": proxy_server.debug, + "html_403": proxy_server.html_403, + "logger_config": ( + proxy_server.logger_config.to_dict() + if proxy_server.logger_config + else None + ), + "filter_config": ( + proxy_server.filter_config.to_dict() + if proxy_server.filter_config + else None + ), + "ssl_config": ( + proxy_server.ssl_config.to_dict() if proxy_server.ssl_config else None + ), + "flask_port": proxy_server.monitoring_config.flask_port, + } + return jsonify(config_data) diff --git a/pyproxy/monitoring/static/monitoring.js b/pyproxy/monitoring/static/monitoring.js new file mode 100644 index 0000000..21f6e98 --- /dev/null +++ b/pyproxy/monitoring/static/monitoring.js @@ -0,0 +1,142 @@ +let countdown = 2; + +async function fetchAllData() { + try { + const [monitoringRes, configRes] = await Promise.all([ + fetch('/monitoring'), + fetch('/config') + ]); + + const monitoring = await monitoringRes.json(); + const config = await configRes.json(); + + document.getElementById('status-section').innerHTML = ` +
Name: ${monitoring.name}
+PID: ${monitoring.pid}
+Status: ${monitoring.status}
+Start Time: ${monitoring.start_time}
+ `; + + document.getElementById('subprocesses-section').innerHTML = ` +PID: ${proc.pid}
+Status: ${proc.status}
+No active connections.
' + : monitoring.active_connections.map(conn => ` +Client: ${conn.client_ip}:${conn.client_port}
+Target: ${conn.target_domain} (${conn.target_ip}:${conn.target_port})
+Sent: ${conn.bytes_sent} bytes
+Received: ${formatBytes(conn.bytes_received)}
+Port: ${config.port ? `${config.port}` : '✗'}
+Flask Port: ${config.flask_port ? `${config.flask_port}` : '✗'}
+HTML 403: ${config.html_403 ? `${config.html_403}` : '✗'}
+Blocked Sites File: ${config.filter_config.blocked_sites ? `${config.filter_config.blocked_sites}` : '✗'}
+Blocked URL File: ${config.filter_config.blocked_url ? `${config.filter_config.blocked_url}` : '✗'}
+Filter Mode: ${config.filter_config.filter_mode}
+Access Log: ${config.logger_config.no_logging_access ? '✗' : '✓'} ${config.logger_config.access_log ? `${config.logger_config.access_log}` : '✗'}
+Block Log: ${config.logger_config.no_logging_block ? '✗' : '✓'} ${config.logger_config.block_log ? `${config.logger_config.block_log}` : '✗'}
+Inspect CA Cert: ${config.ssl_config.inspect_ca_cert ? `${config.ssl_config.inspect_ca_cert}` : '✗'}
+Inspect CA Key: ${config.ssl_config.inspect_ca_key ? `${config.ssl_config.inspect_ca_key}` : '✗'}
+Inspect certs folder: ${config.ssl_config.inspect_certs_folder ? `${config.ssl_config.inspect_certs_folder}` : '✗'}
+Cancel inspect: ${config.ssl_config.cancel_inspect ? `${config.ssl_config.cancel_inspect}` : '✗'}
+ `; + + } catch (err) { + console.error('Error loading data:', err); + } + countdown = 2; +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; +} + +function updateCountdown() { + document.getElementById('refresh-timer').textContent = formatCountdown(countdown); +} + +function formatCountdown(seconds) { + const m = String(Math.floor(seconds / 60)).padStart(2, '0'); + const s = String(seconds % 60).padStart(2, '0'); + return `${m}:${s}`; +} + +setInterval(() => { + countdown--; + if (countdown <= 0) fetchAllData(); + updateCountdown(); +}, 1000); + +const tabs = document.querySelectorAll('.tab'); +const contents = document.querySelectorAll('.tab-content'); + +function activateTab(tab) { + tabs.forEach(t => { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + t.setAttribute('tabindex', '-1'); + }); + contents.forEach(c => c.classList.remove('active')); + + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + tab.setAttribute('tabindex', '0'); + const contentId = tab.getAttribute('aria-controls'); + document.getElementById(contentId).classList.add('active'); + + localStorage.setItem('activeTabId', tab.id); +} + +tabs.forEach(tab => { + tab.addEventListener('click', () => { + activateTab(tab); + }); + + tab.addEventListener('keydown', e => { + let index = Array.from(tabs).indexOf(e.target); + if (e.key === 'ArrowRight') { + index = (index + 1) % tabs.length; + tabs[index].focus(); + } else if (e.key === 'ArrowLeft') { + index = (index - 1 + tabs.length) % tabs.length; + tabs[index].focus(); + } + }); +}); + +window.addEventListener('DOMContentLoaded', () => { + const savedTabId = localStorage.getItem('activeTabId'); + if (savedTabId) { + const savedTab = document.getElementById(savedTabId); + if (savedTab) activateTab(savedTab); + } + activateTab(tabs[0]); + + fetchAllData(); + updateCountdown(); +}); diff --git a/pyproxy/monitoring/static/style.css b/pyproxy/monitoring/static/style.css index 0c8bae3..7941bb7 100644 --- a/pyproxy/monitoring/static/style.css +++ b/pyproxy/monitoring/static/style.css @@ -109,3 +109,35 @@ h1 { vertical-align: middle; font-style: italic; } + +.tabs { + display: flex; + gap: 20px; + margin-bottom: 25px; + cursor: pointer; + user-select: none; + border-bottom: 2px solid #ddd; +} + +.tab { + padding: 10px 20px; + border-radius: 6px 6px 0 0; + background: #eee; + color: #444; + font-weight: bold; + transition: background 0.3s; +} +.tab.active { + background: white; + border: 2px solid #ddd; + border-bottom: none; + color: #0066cc; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} \ No newline at end of file diff --git a/pyproxy/monitoring/templates/index.html b/pyproxy/monitoring/templates/index.html index 7cc65ae..812a100 100644 --- a/pyproxy/monitoring/templates/index.html +++ b/pyproxy/monitoring/templates/index.html @@ -1,114 +1,40 @@ - -Next refresh in: --:--
-