Files
orderpy-bridge/daemon/main.py
2026-03-14 13:01:24 +01:00

125 lines
4.1 KiB
Python

"""Bridge local HTTP API and Cloud WebSocket client (daemon entrypoint)."""
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:
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"}