from pathlib import Path from core.security import require_authenticated_user from fastapi import APIRouter, Depends, HTTPException, Request, status from models.example import AddonExampleCreateInput from models.proxy import ProxyCreateInput, ProxyUpdateInput from pymongo.errors import DuplicateKeyError from repositories.examples import ( create_custom_example, delete_custom_example, list_custom_examples, ) from repositories.proxy import ( create_proxy, delete_proxy, get_proxy_by_port, update_proxy, update_proxy_runtime, ) from repositories.proxy import ( list_proxies as list_proxy_documents, ) from services.mitmproxy.examples import BUILTIN_EXAMPLES from services.mitmproxy.health import check_proxy from services.mitmproxy.manager import MitmdumpError, manager from services.mitmproxy.options import ( MitmOptionError, load_options, public_options, validate_options, ) from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code SUPPORTED_MODES = { "regular", "local", "wireguard", "reverse", "transparent", "tun", "upstream", "socks5", "dns", } SUPPORTED_PROTOCOLS = { "http", "https", "tcp", "tls", "udp", "dtls", "dns", "http3", "quic", } router = APIRouter( prefix="/proxy", tags=["Proxy"], dependencies=[Depends(require_authenticated_user)], ) async def _validate_code_or_raise(code: str) -> None: try: await validate_proxy_code(code) except ProxyCodeValidationError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=error.to_http_detail(), ) from error def validate_mode(mode: str) -> None: if mode not in SUPPORTED_MODES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Unsupported proxy mode: {mode}", ) def validate_mode_spec(mode_spec: str | None) -> None: if mode_spec is None: return mode = mode_spec.split(":", 1)[0].split("@", 1)[0] validate_mode(mode) def validate_protocol(protocol: str) -> None: if protocol not in SUPPORTED_PROTOCOLS: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Unsupported reverse proxy protocol: {protocol}", ) async def validate_mitm_options(options: dict[str, object]) -> None: try: await validate_options(options) except MitmOptionError as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error), ) from error async def get_proxy_or_404(proxy_port: int): proxy = await get_proxy_by_port(proxy_port) if proxy is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found", ) return proxy @router.get("") async def list_proxies_handler(): return await list_proxy_documents() @router.get("/services") async def list_services(request: Request): return getattr(request.app.state, "services", []) @router.get("/mitmproxy/options") async def list_mitmproxy_options(): return public_options(await load_options()) @router.get("/mitmproxy/examples") async def list_mitmproxy_examples(): return [*BUILTIN_EXAMPLES, *await list_custom_examples()] @router.post("/mitmproxy/examples", status_code=status.HTTP_201_CREATED) async def create_mitmproxy_example(payload: AddonExampleCreateInput): await _validate_code_or_raise(payload.code) try: return await create_custom_example(payload) except DuplicateKeyError as error: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Example already exists: {payload.id}", ) from error @router.delete( "/mitmproxy/examples/{example_id}", status_code=status.HTTP_204_NO_CONTENT, ) async def delete_mitmproxy_example(example_id: str): if any(example.id == example_id for example in BUILTIN_EXAMPLES): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Built-in examples cannot be deleted", ) deleted = await delete_custom_example(example_id) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Example not found", ) @router.get("/{proxy_port}") async def get_proxy(proxy_port: int): return await get_proxy_or_404(proxy_port) @router.post("", status_code=status.HTTP_201_CREATED) async def create_proxy_handler(payload: ProxyCreateInput): existing_proxy = await get_proxy_by_port(payload.proxy_port) if existing_proxy is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Proxy already exists on port {payload.proxy_port}", ) await _validate_code_or_raise(payload.code) validate_mode(payload.mode) validate_mode_spec(payload.mode_spec) validate_protocol(payload.protocol) await validate_mitm_options(payload.mitm_options) return await create_proxy(payload) @router.put("/{proxy_port}") async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): if payload.code is not None: await _validate_code_or_raise(payload.code) if payload.mode is not None: validate_mode(payload.mode) if "mode_spec" in payload.model_fields_set: validate_mode_spec(payload.mode_spec) if payload.protocol is not None: validate_protocol(payload.protocol) if payload.mitm_options is not None: await validate_mitm_options(payload.mitm_options) updated_proxy = await update_proxy(proxy_port, payload) if updated_proxy is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found", ) return updated_proxy @router.post("/{proxy_port}/start") async def start_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) try: runtime = await manager.start(proxy) except MitmdumpError as error: updated = await update_proxy_runtime( proxy_port, {"status": "error", "last_error": str(error)}, ) return updated or proxy return await update_proxy_runtime(proxy_port, runtime) @router.post("/{proxy_port}/stop") async def stop_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) runtime = await manager.stop(proxy) return await update_proxy_runtime(proxy_port, runtime) @router.post("/{proxy_port}/restart") async def restart_proxy(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) stopped = await manager.stop(proxy) proxy = await update_proxy_runtime(proxy_port, stopped) or proxy try: runtime = await manager.start(proxy) except MitmdumpError as error: updated = await update_proxy_runtime( proxy_port, {"status": "error", "last_error": str(error)}, ) return updated or proxy return await update_proxy_runtime(proxy_port, runtime) @router.get("/{proxy_port}/status") async def proxy_status(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) runtime_status = await manager.status(proxy) if runtime_status != proxy.status: values: dict[str, object] = {"status": runtime_status} if runtime_status != "running": values["pid"] = None proxy = await update_proxy_runtime(proxy_port, values) or proxy return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid} @router.get("/{proxy_port}/health") async def proxy_health(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) return await check_proxy(proxy) @router.get("/{proxy_port}/logs") async def proxy_logs(proxy_port: int): proxy = await get_proxy_or_404(proxy_port) if proxy.log_path is None: return {"proxy_port": proxy.proxy_port, "log": ""} path = Path(proxy.log_path) if not path.is_file(): return {"proxy_port": proxy.proxy_port, "log": ""} return { "proxy_port": proxy.proxy_port, "log": path.read_text(encoding="utf-8", errors="replace")[-8000:], } @router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT) async def remove_proxy(proxy_port: int): proxy = await get_proxy_by_port(proxy_port) if proxy is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found", ) await manager.stop(proxy) deleted = await delete_proxy(proxy_port) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Proxy not found", )