From 7f115c01024b86e8ef64e161a5807e2492a92b83 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 5 Oct 2025 15:42:30 -0500 Subject: [PATCH 1/7] Implement Custom Interface --- HTTPInterface.py | 333 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 74 ++++++----- http_interface.py | 310 ------------------------------------------ 3 files changed, 376 insertions(+), 341 deletions(-) create mode 100644 HTTPInterface.py delete mode 100644 http_interface.py diff --git a/HTTPInterface.py b/HTTPInterface.py new file mode 100644 index 0000000..213e206 --- /dev/null +++ b/HTTPInterface.py @@ -0,0 +1,333 @@ +"""RNS-over-HTTP Interface + +Custom HTTP interface for Reticulum that implements bidirectional communication using HTTP POST requests. +""" + +import importlib +from http.server import BaseHTTPRequestHandler, HTTPServer +from queue import Empty, Queue +from socketserver import ThreadingMixIn +from threading import Event, Thread +from time import sleep + +import RNS +from RNS.Interfaces.Interface import Interface + +MTU = 4096 +DEFAULT_USER_AGENT = "RNS-HTTP-Tunnel/1.0" + + +class HTTPInterface(Interface): + """HTTPInterface provides a Reticulum interface over HTTP using bidirectional communication via HTTP POST requests.""" + + DEFAULT_IFAC_SIZE = 8 + + owner = None + mode = None + listen_host = None + listen_port = None + server_url = None + poll_interval = None + check_user_agent = None + _recv_queue = None + _send_queue = None + _stop_event = None + _worker_thread = None + _http_server = None + session = None + _consecutive_failures = None + _max_backoff = None + + def __init__(self, owner, configuration): + """Initialize the HTTPInterface with the given owner and configuration.""" + if importlib.util.find_spec("requests") is None: + RNS.log( + "Using this interface requires the requests module to be installed.", + RNS.LOG_CRITICAL, + ) + RNS.log( + "You can install it with the command: python3 -m pip install requests", + RNS.LOG_CRITICAL, + ) + RNS.panic() + + import requests + + super().__init__() + + ifconf = Interface.get_config_obj(configuration) + + name = ifconf["name"] + self.name = name + + mode = ifconf["mode"] if "mode" in ifconf else "client" + listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0" + listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080 + server_url = ifconf["server_url"] if "server_url" in ifconf else None + poll_interval = ( + float(ifconf["poll_interval"]) if "poll_interval" in ifconf else 1.0 + ) + check_user_agent = ( + bool(ifconf["check_user_agent"]) if "check_user_agent" in ifconf else True + ) + user_agent = ( + ifconf["user_agent"] if "user_agent" in ifconf else DEFAULT_USER_AGENT + ) + mtu = int(ifconf["mtu"]) if "mtu" in ifconf else MTU + + if mode not in ["server", "client"]: + raise ValueError(f"Invalid mode '{mode}'. Must be 'server' or 'client'") + if mode == "client" and server_url is None: + raise ValueError("server_url is required for client mode") + + self.HW_MTU = mtu + + self.online = False + self.bitrate = 1000000 + + self.owner = owner + self.mode = mode + self.listen_host = listen_host + self.listen_port = listen_port + self.server_url = server_url + self.poll_interval = poll_interval + self.check_user_agent = check_user_agent + self.user_agent = user_agent + + self._recv_queue = Queue() + self._send_queue = Queue() + self._stop_event = Event() + + if self.mode == "server": + self._http_server = None + self._request_handler_class = self._create_request_handler() + else: + self.session = requests.Session() + self.session.headers.update({"User-Agent": self.user_agent}) + self._consecutive_failures = 0 + self._max_backoff = 30.0 + + try: + self._start_interface() + self._read_thread = Thread(target=self._read_loop, daemon=True) + self._read_thread.start() + except Exception as e: + RNS.log("Could not start HTTP interface " + str(self), RNS.LOG_ERROR) + raise e + + def _create_request_handler(self): + """Create a custom HTTP request handler class for the server mode.""" + interface_instance = self + + class TunnelRequestHandler(BaseHTTPRequestHandler): + """Handles HTTP POST requests for the HTTPInterface server.""" + + def __init__(self, request, client_address, server): + self.interface = interface_instance + super().__init__(request, client_address, server) + + def do_POST(self): + """Handle HTTP POST requests for bidirectional data exchange.""" + if self.path == "/": + if self.interface.check_user_agent: + user_agent = self.headers.get("User-Agent", "") + if user_agent != self.interface.user_agent: + RNS.log( + f"Rejected request with invalid User-Agent: {user_agent}", + RNS.LOG_WARNING, + ) + self.send_response(403) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"Forbidden") + return + + content_length = int(self.headers.get("Content-Length", 0)) + client_data = ( + self.rfile.read(content_length) if content_length > 0 else b"" + ) + + if client_data: + RNS.log( + f"Received {len(client_data)} bytes from client", + RNS.LOG_DEBUG, + ) + self.interface._recv_queue.put(client_data) + + server_data_parts = [] + while True: + try: + server_data_parts.append( + self.interface._send_queue.get_nowait(), + ) + except Empty: + break + + server_data = b"".join(server_data_parts) + if server_data: + RNS.log( + f"Sending {len(server_data)} bytes ({len(server_data_parts)} chunks) to client", + RNS.LOG_DEBUG, + ) + + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(server_data))) + self.end_headers() + self.wfile.write(server_data) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, fmt, *args): + """Override to suppress default logging.""" + + return TunnelRequestHandler + + def _start_interface(self): + """Start the interface in either server or client mode.""" + if self.mode == "server": + self._start_server() + else: + self._start_client() + + def _start_server(self): + """Start the HTTP server in a separate thread.""" + + class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + """Threaded HTTP server for handling multiple connections.""" + + def run_server(): + """Run the HTTP server and handle requests.""" + try: + self._http_server = ThreadedHTTPServer( + (self.listen_host, self.listen_port), + self._request_handler_class, + ) + self.online = True + RNS.log( + f"HTTP server started on http://{self.listen_host}:{self.listen_port}", + RNS.LOG_INFO, + ) + self._http_server.serve_forever() + except Exception as e: + if not self._stop_event.is_set(): + RNS.log(f"Server error: {e}", RNS.LOG_ERROR) + self.online = False + + try: + self._http_server = ThreadedHTTPServer( + (self.listen_host, self.listen_port), + self._request_handler_class, + ) + self.online = True + RNS.log( + f"HTTP server started on http://{self.listen_host}:{self.listen_port}", + RNS.LOG_INFO, + ) + self._worker_thread = Thread( + target=self._http_server.serve_forever, daemon=True + ) + self._worker_thread.start() + except Exception as e: + RNS.log(f"Could not start HTTP server: {e}", RNS.LOG_ERROR) + self.online = False + raise e + + def _start_client(self): + """Start the HTTP client loop in a separate thread.""" + self._worker_thread = Thread(target=self._client_loop, daemon=True) + self._worker_thread.start() + self.online = True + RNS.log(f"HTTP client started, connecting to {self.server_url}", RNS.LOG_INFO) + + def _client_loop(self): + """Main loop for the HTTP client, handling polling and data transfer.""" + while not self._stop_event.is_set(): + data_to_send = b"" + if not self._send_queue.empty(): + from contextlib import suppress + + with suppress(Empty): + data_to_send = self._send_queue.get_nowait() + + try: + RNS.log(f"Sending {len(data_to_send)} bytes to server", RNS.LOG_DEBUG) + response = self.session.post( + self.server_url, + data=data_to_send, + timeout=5, + ) + response.raise_for_status() + + if response.content: + RNS.log( + f"Received {len(response.content)} bytes from server", + RNS.LOG_DEBUG, + ) + self._recv_queue.put(response.content) + + if self._consecutive_failures > 0: + RNS.log("Reconnected to server", RNS.LOG_INFO) + self._consecutive_failures = 0 + + except Exception as e: + self._consecutive_failures += 1 + if self._consecutive_failures % 10 == 1: + RNS.log( + f"Error communicating with server (attempt {self._consecutive_failures}): {e}", + RNS.LOG_ERROR, + ) + + if self._consecutive_failures > 0: + delay = min( + self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)), + self._max_backoff, + ) + else: + delay = self.poll_interval + + sleep(delay) + + def process_incoming(self, data): + """Process incoming data received from the underlying medium.""" + self.rxb += len(data) + self.owner.inbound(data, self) + + def process_outgoing(self, data): + """Process outgoing data to be transmitted by the interface.""" + if self.online: + if len(data) > self.HW_MTU: + RNS.log( + f"Packet too large ({len(data)} > {self.HW_MTU}), dropping", + RNS.LOG_WARNING, + ) + return + + self._send_queue.put(data) + self.txb += len(data) + + def should_ingress_limit(self): + """Indicate that this interface should not perform ingress limiting.""" + return False + + def __str__(self): + """Return a string representation of the HTTPInterface.""" + return f"HTTPInterface[{self.name}]" + + def _read_loop(self): + """Read loop that processes incoming data from the queue.""" + try: + while not self._stop_event.is_set(): + try: + data = self._recv_queue.get(timeout=0.1) + self.process_incoming(data) + except Empty: + continue + except Exception as e: + if not self._stop_event.is_set(): + RNS.log(f"Error in read loop: {e}", RNS.LOG_ERROR) + self.online = False + + +interface_class = HTTPInterface diff --git a/README.md b/README.md index 0173066..9f8b872 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [Русский](README-RU.md) -A Reticulum interface that tunnels traffic over standard HTTP/S POST requests. This allows Reticulum to operate on networks where only web traffic is permitted, effectively bypassing firewalls, DPI, and other restrictions. +A custom Reticulum interface that tunnels traffic over standard HTTP/S POST requests. This allows Reticulum to operate on networks where only web traffic is permitted, effectively bypassing firewalls, DPI, and other restrictions. [Non-GitHub Mirror](https://lavaforge.org/Ivan/RNS-over-HTTP). Also available on the network `RNS-over-HTTP` node. @@ -40,35 +40,37 @@ This continuous cycle creates a reliable, albeit higher-latency, communication c ### Requirements - Python 3.9 or later -- `pip` for installing packages +- `rns` +- `requests` ### Installation -1. **Install the `requests` library if not already installed:** +1. **Install Reticulum and dependencies:** ```bash - pip install requests + pip install rns requests ``` -2. **Download the interface script:** - Place `http_interface.py` in a known location on both your client and server machines, for example, `~/.reticulum/interfaces/`. +2. **Install the custom interface:** + Place `http_interface.py` in your Reticulum interfaces directory: `~/.reticulum/interfaces/`. ## Configuration -Set up a `PipeInterface` in your `~/.reticulum/config` file on both the server and client machines. +Add an interface entry to your Reticulum configuration file (`~/.reticulum/config`) on both the server and client machines. ### Server Configuration The server listens for incoming connections from clients. ```ini -[[HTTP Interface]] - type = PipeInterface - enabled = True - # The command to run the server script. Listens on all interfaces by default. - command = python3 /path/to/your/http_interface.py server --host 0.0.0.0 --port 8080 - # Optional: delay before respawning the interface if it crashes. - respawn_delay = 5 - name = HTTP Interface Server +[[HTTP Server Interface]] + type = HTTPInterface + enabled = true + mode = server + listen_host = 0.0.0.0 + listen_port = 8080 + mtu = 4096 + check_user_agent = true + user_agent = RNS-HTTP-Tunnel/1.0 ``` ### Client Configuration @@ -76,26 +78,36 @@ The server listens for incoming connections from clients. The client connects to the server's public URL. ```ini -[[HTTP Interface]] - type = PipeInterface - enabled = True - # The command to run the client script. Point --url to your server. - command = python3 /path/to/your/http_interface.py client --url http:// - # Optional: delay before respawning the interface if it crashes. - respawn_delay = 5 - name = HTTP Interface Client +[[HTTP Client Interface]] + type = HTTPInterface + enabled = true + mode = client + server_url = http://your-server-ip-or-domain:8080 + poll_interval = 1.0 + mtu = 4096 + user_agent = RNS-HTTP-Tunnel/1.0 ``` -## Command-Line Options +## Configuration Options -You can customize the behavior of the script with these arguments: +### Common Options -- `--mtu`: Maximum Transmission Unit in bytes (default: `4096`). -- `--poll-interval`: Client polling interval in seconds (default: `0.1`). -- `--verbose` or `-v`: Enable verbose debug logging. -- `--host`: Server listen host (default: `0.0.0.0`). -- `--port`: Server listen port (default: `8080`). -- `--disable-user-agent-check`: Disable User-Agent validation on the server. +- `mtu`: Maximum Transmission Unit in bytes (default: `4096`). +- `name`: Interface name for logging and identification. +- `user_agent`: User-Agent string to use for HTTP requests (default: `"RNS-HTTP-Tunnel/1.0"`). + +### Server Mode Options + +- `mode`: Must be set to `server`. +- `listen_host`: Host to bind the HTTP server to (default: `0.0.0.0`). +- `listen_port`: Port to listen on (default: `8080`). +- `check_user_agent`: Whether to validate User-Agent headers (default: `true`). + +### Client Mode Options + +- `mode`: Must be set to `client`. +- `server_url`: Full URL of the server to connect to (required for client mode). +- `poll_interval`: Polling interval in seconds (default: `1.0`). ## Reverse Proxy Setup (Caddy Example) diff --git a/http_interface.py b/http_interface.py deleted file mode 100644 index 919dd6e..0000000 --- a/http_interface.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -RNS-over-HTTP Interface - -HTTP interface for Reticulum that implements bidirectional communication using HTTP POST requests. -""" - -import logging -import logging.handlers -import sys -import threading -from abc import ABC, abstractmethod -from queue import Queue, Empty -from threading import Thread, Event -from time import sleep -from typing import Iterable -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn -import socket - -import requests - -MTU = 4096 -TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0" - -def setup_logging(): - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - - console_handler = logging.StreamHandler(sys.stderr) - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(formatter) - - logger.addHandler(console_handler) - -setup_logging() - -class AbstractTunnel(ABC): - def __init__(self, mtu: int): - self.mtu = mtu - self._recv_queue: Queue[bytes] = Queue() - self._send_queue: Queue[bytes] = Queue() - self._stop_event = Event() - self.logger = logging.getLogger(self.__class__.__name__) - - def send(self, pkt: bytes) -> None: - if len(pkt) > self.mtu: - raise ValueError(f"payload too large ({len(pkt)} > {self.mtu})") - self._send_queue.put(pkt) - - def recv(self) -> Iterable[bytes]: - while True: - yield self._recv_queue.get(block=True) - - @abstractmethod - def start(self) -> None: - pass - - @abstractmethod - def stop(self) -> None: - pass - -class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - pass - -class Server(AbstractTunnel): - def __init__(self, listen_host: str, listen_port: int, mtu: int, check_user_agent: bool = True): - super().__init__(mtu) - self.listen_host = listen_host - self.listen_port = listen_port - self.check_user_agent = check_user_agent - self._server_thread: Thread | None = None - self._http_server: HTTPServer | None = None - - class TunnelRequestHandler(BaseHTTPRequestHandler): - def __init__(self, request, client_address, server, tunnel_instance=self): - self.tunnel = tunnel_instance - super().__init__(request, client_address, server) - - def do_POST(self): - if self.path == "/": - if self.tunnel.check_user_agent: - user_agent = self.headers.get('User-Agent', '') - if user_agent != TUNNEL_USER_AGENT: - self.tunnel.logger.warning(f"Rejected request with invalid User-Agent: {user_agent}") - self.send_response(403) - self.send_header('Content-Type', 'text/plain') - self.end_headers() - self.wfile.write(b'Forbidden') - return - - content_length = int(self.headers.get('Content-Length', 0)) - client_data = self.rfile.read(content_length) if content_length > 0 else b"" - - if client_data: - self.tunnel.logger.debug(f"Received {len(client_data)} bytes from client") - self.tunnel._recv_queue.put(client_data) - - server_data_parts = [] - while not self.tunnel._send_queue.empty(): - try: - server_data_parts.append(self.tunnel._send_queue.get_nowait()) - except Empty: - break - - server_data = b"".join(server_data_parts) - if server_data: - self.tunnel.logger.debug(f"Sending {len(server_data)} bytes ({len(server_data_parts)} chunks) to client") - - self.send_response(200) - self.send_header('Content-Type', 'application/octet-stream') - self.send_header('Content-Length', str(len(server_data))) - self.end_headers() - self.wfile.write(server_data) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - pass - - self._request_handler_class = TunnelRequestHandler - - def start(self) -> None: - def run_server(): - try: - self._http_server = ThreadedHTTPServer((self.listen_host, self.listen_port), self._request_handler_class) - self._http_server.serve_forever() - except Exception as e: - if not self._stop_event.is_set(): - self.logger.error(f"Server error: {e}") - - self._server_thread = Thread(target=run_server, daemon=True) - self._server_thread.start() - self.logger.info(f"HTTP server started on http://{self.listen_host}:{self.listen_port}") - - def stop(self) -> None: - self.logger.info("Stopping HTTP server...") - self._stop_event.set() - if self._http_server: - self._http_server.shutdown() - self._http_server.server_close() - if self._server_thread: - self._server_thread.join(timeout=2) - -class Client(AbstractTunnel): - def __init__(self, server_url: str, mtu: int, poll_interval: float = 1.0): - super().__init__(mtu) - self.server_url = server_url - self.poll_interval = poll_interval - self._client_thread: Thread | None = None - self.session = requests.Session() - self.session.headers.update({'User-Agent': TUNNEL_USER_AGENT}) - self._consecutive_failures = 0 - self._max_backoff = 30.0 # Maximum backoff time in seconds - - def start(self) -> None: - self._client_thread = Thread(target=self._run, daemon=True) - self._client_thread.start() - self.logger.info(f"HTTP client started, connecting to {self.server_url}") - - def stop(self) -> None: - self.logger.info("Stopping HTTP client...") - self._stop_event.set() - if self._client_thread: - self._client_thread.join(timeout=2) - - def _run(self): - while not self._stop_event.is_set(): - data_to_send = b"" - if not self._send_queue.empty(): - try: - data_to_send = self._send_queue.get_nowait() - except Empty: - pass - - try: - self.logger.debug(f"Sending {len(data_to_send)} bytes to server") - response = self.session.post(self.server_url, data=data_to_send, timeout=5) - response.raise_for_status() - - if response.content: - self.logger.debug(f"Received {len(response.content)} bytes from server") - self._recv_queue.put(response.content) - - if self._consecutive_failures > 0: - self.logger.info("Reconnected to server") - self._consecutive_failures = 0 - - except requests.exceptions.RequestException as e: - self._consecutive_failures += 1 - if self._consecutive_failures % 10 == 1: - self.logger.error(f"Error communicating with server (attempt {self._consecutive_failures}): {e}") - - if self._consecutive_failures > 0: - delay = min(self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)), self._max_backoff) - else: - delay = self.poll_interval - - sleep(delay) - - -if __name__ == "__main__": - import argparse - import os - import errno - - logger = logging.getLogger(__name__) - - parser = argparse.ArgumentParser(description="HTTP Tunnel - Server/Client") - parser.add_argument("mode", choices=["server", "client"], help="Run mode: server or client") - parser.add_argument("--mtu", type=int, default=MTU, help=f"MTU size (default: {MTU})") - parser.add_argument("--host", type=str, default="0.0.0.0", help="Listen host (for server mode)") - parser.add_argument("--port", type=int, default=8080, help="Listen port (for server mode)") - parser.add_argument("--url", type=str, help="Server URL (required for client mode)") - parser.add_argument("--poll-interval", type=float, default=0.1, help="Client poll interval in seconds") - parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") - parser.add_argument("--disable-user-agent-check", action="store_true", help="Disable User-Agent validation (server mode only)") - - args = parser.parse_args() - - if args.verbose: - for handler in logging.getLogger().handlers: - if isinstance(handler, logging.StreamHandler): - handler.setLevel(logging.DEBUG) - - def receive_messages(tunnel, stop_event): - stdout_fd = sys.stdout.fileno() - try: - for received_data in tunnel.recv(): - if stop_event.is_set(): - break - if received_data: - try: - os.write(stdout_fd, received_data) - except OSError as e: - if e.errno == errno.EPIPE: - stop_event.set() - return - else: - logger.error("Error writing to stdout: %s", e) - stop_event.set() - return - except Exception: - if not stop_event.is_set(): - logger.error("Error in receive thread", exc_info=True) - return - - def read_stdin_bytes(): - try: - return os.read(0, 4096) - except (IOError, OSError): - return None - - try: - if args.mode == "server": - server = Server(listen_host=args.host, listen_port=args.port, mtu=args.mtu, check_user_agent=not args.disable_user_agent_check) - server.start() - - stop_event = threading.Event() - receive_thread = threading.Thread(target=receive_messages, args=(server, stop_event), daemon=True) - receive_thread.start() - - try: - while not stop_event.is_set(): - message = read_stdin_bytes() - if message: - server.send(message) - else: - sleep(0.01) - - except KeyboardInterrupt: - logger.info("Stopping server...") - finally: - stop_event.set() - server.stop() - receive_thread.join(timeout=1) - - elif args.mode == "client": - if not args.url: - parser.error("--url is required for client mode") - - client = Client(server_url=args.url, mtu=args.mtu, poll_interval=args.poll_interval) - client.start() - - stop_event = threading.Event() - receive_thread = threading.Thread(target=receive_messages, args=(client, stop_event), daemon=True) - receive_thread.start() - - try: - while not stop_event.is_set(): - message = read_stdin_bytes() - if message: - client.send(message) - else: - sleep(0.01) - - except KeyboardInterrupt: - logger.info("Stopping client...") - finally: - stop_event.set() - client.stop() - receive_thread.join(timeout=1) - - except Exception as e: - logger.error(f"A critical error occurred: {e}", exc_info=True) - sys.exit(1) -- 2.49.1 From 7d4a5b5da2de9fc0cb15d9a7021d24b316fd7a08 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 5 Oct 2025 16:16:20 -0500 Subject: [PATCH 2/7] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f8b872..6343fbe 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ This continuous cycle creates a reliable, albeit higher-latency, communication c ``` 2. **Install the custom interface:** - Place `http_interface.py` in your Reticulum interfaces directory: `~/.reticulum/interfaces/`. + Place `HTTPInterface.py` in your Reticulum interfaces directory: `~/.reticulum/interfaces/`. ## Configuration -- 2.49.1 From 447492dc88ee94f330bf4406426f435ce7fb85a1 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 19 Nov 2025 22:15:23 -0600 Subject: [PATCH 3/7] Update Custom Interface code --- HTTPInterface.py | 391 ++++++++++++++++++++++------------------------- 1 file changed, 183 insertions(+), 208 deletions(-) mode change 100644 => 100755 HTTPInterface.py diff --git a/HTTPInterface.py b/HTTPInterface.py old mode 100644 new mode 100755 index 213e206..00202a6 --- a/HTTPInterface.py +++ b/HTTPInterface.py @@ -1,141 +1,153 @@ -"""RNS-over-HTTP Interface - -Custom HTTP interface for Reticulum that implements bidirectional communication using HTTP POST requests. -""" - -import importlib +import os +import threading +import time from http.server import BaseHTTPRequestHandler, HTTPServer from queue import Empty, Queue from socketserver import ThreadingMixIn from threading import Event, Thread -from time import sleep +import requests import RNS from RNS.Interfaces.Interface import Interface -MTU = 4096 -DEFAULT_USER_AGENT = "RNS-HTTP-Tunnel/1.0" +class HTTPTunnelInterface(Interface): + """HTTP Tunnel Interface for Reticulum. -class HTTPInterface(Interface): - """HTTPInterface provides a Reticulum interface over HTTP using bidirectional communication via HTTP POST requests.""" + This interface implements bidirectional communication over HTTP POST requests, + allowing Reticulum to traverse firewalls and proxies that allow HTTP/HTTPS traffic. - DEFAULT_IFAC_SIZE = 8 + Configuration: + mode: "client" or "server" + listen_host: IP address to bind server to (server mode) + listen_port: Port to bind server to (server mode) + server_url: URL of the HTTP server (client mode) + poll_interval: Polling interval in seconds for client (default: 0.1) + check_user_agent: Enable User-Agent validation (default: True) + serve_html_page: Serve HTML page on GET requests (default: False) + html_file_path: Path to HTML file to serve (optional) - owner = None - mode = None - listen_host = None - listen_port = None - server_url = None - poll_interval = None - check_user_agent = None - _recv_queue = None - _send_queue = None - _stop_event = None - _worker_thread = None - _http_server = None - session = None - _consecutive_failures = None - _max_backoff = None + Config example for server: + [[HTTP Tunnel Server]] + type = HTTPTunnelInterface + interface_enabled = True + mode = server + listen_host = 0.0.0.0 + listen_port = 8080 + + Config example for server with HTML: + [[HTTP Tunnel Server]] + type = HTTPTunnelInterface + interface_enabled = True + mode = server + listen_host = 0.0.0.0 + listen_port = 8080 + serve_html_page = True + html_file_path = index.html + + Config example for client: + [[HTTP Tunnel Client]] + type = HTTPTunnelInterface + interface_enabled = True + mode = client + server_url = http://example.com:8080/ + poll_interval = 0.1 + """ + + DEFAULT_IFAC_SIZE = 16 + BITRATE_GUESS = 10_000_000 + AUTOCONFIGURE_MTU = True + + DEFAULT_MTU = 4096 + TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0" + DEFAULT_POLL_INTERVAL = 0.1 def __init__(self, owner, configuration): - """Initialize the HTTPInterface with the given owner and configuration.""" - if importlib.util.find_spec("requests") is None: - RNS.log( - "Using this interface requires the requests module to be installed.", - RNS.LOG_CRITICAL, - ) - RNS.log( - "You can install it with the command: python3 -m pip install requests", - RNS.LOG_CRITICAL, - ) - RNS.panic() - - import requests - super().__init__() ifconf = Interface.get_config_obj(configuration) - name = ifconf["name"] - self.name = name + self.name = ifconf["name"] mode = ifconf["mode"] if "mode" in ifconf else "client" listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0" listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080 server_url = ifconf["server_url"] if "server_url" in ifconf else None - poll_interval = ( - float(ifconf["poll_interval"]) if "poll_interval" in ifconf else 1.0 - ) - check_user_agent = ( - bool(ifconf["check_user_agent"]) if "check_user_agent" in ifconf else True - ) - user_agent = ( - ifconf["user_agent"] if "user_agent" in ifconf else DEFAULT_USER_AGENT - ) - mtu = int(ifconf["mtu"]) if "mtu" in ifconf else MTU + poll_interval = float(ifconf["poll_interval"]) if "poll_interval" in ifconf else self.DEFAULT_POLL_INTERVAL + check_user_agent = ifconf.as_bool("check_user_agent") if "check_user_agent" in ifconf else True + mtu = int(ifconf["mtu"]) if "mtu" in ifconf else self.DEFAULT_MTU + serve_html_page = ifconf.as_bool("serve_html_page") if "serve_html_page" in ifconf else False + html_file_path = ifconf["html_file_path"] if "html_file_path" in ifconf else None + + if mode not in ["client", "server"]: + raise ValueError(f"Invalid mode '{mode}' for {self}. Must be 'client' or 'server'") - if mode not in ["server", "client"]: - raise ValueError(f"Invalid mode '{mode}'. Must be 'server' or 'client'") if mode == "client" and server_url is None: - raise ValueError("server_url is required for client mode") + raise ValueError(f"No server_url specified for client mode in {self}") self.HW_MTU = mtu - self.online = False - self.bitrate = 1000000 + self.bitrate = HTTPTunnelInterface.BITRATE_GUESS + self.optimise_mtu() self.owner = owner self.mode = mode - self.listen_host = listen_host - self.listen_port = listen_port - self.server_url = server_url - self.poll_interval = poll_interval + self.mtu = mtu self.check_user_agent = check_user_agent - self.user_agent = user_agent + self.serve_html_page = serve_html_page + self.html_file_path = html_file_path + self.html_content = None + + if self.serve_html_page and self.html_file_path: + self._load_html_content() self._recv_queue = Queue() self._send_queue = Queue() self._stop_event = Event() - if self.mode == "server": - self._http_server = None - self._request_handler_class = self._create_request_handler() + if mode == "server": + self.listen_host = listen_host + self.listen_port = listen_port + self.setup_server() else: - self.session = requests.Session() - self.session.headers.update({"User-Agent": self.user_agent}) - self._consecutive_failures = 0 - self._max_backoff = 30.0 + self.server_url = server_url + self.poll_interval = poll_interval + self.setup_client() + def _load_html_content(self): try: - self._start_interface() - self._read_thread = Thread(target=self._read_loop, daemon=True) - self._read_thread.start() + if os.path.isfile(self.html_file_path): + with open(self.html_file_path, encoding="utf-8") as f: + self.html_content = f.read() + RNS.log(f"Loaded HTML content from {self.html_file_path}", RNS.LOG_INFO) + else: + RNS.log(f"HTML file not found: {self.html_file_path}", RNS.LOG_WARNING) + self.html_content = None except Exception as e: - RNS.log("Could not start HTTP interface " + str(self), RNS.LOG_ERROR) - raise e + RNS.log(f"Error loading HTML file {self.html_file_path}: {e}", RNS.LOG_ERROR) + self.html_content = None - def _create_request_handler(self): - """Create a custom HTTP request handler class for the server mode.""" + def setup_server(self): interface_instance = self class TunnelRequestHandler(BaseHTTPRequestHandler): - """Handles HTTP POST requests for the HTTPInterface server.""" - - def __init__(self, request, client_address, server): - self.interface = interface_instance - super().__init__(request, client_address, server) + def do_GET(self): + if self.path == "/" and interface_instance.serve_html_page and interface_instance.html_content: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(interface_instance.html_content))) + self.end_headers() + self.wfile.write(interface_instance.html_content.encode("utf-8")) + else: + self.send_response(404) + self.end_headers() def do_POST(self): - """Handle HTTP POST requests for bidirectional data exchange.""" if self.path == "/": - if self.interface.check_user_agent: + if interface_instance.check_user_agent: user_agent = self.headers.get("User-Agent", "") - if user_agent != self.interface.user_agent: - RNS.log( - f"Rejected request with invalid User-Agent: {user_agent}", - RNS.LOG_WARNING, - ) + if user_agent != HTTPTunnelInterface.TUNNEL_USER_AGENT: + RNS.log(f"Rejected request with invalid User-Agent: {user_agent}", RNS.LOG_WARNING) self.send_response(403) self.send_header("Content-Type", "text/plain") self.end_headers() @@ -143,32 +155,22 @@ class HTTPInterface(Interface): return content_length = int(self.headers.get("Content-Length", 0)) - client_data = ( - self.rfile.read(content_length) if content_length > 0 else b"" - ) + client_data = self.rfile.read(content_length) if content_length > 0 else b"" if client_data: - RNS.log( - f"Received {len(client_data)} bytes from client", - RNS.LOG_DEBUG, - ) - self.interface._recv_queue.put(client_data) + RNS.log(f"Received {len(client_data)} bytes from client", RNS.LOG_EXTREME) + interface_instance._recv_queue.put(client_data) server_data_parts = [] - while True: + while not interface_instance._send_queue.empty(): try: - server_data_parts.append( - self.interface._send_queue.get_nowait(), - ) + server_data_parts.append(interface_instance._send_queue.get_nowait()) except Empty: break server_data = b"".join(server_data_parts) if server_data: - RNS.log( - f"Sending {len(server_data)} bytes ({len(server_data_parts)} chunks) to client", - RNS.LOG_DEBUG, - ) + RNS.log(f"Sending {len(server_data)} bytes ({len(server_data_parts)} chunks) to client", RNS.LOG_EXTREME) self.send_response(200) self.send_header("Content-Type", "application/octet-stream") @@ -180,154 +182,127 @@ class HTTPInterface(Interface): self.end_headers() def log_message(self, fmt, *args): - """Override to suppress default logging.""" - - return TunnelRequestHandler - - def _start_interface(self): - """Start the interface in either server or client mode.""" - if self.mode == "server": - self._start_server() - else: - self._start_client() - - def _start_server(self): - """Start the HTTP server in a separate thread.""" + pass class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - """Threaded HTTP server for handling multiple connections.""" + pass def run_server(): - """Run the HTTP server and handle requests.""" try: - self._http_server = ThreadedHTTPServer( - (self.listen_host, self.listen_port), - self._request_handler_class, - ) - self.online = True - RNS.log( - f"HTTP server started on http://{self.listen_host}:{self.listen_port}", - RNS.LOG_INFO, - ) + self._http_server = ThreadedHTTPServer((self.listen_host, self.listen_port), TunnelRequestHandler) self._http_server.serve_forever() except Exception as e: if not self._stop_event.is_set(): - RNS.log(f"Server error: {e}", RNS.LOG_ERROR) - self.online = False + RNS.log(f"HTTP server error for {self}: {e}", RNS.LOG_ERROR) + if RNS.Reticulum.panic_on_interface_error: + RNS.panic() - try: - self._http_server = ThreadedHTTPServer( - (self.listen_host, self.listen_port), - self._request_handler_class, - ) - self.online = True - RNS.log( - f"HTTP server started on http://{self.listen_host}:{self.listen_port}", - RNS.LOG_INFO, - ) - self._worker_thread = Thread( - target=self._http_server.serve_forever, daemon=True - ) - self._worker_thread.start() - except Exception as e: - RNS.log(f"Could not start HTTP server: {e}", RNS.LOG_ERROR) - self.online = False - raise e + self._server_thread = Thread(target=run_server, daemon=True) + self._server_thread.start() + + thread = threading.Thread(target=self.receive_loop) + thread.daemon = True + thread.start() - def _start_client(self): - """Start the HTTP client loop in a separate thread.""" - self._worker_thread = Thread(target=self._client_loop, daemon=True) - self._worker_thread.start() self.online = True - RNS.log(f"HTTP client started, connecting to {self.server_url}", RNS.LOG_INFO) + RNS.log(f"HTTP server started on http://{self.listen_host}:{self.listen_port}", RNS.LOG_NOTICE) - def _client_loop(self): - """Main loop for the HTTP client, handling polling and data transfer.""" + def setup_client(self): + self.session = requests.Session() + self.session.headers.update({"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT}) + self._consecutive_failures = 0 + self._max_backoff = 30.0 + + thread = threading.Thread(target=self.client_loop) + thread.daemon = True + thread.start() + + self.online = True + RNS.log(f"HTTP client started, connecting to {self.server_url}", RNS.LOG_NOTICE) + + def receive_loop(self): + while not self._stop_event.is_set(): + try: + received_data = self._recv_queue.get(timeout=1) + if received_data: + self.process_incoming(received_data) + except Empty: + continue + except Exception as e: + if not self._stop_event.is_set(): + RNS.log(f"Error in receive loop for {self}: {e}", RNS.LOG_ERROR) + + def client_loop(self): while not self._stop_event.is_set(): data_to_send = b"" if not self._send_queue.empty(): - from contextlib import suppress - - with suppress(Empty): + try: data_to_send = self._send_queue.get_nowait() + except Empty: + pass try: - RNS.log(f"Sending {len(data_to_send)} bytes to server", RNS.LOG_DEBUG) - response = self.session.post( - self.server_url, - data=data_to_send, - timeout=5, - ) + RNS.log(f"Sending {len(data_to_send)} bytes to server", RNS.LOG_EXTREME) + response = self.session.post(self.server_url, data=data_to_send, timeout=5) response.raise_for_status() if response.content: - RNS.log( - f"Received {len(response.content)} bytes from server", - RNS.LOG_DEBUG, - ) - self._recv_queue.put(response.content) + RNS.log(f"Received {len(response.content)} bytes from server", RNS.LOG_EXTREME) + self.process_incoming(response.content) if self._consecutive_failures > 0: - RNS.log("Reconnected to server", RNS.LOG_INFO) + RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO) self._consecutive_failures = 0 - except Exception as e: + except requests.exceptions.RequestException as e: self._consecutive_failures += 1 if self._consecutive_failures % 10 == 1: - RNS.log( - f"Error communicating with server (attempt {self._consecutive_failures}): {e}", - RNS.LOG_ERROR, - ) + RNS.log(f"Error communicating with server for {self} (attempt {self._consecutive_failures}): {e}", RNS.LOG_WARNING) if self._consecutive_failures > 0: - delay = min( - self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)), - self._max_backoff, - ) + delay = min(self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)), self._max_backoff) else: delay = self.poll_interval - sleep(delay) + time.sleep(delay) def process_incoming(self, data): - """Process incoming data received from the underlying medium.""" - self.rxb += len(data) - self.owner.inbound(data, self) + if len(data) > 0 and self.online: + self.rxb += len(data) + self.owner.inbound(data, self) def process_outgoing(self, data): - """Process outgoing data to be transmitted by the interface.""" if self.online: - if len(data) > self.HW_MTU: - RNS.log( - f"Packet too large ({len(data)} > {self.HW_MTU}), dropping", - RNS.LOG_WARNING, - ) + if len(data) > self.mtu: + RNS.log(f"Payload too large ({len(data)} > {self.mtu}) for {self}", RNS.LOG_ERROR) return self._send_queue.put(data) self.txb += len(data) + def detach(self): + RNS.log(f"Detaching {self}", RNS.LOG_DEBUG) + self._stop_event.set() + self.online = False + + if self.mode == "server": + if hasattr(self, "_http_server") and self._http_server: + try: + self._http_server.shutdown() + self._http_server.server_close() + except Exception as e: + RNS.log(f"Error while shutting down HTTP server for {self}: {e}", RNS.LOG_ERROR) + + if hasattr(self, "_server_thread") and self._server_thread: + self._server_thread.join(timeout=2) + def should_ingress_limit(self): - """Indicate that this interface should not perform ingress limiting.""" return False def __str__(self): - """Return a string representation of the HTTPInterface.""" - return f"HTTPInterface[{self.name}]" + if self.mode == "server": + return f"HTTPTunnelInterface[{self.name}/server/{self.listen_host}:{self.listen_port}]" + return f"HTTPTunnelInterface[{self.name}/client/{self.server_url}]" - def _read_loop(self): - """Read loop that processes incoming data from the queue.""" - try: - while not self._stop_event.is_set(): - try: - data = self._recv_queue.get(timeout=0.1) - self.process_incoming(data) - except Empty: - continue - except Exception as e: - if not self._stop_event.is_set(): - RNS.log(f"Error in read loop: {e}", RNS.LOG_ERROR) - self.online = False +interface_class = HTTPTunnelInterface - -interface_class = HTTPInterface -- 2.49.1 From 1bb90e382003728168c8359b8c338285af93bdb4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 19 Nov 2025 22:15:41 -0600 Subject: [PATCH 4/7] add example config --- config_example | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 config_example diff --git a/config_example b/config_example new file mode 100644 index 0000000..b735332 --- /dev/null +++ b/config_example @@ -0,0 +1,67 @@ +# RNS HTTP Tunnel Interface Configuration Examples + +# Server Mode Configuration +# This creates an HTTP server that accepts connections from HTTP tunnel clients +[[HTTP Tunnel Server]] + type = HTTPTunnelInterface + interface_enabled = True + mode = server + listen_host = 0.0.0.0 + listen_port = 8080 + check_user_agent = True + mtu = 4096 + +# Server Mode with HTML Page +# This creates an HTTP server that serves an HTML page on GET requests +[[HTTP Tunnel Server with HTML]] + type = HTTPTunnelInterface + interface_enabled = True + mode = server + listen_host = 0.0.0.0 + listen_port = 8080 + check_user_agent = True + mtu = 4096 + serve_html_page = True + html_file_path = index.html + +# Client Mode Configuration +# This connects to an HTTP tunnel server +[[HTTP Tunnel Client]] + type = HTTPTunnelInterface + interface_enabled = False + mode = client + server_url = http://example.com:8080/ + poll_interval = 0.1 + mtu = 4096 + +# HTTPS Example (Client) +# Use this to connect to an HTTPS-enabled server +[[HTTPS Tunnel Client]] + type = HTTPTunnelInterface + interface_enabled = False + mode = client + server_url = https://secure.example.com:443/ + poll_interval = 0.2 + check_user_agent = True + +# Configuration Options: +# +# mode (required): +# - "server": Run as HTTP server accepting incoming connections +# - "client": Run as HTTP client connecting to a server +# +# Server Mode Options: +# listen_host: IP address to bind to (default: 0.0.0.0) +# listen_port: TCP port to bind to (default: 8080) +# +# Client Mode Options: +# server_url: Full URL of the HTTP server (required for client mode) +# poll_interval: Seconds between polls (default: 0.1) +# +# Common Options: +# check_user_agent: Validate User-Agent header (default: True) +# mtu: Maximum transmission unit in bytes (default: 4096) +# serve_html_page: Serve HTML page on GET requests (default: False) +# html_file_path: Path to HTML file to serve (required if serve_html_page is True) +# interface_enabled: Enable/disable the interface (default: False) + -- 2.49.1 From 8a2c20df0bdd47339b81dfd39aebd3711ee83bed Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 19 Nov 2025 22:15:52 -0600 Subject: [PATCH 5/7] add basic html page --- index.html | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 index.html diff --git a/index.html b/index.html new file mode 100644 index 0000000..ac0406b --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + + Service Status + + + +
+
+

