"""Bridge local HTTP API and Cloud WebSocket client.""" import asyncio import re import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from uvicorn import Config, Server from bridge_core.app_config import get_allowed_origins, get_cloud_url from bridge_core.cloud_client import run_cloud_client from bridge_core.pairing_ui import create_pairing_app from bridge_core.ssl_util import generate_self_signed_cert from bridge_core.state import get_bridge_id, get_claim_token, get_tenant_name, get_ws_connected, is_claimed, is_locked, rotate_claim_token logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # IPv4 with optional :port HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$") PAIRING_PORT = 8088 allowed_origins = get_allowed_origins() async def _rotate_ots_periodically() -> None: """Rotate OTS every 15 minutes while still unclaimed.""" while True: await asyncio.sleep(900) # 15 min if get_claim_token(): rotate_claim_token() logger.debug("OTS rotated") @asynccontextmanager async def lifespan(app: FastAPI): cloud_url = get_cloud_url() pairing_queue = asyncio.Queue() ws_task = asyncio.create_task(run_cloud_client(cloud_url, pairing_queue)) rot_task = asyncio.create_task(_rotate_ots_periodically()) pairing_task = None try: key_path, cert_path = generate_self_signed_cert() def get_connection_info(): return {"claimed": is_claimed(), "tenant_name": get_tenant_name(), "is_online": get_ws_connected()} pairing_app = create_pairing_app(pairing_queue, get_connection_info) pairing_config = Config( pairing_app, host="0.0.0.0", port=PAIRING_PORT, ssl_keyfile=str(key_path), ssl_certfile=str(cert_path), ) pairing_server = Server(pairing_config) pairing_task = asyncio.create_task(pairing_server.serve()) logger.info("Pairing UI (HTTPS) listening on port %s", PAIRING_PORT) except Exception as e: logger.warning("Could not start pairing UI on %s: %s", PAIRING_PORT, e) yield ws_task.cancel() rot_task.cancel() if pairing_task is not None: pairing_task.cancel() try: await ws_task except asyncio.CancelledError: pass try: await rot_task except asyncio.CancelledError: pass if pairing_task is not None: try: await pairing_task except asyncio.CancelledError: pass app = FastAPI(title="OrderPy Bridge", version="1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origin_regex=None, allow_origins=allowed_origins, allow_credentials=True, allow_methods=["GET", "OPTIONS"], allow_headers=["*"], ) @app.middleware("http") async def host_header_check(request: Request, call_next): """DNS Rebinding: only allow direct IP access.""" host = request.headers.get("host", "") if not HOST_IP_PATTERN.match(host): return Response("Forbidden: Direct IP access only", status_code=403) return await call_next(request) @app.get("/setup/info") async def setup_info(request: Request): """Return bridgeId and claimToken for discovery. Fail2Ban: 503 when locked.""" origin = request.headers.get("origin") if origin and origin not in allowed_origins: return Response("Forbidden", status_code=403) if is_locked(): return Response("Service temporarily unavailable", status_code=503) bridge_id = get_bridge_id() if not bridge_id: # Bridge has not yet received bridgeId from Cloud (WebSocket not connected or still handshaking) return Response( "Bridge not yet registered with Cloud; wait a few seconds and retry", status_code=503, headers={"Retry-After": "3"}, ) # Discovery: bridgeId only. claimToken kept for backward compatibility during transition. token = get_claim_token() return {"bridgeId": bridge_id, "claimToken": token, "pairing_ui": True} @app.get("/health") async def health(): return {"status": "ok"}