import re import sys import time import threading import RNS from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import parse_qs, urlparse APP_NAME = "tinyweb" ASPECTS = ["server"] GATEWAY_PORT = 8080 REQUEST_TIMEOUT = 60 MAX_BODY_SIZE = 16 * 1024 * 1024 # 16 MiB — covers /import and every other form class GatewayState: reticulum = None destination = None link = None link_lock = threading.Lock() local_dispatch = None # set when running inside app.py def resolve_destination(dest_hash_hex): dest_hash = bytes.fromhex(dest_hash_hex) if not RNS.Transport.has_path(dest_hash): RNS.Transport.request_path(dest_hash) print(f"Requesting path to {RNS.prettyhexrep(dest_hash)}...") elapsed = 0 while not RNS.Transport.has_path(dest_hash) and elapsed < 15: time.sleep(0.5) elapsed += 0.5 if not RNS.Transport.has_path(dest_hash): raise ConnectionError(f"Could not find path to {RNS.prettyhexrep(dest_hash)}") server_identity = RNS.Identity.recall(dest_hash) GatewayState.destination = RNS.Destination( server_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, *ASPECTS, ) print(f"Resolved destination: {RNS.prettyhexrep(dest_hash)}") def ensure_link(): with GatewayState.link_lock: if GatewayState.link and GatewayState.link.status == RNS.Link.ACTIVE: return GatewayState.link print("Establishing link...") link = RNS.Link(GatewayState.destination) elapsed = 0 while link.status == RNS.Link.PENDING and elapsed < 15: time.sleep(0.25) elapsed += 0.25 if link.status != RNS.Link.ACTIVE: raise ConnectionError("Link establishment failed") GatewayState.link = link print("Link established") return link class GatewayHandler(BaseHTTPRequestHandler): def _forward(self, method): parsed = urlparse(self.path) query = parse_qs(parsed.query) body = {} if method == "POST": try: length = int(self.headers.get("Content-Length", 0)) except ValueError: self.send_error(400, "Invalid Content-Length") return if length < 0: self.send_error(400, "Invalid Content-Length") return if length > MAX_BODY_SIZE: self.send_error(413, "Request body too large") return raw = self.rfile.read(length).decode("utf-8", errors="replace") body = parse_qs(raw) # Parse cookies cookies = {} cookie_header = self.headers.get("Cookie", "") if cookie_header: for part in cookie_header.split(";"): part = part.strip() if "=" in part: k, v = part.split("=", 1) cookies[k.strip()] = v.strip() request_data = { "method": method, "path": parsed.path, "query": query, "body": body, "cookies": cookies, "gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"), } try: if GatewayState.local_dispatch: resp = GatewayState.local_dispatch(request_data) else: link = ensure_link() receipt = link.request( "/tinyweb", data=request_data, timeout=REQUEST_TIMEOUT, ) # Wait for the response elapsed = 0 done_statuses = (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED, RNS.RequestReceipt.FAILED) while receipt.get_status() not in done_statuses and elapsed < REQUEST_TIMEOUT: time.sleep(0.1) elapsed += 0.1 if receipt.get_status() in (RNS.RequestReceipt.READY, RNS.RequestReceipt.DELIVERED): resp = receipt.get_response() elif receipt.get_status() == RNS.RequestReceipt.FAILED: self.send_error(504, "Request to TinyWeb server failed") return else: self.send_error(504, "Request to TinyWeb server timed out") return self.send_response(resp["status"]) self.send_header("Content-Type", resp.get("content_type", "text/html; charset=utf-8")) self.send_header("Referrer-Policy", "no-referrer") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Content-Security-Policy", "default-src 'self'; " "style-src 'self' 'unsafe-inline'; " "script-src 'self' 'unsafe-inline'; " "img-src 'self' data:") for k, v in resp.get("headers", {}).items(): self.send_header(k, v) self.end_headers() resp_body = resp.get("body", "") if resp_body: self.wfile.write(resp_body.encode() if isinstance(resp_body, str) else resp_body) except ConnectionError as e: GatewayState.link = None self.send_error(502, f"Gateway error: {e}") except Exception as e: GatewayState.link = None self.send_error(502, f"Gateway error: {e}") def do_GET(self): self._forward("GET") def do_POST(self): self._forward("POST") def log_message(self, format, *args): try: msg = format % args except TypeError: msg = format # /bookmark carries a long-lived token and the URL being indexed — # redact the query so it doesn't end up in stdout, journald, docker logs, etc. msg = re.sub(r'(/bookmark)\?\S*', r'\1?[redacted]', msg) print(f"[Gateway] {msg}") def main(): if len(sys.argv) < 2: print(f"Usage: python gateway.py ") print(f" The destination hash is printed by app.py on startup.") sys.exit(1) dest_hash = sys.argv[1].replace("<", "").replace(">", "") GatewayState.reticulum = RNS.Reticulum() resolve_destination(dest_hash) print(f"Gateway listening on http://localhost:{GATEWAY_PORT}") print(f"Open http://localhost:{GATEWAY_PORT} in your browser") HTTPServer(("127.0.0.1", GATEWAY_PORT), GatewayHandler).serve_forever() if __name__ == "__main__": main()