Compare commits
7 Commits
master
...
custom-int
| Author | SHA1 | Date | |
|---|---|---|---|
|
6a84bc9b6f
|
|||
|
d256a34498
|
|||
|
8a2c20df0b
|
|||
|
1bb90e3820
|
|||
|
447492dc88
|
|||
| 7d4a5b5da2 | |||
| 7f115c0102 |
312
HTTPInterface.py
Executable file
312
HTTPInterface.py
Executable file
@@ -0,0 +1,312 @@
|
|||||||
|
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.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()
|
||||||
|
|
||||||
|
self.HW_MTU = mtu
|
||||||
|
self.online = False
|
||||||
|
self.bitrate = HTTPTunnelInterface.BITRATE_GUESS
|
||||||
|
|
||||||
|
if mode == "server":
|
||||||
|
self.listen_host = listen_host
|
||||||
|
self.listen_port = listen_port
|
||||||
|
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):
|
||||||
|
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
|
||||||
|
|
||||||
74
README.md
74
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[Русский](README-RU.md)
|
[Русский](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.
|
[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
|
### Requirements
|
||||||
|
|
||||||
- Python 3.9 or later
|
- Python 3.9 or later
|
||||||
- `pip` for installing packages
|
- `rns`
|
||||||
|
- `requests`
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
1. **Install the `requests` library if not already installed:**
|
1. **Install Reticulum and dependencies:**
|
||||||
```bash
|
```bash
|
||||||
pip install requests
|
pip install rns requests
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Download the interface script:**
|
2. **Install the custom interface:**
|
||||||
Place `http_interface.py` in a known location on both your client and server machines, for example, `~/.reticulum/interfaces/`.
|
Place `HTTPInterface.py` in your Reticulum interfaces directory: `~/.reticulum/interfaces/`.
|
||||||
|
|
||||||
## Configuration
|
## 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
|
### Server Configuration
|
||||||
|
|
||||||
The server listens for incoming connections from clients.
|
The server listens for incoming connections from clients.
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[[HTTP Interface]]
|
[[HTTP Server Interface]]
|
||||||
type = PipeInterface
|
type = HTTPInterface
|
||||||
enabled = True
|
enabled = true
|
||||||
# The command to run the server script. Listens on all interfaces by default.
|
mode = server
|
||||||
command = python3 /path/to/your/http_interface.py server --host 0.0.0.0 --port 8080
|
listen_host = 0.0.0.0
|
||||||
# Optional: delay before respawning the interface if it crashes.
|
listen_port = 8080
|
||||||
respawn_delay = 5
|
mtu = 4096
|
||||||
name = HTTP Interface Server
|
check_user_agent = true
|
||||||
|
user_agent = RNS-HTTP-Tunnel/1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Client Configuration
|
### Client Configuration
|
||||||
@@ -76,26 +78,36 @@ The server listens for incoming connections from clients.
|
|||||||
The client connects to the server's public URL.
|
The client connects to the server's public URL.
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[[HTTP Interface]]
|
[[HTTP Client Interface]]
|
||||||
type = PipeInterface
|
type = HTTPInterface
|
||||||
enabled = True
|
enabled = true
|
||||||
# The command to run the client script. Point --url to your server.
|
mode = client
|
||||||
command = python3 /path/to/your/http_interface.py client --url http://<your-server-ip-or-domain>
|
server_url = http://your-server-ip-or-domain:8080
|
||||||
# Optional: delay before respawning the interface if it crashes.
|
poll_interval = 1.0
|
||||||
respawn_delay = 5
|
mtu = 4096
|
||||||
name = HTTP Interface Client
|
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`).
|
- `mtu`: Maximum Transmission Unit in bytes (default: `4096`).
|
||||||
- `--poll-interval`: Client polling interval in seconds (default: `0.1`).
|
- `name`: Interface name for logging and identification.
|
||||||
- `--verbose` or `-v`: Enable verbose debug logging.
|
- `user_agent`: User-Agent string to use for HTTP requests (default: `"RNS-HTTP-Tunnel/1.0"`).
|
||||||
- `--host`: Server listen host (default: `0.0.0.0`).
|
|
||||||
- `--port`: Server listen port (default: `8080`).
|
### Server Mode Options
|
||||||
- `--disable-user-agent-check`: Disable User-Agent validation on the server.
|
|
||||||
|
- `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)
|
## Reverse Proxy Setup (Caddy Example)
|
||||||
|
|
||||||
|
|||||||
67
config_example
Normal file
67
config_example
Normal file
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Service Status</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
|
||||||
|
.container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.status { color: #28a745; font-weight: bold; }
|
||||||
|
.header { text-align: center; margin-bottom: 30px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Service Status</h1>
|
||||||
|
<p class="status">✓ All systems operational</p>
|
||||||
|
</div>
|
||||||
|
<p>This service is currently running normally. If you're seeing this page, the service is functioning as expected.</p>
|
||||||
|
<p>For technical inquiries, please contact the system administrator.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user