Security:
- Bind HTTP gateway to 127.0.0.1 by default; add --bind for LAN opt-in
- Restrict Reticulum mesh surface to GET /api/sites only (CSRF cannot
authenticate mesh callers, so gate by whitelist)
- Cap request body size at 16 MiB to prevent memory DoS
- Redact /bookmark query strings from request logs so the bookmark token
and URLs do not land in stdout / docker / journal logs
- Tighten FTS5 sanitizer: strip colon, drop AND/OR/NOT/NEAR operator words
- Expand .dockerignore; document trust model in README
Features:
- Add sharing mode toggle (share everything except private vs share only
public-tagged) with /share/preview so users can see what subscribers
would receive before enabling sharing
Bugs:
- handle_export() crashed on every call (missing query kwarg)
- Dead float16 decompression branch in embeddings.py silently corrupted
the HNSW index when compress_embeddings was on
- GATEWAY_PORT staleness: --port and find_available_port had no effect
on the actual bind
- semantic_search default mismatched between db.py ("1") and the rest of
the app ("0"), causing embeddings to be generated when the UI said off
- Connection pool returned connections with uncommitted transactions to
the next consumer
- Gateway POST body decode 502'd on non-UTF-8 input
- ensure_rns_config clobbered user-edited ~/.reticulum/config; now only
rewrites files it authored (sentinel-tagged)
194 lines
6.6 KiB
Python
194 lines
6.6 KiB
Python
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 <destination_hash>")
|
|
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()
|