Service Status

+

✓ All systems operational

+
+

This service is currently running normally. If you're seeing this page, the service is functioning as expected.

+

For technical inquiries, please contact the system administrator.

+
+ + -- 2.49.1 From d256a34498249a8a21511101614636a4021179f9 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 26 Nov 2025 15:39:14 -0600 Subject: [PATCH 6/7] Fix HTTPTunnelInterface initialization --- HTTPInterface.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/HTTPInterface.py b/HTTPInterface.py index 00202a6..1350114 100755 --- a/HTTPInterface.py +++ b/HTTPInterface.py @@ -85,11 +85,6 @@ class HTTPTunnelInterface(Interface): if mode == "client" and server_url is None: raise ValueError(f"No server_url specified for client mode in {self}") - self.HW_MTU = mtu - self.online = False - self.bitrate = HTTPTunnelInterface.BITRATE_GUESS - self.optimise_mtu() - self.owner = owner self.mode = mode self.mtu = mtu @@ -105,6 +100,11 @@ class HTTPTunnelInterface(Interface): self._send_queue = Queue() self._stop_event = Event() + self.HW_MTU = mtu + self.online = False + self.bitrate = HTTPTunnelInterface.BITRATE_GUESS + self.optimise_mtu() + if mode == "server": self.listen_host = listen_host self.listen_port = listen_port -- 2.49.1 From 6a84bc9b6fd16ae6b19cdec98e3e51114e5a2a4b Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 26 Nov 2025 15:41:50 -0600 Subject: [PATCH 7/7] Fix HTTPTunnelInterface initialization to ensure MTU optimization and server setup occur in the correct order --- HTTPInterface.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/HTTPInterface.py b/HTTPInterface.py index 1350114..754d73d 100755 --- a/HTTPInterface.py +++ b/HTTPInterface.py @@ -103,15 +103,19 @@ class HTTPTunnelInterface(Interface): self.HW_MTU = mtu self.online = False self.bitrate = HTTPTunnelInterface.BITRATE_GUESS - self.optimise_mtu() if mode == "server": self.listen_host = listen_host self.listen_port = listen_port - self.setup_server() else: self.server_url = server_url self.poll_interval = poll_interval + + self.optimise_mtu() + + if mode == "server": + self.setup_server() + else: self.setup_client() def _load_html_content(self): -- 2.49.1