Files
adaptive-multi-protocol-bro…/ampbrowser/route_helper.py
T

161 lines
5.1 KiB
Python

from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import threading
import time
from typing import Any
from .config import AppConfig, load_config
from .routing import route_url
from .transport_manager import ManagedTransportResult, ensure_transport_ready, transport_status
SUPPORTED_HELPER_TRANSPORTS = {"tor", "i2p"}
def handle_helper_message(
message: dict[str, Any],
*,
root: Path,
config: AppConfig,
) -> dict[str, Any]:
action = _string(message.get("action"))
transport = _message_transport(message)
if not transport:
return _error_response("unsupported-route", "URL does not route to a managed browser transport")
if transport not in SUPPORTED_HELPER_TRANSPORTS:
return _error_response("unsupported-transport", f"{transport} is not supported by the route helper yet")
if action == "status":
return _result_response(transport_status(transport, config=config, root=root))
if action == "ensure":
return _result_response(ensure_transport_ready(transport, config=config, root=root))
return _error_response("unsupported-action", f"unsupported helper action: {action or '-'}")
def serve_route_helper(
*,
host: str,
port: int,
token: str,
root: Path,
config_path: Path | None = None,
watch_pid: int = 0,
) -> None:
config = load_config(root, config_path)
server = _RouteHelperServer((host, port), _RouteHelperHandler)
server.token = token
server.root_path = root
server.config = config
if watch_pid > 0:
watcher = threading.Thread(target=_shutdown_when_pid_exits, args=(server, watch_pid), daemon=True)
watcher.start()
server.serve_forever()
class _RouteHelperServer(ThreadingHTTPServer):
token: str
root_path: Path
config: AppConfig
class _RouteHelperHandler(BaseHTTPRequestHandler):
server: _RouteHelperServer
def do_OPTIONS(self) -> None:
self._write_json(200, {"ok": True})
def do_POST(self) -> None:
if self.headers.get("X-AMPB-Token") != self.server.token:
self._write_json(403, _error_response("forbidden", "invalid helper token"))
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
try:
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
except json.JSONDecodeError:
self._write_json(400, _error_response("bad-json", "request body is not valid JSON"))
return
if not isinstance(payload, dict):
self._write_json(400, _error_response("bad-request", "request body must be a JSON object"))
return
response = handle_helper_message(payload, root=self.server.root_path, config=self.server.config)
self._write_json(200 if response.get("ok") else 400, response)
def log_message(self, _format: str, *_args: object) -> None:
return
def _write_json(self, status: int, payload: dict[str, Any]) -> None:
data = json.dumps(payload, sort_keys=True).encode("utf-8")
self.send_response(status)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-AMPB-Token")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _message_transport(message: dict[str, Any]) -> str:
raw_transport = _string(message.get("transport"))
if raw_transport:
return raw_transport
raw_url = _string(message.get("url"))
if not raw_url:
return ""
return route_url(raw_url).transport
def _result_response(result: ManagedTransportResult) -> dict[str, Any]:
return {
"ok": True,
"transport": result.transport,
"provider": result.provider,
"provider_source": result.provider_source,
"status": result.status,
"ready": result.ready,
"owned": result.owned,
"pid": result.pid,
"endpoint": result.endpoint,
"state_dir": result.state_dir,
"message": result.message,
"command": list(result.command),
"setup_hint": result.setup_hint,
"install_command": list(result.install_command),
}
def _error_response(status: str, message: str) -> dict[str, Any]:
return {
"ok": False,
"status": status,
"ready": False,
"message": message,
}
def _string(value: object) -> str:
return value if isinstance(value, str) else ""
def _shutdown_when_pid_exits(server: _RouteHelperServer, pid: int, *, interval_seconds: float = 1.0) -> None:
while _pid_exists(pid):
time.sleep(interval_seconds)
server.shutdown()
def _pid_exists(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError:
return False
return True