all
This commit is contained in:
21
container-bridge/Dockerfile
Normal file
21
container-bridge/Dockerfile
Normal 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"]
|
||||
7
container-bridge/docker-entrypoint.sh
Normal file
7
container-bridge/docker-entrypoint.sh
Normal 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
125
container-bridge/main.py
Normal 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"}
|
||||
5
container-bridge/requirements.txt
Normal file
5
container-bridge/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.109
|
||||
uvicorn[standard]>=0.27
|
||||
websockets>=12.0
|
||||
cryptography>=41.0
|
||||
httpx>=0.26
|
||||
Reference in New Issue
Block a user