Files
RNS-over-HTTP/HTTPInterface.py
2025-11-19 22:15:23 -06:00

309 lines
12 KiB
Python
Executable File

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
import requests
import RNS
from RNS.Interfaces.Interface import Interface
class HTTPTunnelInterface(Interface):
"""HTTP Tunnel Interface for Reticulum.
This interface implements bidirectional communication over HTTP POST requests,
allowing Reticulum to traverse firewalls and proxies that allow HTTP/HTTPS traffic.
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)
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):
super().__init__()
ifconf = Interface.get_config_obj(configuration)
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 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 == "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
self.check_user_agent = check_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 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.setup_client()
def _load_html_content(self):
try:
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(f"Error loading HTML file {self.html_file_path}: {e}", RNS.LOG_ERROR)
self.html_content = None
def setup_server(self):
interface_instance = self
class TunnelRequestHandler(BaseHTTPRequestHandler):
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):
if self.path == "/":
if interface_instance.check_user_agent:
user_agent = self.headers.get("User-Agent", "")
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()
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_EXTREME)
interface_instance._recv_queue.put(client_data)
server_data_parts = []
while not interface_instance._send_queue.empty():
try:
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_EXTREME)
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):
pass
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
pass
def run_server():
try:
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"HTTP server error for {self}: {e}", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
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()
self.online = True
RNS.log(f"HTTP server started on http://{self.listen_host}:{self.listen_port}", RNS.LOG_NOTICE)
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():
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_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_EXTREME)
self.process_incoming(response.content)
if self._consecutive_failures > 0:
RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO)
self._consecutive_failures = 0
except requests.exceptions.RequestException as e:
self._consecutive_failures += 1
if self._consecutive_failures % 10 == 1:
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)
else:
delay = self.poll_interval
time.sleep(delay)
def process_incoming(self, data):
if len(data) > 0 and self.online:
self.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online:
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):
return False
def __str__(self):
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}]"
interface_class = HTTPTunnelInterface