From 65e6c7e0f8a6c6fb95e610758b5c591d5fe4ebb8 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Sat, 9 Nov 2024 13:27:10 +0100 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 102 +++++++++++++++++++++++ env.example | 3 + main.py | 174 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + src/main.py | 69 ++++++++++++++++ src/proxy/logger.py | 20 +++++ src/proxy/tcp_handler.py | 104 +++++++++++++++++++++++ src/proxy/udp_handler.py | 50 +++++++++++ 9 files changed, 527 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 env.example create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/main.py create mode 100644 src/proxy/logger.py create mode 100644 src/proxy/tcp_handler.py create mode 100644 src/proxy/udp_handler.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3805df2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.log +*.log.* +__pycache__ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..83ac5c2 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# OProxy + +*High-performance, transparent proxy that supports both TCP and UDP protocols.* + +A high-performance, transparent proxy that supports both TCP and UDP protocols. + +**NOTE:** This proxy has been designed with local API proxy in mind. Specifically, I used it to forward Ollama API requests to the remote Ollama server for applications that try to connect to the local Ollama server on localhost. + +## Features + +- Transparent TCP proxying +- HTTP/HTTPS proxying without decrypting the traffic +- Optional UDP support +- Detailed logging capabilities +- Configurable through environment variables +- Support for both file and stdout logging +- Data content logging (optional) + +## Requirements + +- Python 3.7+ +- python-dotenv +- socket +- threading + +## Installation + +1. Clone the repository: + +```bash +git clone https://github.com/tcsenpai/oproxy.git + +cd oproxy +``` + +2. Install dependencies: + +```bash +pip install -r requirements.txt +``` + + +3. Copy the example environment file: + +```bash +cp .env.example .env +``` + + +4. Edit the .env file with your configuration: + +```bash +PROXY_PORT=11434 +TARGET_HOST=127.0.0.1 +TARGET_PORT=80 +``` + + +## Usage + +Basic TCP proxy: + +```bash +python src/main.py +``` + +Enable logging to file: + +```bash +python src/main.py --log-file=proxy.log +``` + +Enable data logging with debug level: + +```bash +python src/main.py --log-file proxy.log --log-data --log-level DEBUG +``` + +Enable UDP support: + +```bash +python src/main.py --enable-udp +``` + + +## Command Line Arguments + +- `--log-file`: Path to the log file +- `--log-data`: Enable logging of data content +- `--log-level`: Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +- `--enable-udp`: Enable UDP proxy alongside TCP + +## Notes + +- TCP proxy runs on the port specified in .env +- UDP proxy (if enabled) runs on PROXY_PORT + 1 +- Data logging should be used carefully as it may contain sensitive information +- UDP support is experimental and runs as a daemon thread + +## License + +MIT License \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..4f8d6b6 --- /dev/null +++ b/env.example @@ -0,0 +1,3 @@ +PROXY_PORT=11434 +TARGET_HOST=localhost +TARGET_PORT=11434 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..4dc7fab --- /dev/null +++ b/main.py @@ -0,0 +1,174 @@ +import socket +import threading +import os +from dotenv import load_dotenv +import logging +from datetime import datetime +import argparse +from collections import defaultdict + +# Load environment variables +load_dotenv() + +# Configuration +PROXY_HOST = '0.0.0.0' # Listen on all interfaces +PROXY_PORT = int(os.getenv('PROXY_PORT', 8080)) +TARGET_HOST = os.getenv('TARGET_HOST', 'localhost') +TARGET_PORT = int(os.getenv('TARGET_PORT', 80)) + +def setup_logging(log_file=None, log_level=logging.INFO): + # Configure logging format + log_format = '%(asctime)s - %(levelname)s - %(message)s' + + # Setup basic configuration + if log_file: + logging.basicConfig( + level=log_level, + format=log_format, + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler() # This will also print to stdout + ] + ) + else: + logging.basicConfig( + level=log_level, + format=log_format + ) + +def parse_args(): + parser = argparse.ArgumentParser(description='Transparent TCP/UDP Proxy with logging capabilities') + parser.add_argument('--log-file', type=str, help='Path to the log file') + parser.add_argument('--log-data', action='store_true', help='Enable logging of data content') + parser.add_argument('--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default='INFO', + help='Set the logging level') + parser.add_argument('--enable-udp', action='store_true', help='Enable UDP proxy alongside TCP') + return parser.parse_args() + +def handle_tcp_client(client_socket, log_data=False): + client_address = client_socket.getpeername() + logging.info(f"New connection from {client_address[0]}:{client_address[1]}") + + # Connect to target server + target_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + target_socket.connect((TARGET_HOST, TARGET_PORT)) + logging.info(f"Connected to target {TARGET_HOST}:{TARGET_PORT}") + + def forward(source, destination, direction): + try: + while True: + data = source.recv(4096) + if not data: + break + if log_data: + print("[INFO] Logging data is enabled") + src = source.getpeername() + dst = destination.getpeername() + timestamp = datetime.now().isoformat() + logging.debug(f"[{direction}] {src[0]}:{src[1]} -> {dst[0]}:{dst[1]}") + logging.debug(f"Data: {data[:1024]!r}...") # Log first 1KB of data + destination.send(data) + except Exception as e: + logging.error(f"Error in {direction}: {str(e)}") + finally: + source.close() + destination.close() + logging.info(f"Connection closed ({direction})") + + # Create two threads for bidirectional communication + client_to_target = threading.Thread( + target=forward, + args=(client_socket, target_socket, "CLIENT->TARGET") + ) + target_to_client = threading.Thread( + target=forward, + args=(target_socket, client_socket, "TARGET->CLIENT") + ) + + client_to_target.start() + target_to_client.start() + +def handle_udp_proxy(log_data=False): + udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_socket.bind((PROXY_HOST, PROXY_PORT + 1)) # Use next port for UDP + + clients = defaultdict(dict) + + logging.info(f"UDP proxy listening on {PROXY_HOST}:{PROXY_PORT + 1}") + + while True: + try: + data, client_addr = udp_socket.recvfrom(4096) + if log_data: + logging.debug(f"UDP: {client_addr} -> {TARGET_HOST}:{TARGET_PORT}") + logging.debug(f"Data: {data[:1024]!r}...") + + # Forward to target + target_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + target_socket.sendto(data, (TARGET_HOST, TARGET_PORT)) + + # Store socket for this client + clients[client_addr]['socket'] = target_socket + clients[client_addr]['target'] = (TARGET_HOST, TARGET_PORT) + + # Handle response in a separate thread to not block + def handle_response(client_addr, target_socket): + try: + response, _ = target_socket.recvfrom(4096) + udp_socket.sendto(response, client_addr) + if log_data: + logging.debug(f"UDP Response: {TARGET_HOST}:{TARGET_PORT} -> {client_addr}") + except Exception as e: + logging.error(f"UDP Response Error: {str(e)}") + finally: + target_socket.close() + + threading.Thread(target=handle_response, + args=(client_addr, target_socket)).start() + + except Exception as e: + logging.error(f"UDP Error: {str(e)}") + +def main(): + # Parse command line arguments + args = parse_args() + + # Setup logging + log_level = getattr(logging, args.log_level) + setup_logging(args.log_file, log_level) + + # Start TCP proxy (main functionality) + tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_server.bind((PROXY_HOST, PROXY_PORT)) + tcp_server.listen(100) + + logging.info(f"TCP proxy listening on {PROXY_HOST}:{PROXY_PORT}") + logging.info(f"Forwarding to {TARGET_HOST}:{TARGET_PORT}") + logging.info(f"Logging level: {args.log_level}") + if args.log_file: + logging.info(f"Logging to file: {args.log_file}") + if args.log_data: + logging.info("Data logging is enabled") + + # Start UDP proxy if enabled + if args.enable_udp: + udp_thread = threading.Thread(target=handle_udp_proxy, + args=(args.log_data,), + daemon=True) + udp_thread.start() + logging.info("UDP proxy enabled") + + # Main TCP loop + while True: + client_socket, addr = tcp_server.accept() + proxy_thread = threading.Thread( + target=handle_tcp_client, + args=(client_socket, args.log_data) + ) + proxy_thread.start() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e338bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-dotenv \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..98d4234 --- /dev/null +++ b/src/main.py @@ -0,0 +1,69 @@ +import os +import argparse +from dotenv import load_dotenv +import socket +import threading +import logging + +from proxy.logger import setup_logging +from proxy.tcp_handler import TCPHandler +from proxy.udp_handler import UDPHandler + +def parse_args(): + parser = argparse.ArgumentParser(description='Transparent TCP/UDP Proxy with logging capabilities') + parser.add_argument('--log-file', type=str, help='Path to the log file') + parser.add_argument('--log-data', action='store_true', help='Enable logging of data content') + parser.add_argument('--full-debug', action='store_true', help='Enable full data logging (entire payload)') + parser.add_argument('--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default='INFO', + help='Set the logging level') + parser.add_argument('--enable-udp', action='store_true', help='Enable UDP proxy alongside TCP') + return parser.parse_args() + +def main(): + # Load configuration + load_dotenv() + PROXY_HOST = '0.0.0.0' + PROXY_PORT = int(os.getenv('PROXY_PORT', 8080)) + TARGET_HOST = os.getenv('TARGET_HOST', 'localhost') + TARGET_PORT = int(os.getenv('TARGET_PORT', 80)) + + # Parse arguments and setup logging + args = parse_args() + setup_logging(args.log_file, getattr(logging, args.log_level)) + + # Initialize TCP handler + tcp_handler = TCPHandler(TARGET_HOST, TARGET_PORT) + + # Setup TCP server + tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + tcp_server.bind((PROXY_HOST, PROXY_PORT)) + tcp_server.listen(100) + + logging.info(f"TCP proxy listening on {PROXY_HOST}:{PROXY_PORT}") + logging.info(f"Forwarding to {TARGET_HOST}:{TARGET_PORT}") + + # Start UDP handler if enabled + if args.enable_udp: + udp_handler = UDPHandler(PROXY_HOST, PROXY_PORT, TARGET_HOST, TARGET_PORT) + udp_thread = threading.Thread( + target=udp_handler.start, + args=(args.log_data,), + daemon=True + ) + udp_thread.start() + logging.info("UDP proxy enabled") + + # Main TCP loop + while True: + client_socket, addr = tcp_server.accept() + proxy_thread = threading.Thread( + target=tcp_handler.handle_client, + args=(client_socket, args.log_data, args.full_debug) + ) + proxy_thread.start() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/proxy/logger.py b/src/proxy/logger.py new file mode 100644 index 0000000..ceb2bc7 --- /dev/null +++ b/src/proxy/logger.py @@ -0,0 +1,20 @@ +import logging +from typing import Optional + +def setup_logging(log_file: Optional[str] = None, log_level: int = logging.INFO) -> None: + log_format = '%(asctime)s - %(levelname)s - %(message)s' + + if log_file: + logging.basicConfig( + level=log_level, + format=log_format, + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler() + ] + ) + else: + logging.basicConfig( + level=log_level, + format=log_format + ) \ No newline at end of file diff --git a/src/proxy/tcp_handler.py b/src/proxy/tcp_handler.py new file mode 100644 index 0000000..1b8e5dd --- /dev/null +++ b/src/proxy/tcp_handler.py @@ -0,0 +1,104 @@ +import socket +import threading +import logging +from datetime import datetime +from typing import Tuple, Optional + +class TCPHandler: + def __init__(self, target_host: str, target_port: int): + self.target_host = target_host + self.target_port = target_port + + def log_data_content(self, data: bytes, src: tuple, dst: tuple, direction: str, full_debug: bool = False) -> None: + try: + # Always log basic info + logging.info(f"[{direction}] {src[0]}:{src[1]} -> {dst[0]}:{dst[1]} ({len(data)} bytes)") + + # Attempt to decode the data + if all(32 <= byte <= 126 or byte in (9, 10, 13) for byte in data[:100]): + decoded = data.decode('utf-8', errors='replace') + if full_debug: + # Log the entire payload with clear separators + logging.debug("="*50) + logging.debug(f"FULL DATA [{direction}] START") + logging.debug("="*50) + logging.debug(decoded) + logging.debug("="*50) + logging.debug(f"FULL DATA [{direction}] END") + logging.debug("="*50) + else: + # Log just a preview + logging.debug(f"Data preview: {decoded[:200]}...") + else: + if full_debug: + # For binary data, log the full hex dump + logging.debug("="*50) + logging.debug(f"FULL BINARY DATA [{direction}] START") + logging.debug("="*50) + logging.debug(' '.join(f'{byte:02x}' for byte in data)) + logging.debug("="*50) + logging.debug(f"FULL BINARY DATA [{direction}] END") + logging.debug("="*50) + else: + logging.debug(f"Binary data: {len(data)} bytes transferred") + except Exception as decode_err: + logging.debug(f"Could not decode data: {decode_err}") + + def forward(self, source: socket.socket, destination: socket.socket, + direction: str, log_data: bool, full_debug: bool = False) -> None: + total_bytes = 0 + try: + while True: + data = source.recv(4096) + if not data: + break + total_bytes += len(data) + destination.send(data) + + if log_data: + src = source.getpeername() + dst = destination.getpeername() + self.log_data_content(data, src, dst, direction, full_debug) + + except Exception as e: + logging.error(f"Error in {direction}: {str(e)}") + finally: + logging.info(f"Connection closed ({direction}). Total bytes transferred: {total_bytes}") + try: + source.close() + destination.close() + except: + pass + + def handle_client(self, client_socket: socket.socket, log_data: bool, full_debug: bool = False) -> None: + try: + client_address = client_socket.getpeername() + logging.info(f"New connection from {client_address[0]}:{client_address[1]}") + + target_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + target_socket.connect((self.target_host, self.target_port)) + logging.info(f"Connected to target {self.target_host}:{self.target_port}") + + client_to_target = threading.Thread( + target=self.forward, + args=(client_socket, target_socket, "CLIENT->TARGET", log_data, full_debug), + name="ClientToTarget" + ) + target_to_client = threading.Thread( + target=self.forward, + args=(target_socket, client_socket, "TARGET->CLIENT", log_data, full_debug), + name="TargetToClient" + ) + + client_to_target.start() + target_to_client.start() + + client_to_target.join() + target_to_client.join() + + except Exception as e: + logging.error(f"Error in handle_client: {str(e)}") + try: + client_socket.close() + except: + pass \ No newline at end of file diff --git a/src/proxy/udp_handler.py b/src/proxy/udp_handler.py new file mode 100644 index 0000000..510e895 --- /dev/null +++ b/src/proxy/udp_handler.py @@ -0,0 +1,50 @@ +import socket +import threading +import logging +from collections import defaultdict +from typing import Dict, Any + +class UDPHandler: + def __init__(self, proxy_host: str, proxy_port: int, + target_host: str, target_port: int): + self.proxy_host = proxy_host + self.proxy_port = proxy_port + 1 # UDP uses next port + self.target_host = target_host + self.target_port = target_port + self.clients: Dict[Any, dict] = defaultdict(dict) + + def handle_response(self, client_addr: tuple, target_socket: socket.socket, + udp_socket: socket.socket, log_data: bool) -> None: + try: + response, _ = target_socket.recvfrom(4096) + udp_socket.sendto(response, client_addr) + if log_data: + logging.debug(f"UDP Response: {self.target_host}:{self.target_port} -> {client_addr}") + except Exception as e: + logging.error(f"UDP Response Error: {str(e)}") + finally: + target_socket.close() + + def start(self, log_data: bool) -> None: + udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_socket.bind((self.proxy_host, self.proxy_port)) + + logging.info(f"UDP proxy listening on {self.proxy_host}:{self.proxy_port}") + + while True: + try: + data, client_addr = udp_socket.recvfrom(4096) + if log_data: + logging.debug(f"UDP: {client_addr} -> {self.target_host}:{self.target_port}") + logging.debug(f"Data: {data[:1024]!r}...") + + target_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + target_socket.sendto(data, (self.target_host, self.target_port)) + + threading.Thread( + target=self.handle_response, + args=(client_addr, target_socket, udp_socket, log_data) + ).start() + + except Exception as e: + logging.error(f"UDP Error: {str(e)}") \ No newline at end of file