This commit is contained in:
2026-03-14 13:01:24 +01:00
commit 46fb96886a
25 changed files with 1546 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
# Build context must be orderpy-bridge (parent), e.g. docker-compose with context: . and dockerfile: container-bridge/Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY bridge_core /app/bridge_core
COPY container-bridge/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY container-bridge/main.py /app/main.py
COPY container-bridge/docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data && chown -R app:app /app/data
ENV ORDERPY_KEY_PATH=/app/data/bridge_key.pem
EXPOSE 8080 8088
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -e
# Ensure data dir exists and is writable by app user (fixes bind-mount permissions)
mkdir -p /app/data
chown -R app:app /app/data 2>/dev/null || true
chmod 777 /app/data
exec gosu app "$@"

125
container-bridge/main.py Normal file
View File

@@ -0,0 +1,125 @@
"""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"}

View File

@@ -0,0 +1,5 @@
fastapi>=0.109
uvicorn[standard]>=0.27
websockets>=12.0
cryptography>=41.0
httpx>=0.26