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

1
bridge_core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Shared core for OrderPy Bridge (container and daemon).

21
bridge_core/app_config.py Normal file
View File

@@ -0,0 +1,21 @@
import os
def get_cloud_url() -> str:
return os.environ.get("ORDERPY_CLOUD_URL", "http://localhost:8001").rstrip("/").replace("http://", "ws://").replace("https://", "wss://")
def get_printer_health_interval() -> int:
"""Seconds between printer health checks. Default 10."""
try:
return max(5, int(os.environ.get("ORDERPY_PRINTER_HEALTH_INTERVAL", "10")))
except ValueError:
return 10
def get_allowed_origins() -> list[str]:
raw = os.environ.get(
"ORDERPY_ALLOWED_ORIGINS",
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173",
)
return [o.strip() for o in raw.split(",") if o.strip()]

423
bridge_core/cloud_client.py Normal file
View File

@@ -0,0 +1,423 @@
"""WebSocket client to OrderPy Cloud. Sends pubKey, receives bridgeId; handles verify_claim, print_test, config, printer_status, print_order."""
import asyncio
import base64
import json
import logging
import os
from typing import Any
from bridge_core.state import (
check_token,
get_bridge_id,
invalidate_claim_token,
record_verify_failure,
set_bridge_id,
set_tenant_name,
set_ws_connected,
unpair,
)
logger = logging.getLogger(__name__)
# Printers for healthcheck: [{id, address, port}], updated on config
_printers: list[dict[str, Any]] = []
_health_task: asyncio.Task[None] | None = None
# API key from config; used to fetch print payload when backend sends minimal print_order
_api_key: str | None = None
_cloud_http_url: str = ""
async def _print_service_call_to_all_printers(payload: dict) -> None:
"""Send receipt bytes to all configured printers (no ack to cloud). Payload must have receipt_bytes_base64."""
b64 = payload.get("receipt_bytes_base64")
if not b64:
logger.warning("print_service_call missing receipt_bytes_base64")
return
try:
data = base64.b64decode(b64)
except Exception as e:
logger.warning("print_service_call invalid receipt_bytes_base64: %s", e)
return
for p in _printers:
address = p.get("address", "")
port = int(p.get("port", 9100))
if address and 1 <= port <= 65535:
ok = await _send_bytes_to_printer(address, port, data)
if ok:
logger.info("Service call sent to printer %s:%s", address, port)
async def _send_bytes_to_printer(address: str, port: int, data: bytes) -> bool:
"""Send raw ESC/POS bytes to printer. Returns True on success."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(address, port),
timeout=5.0,
)
try:
writer.write(data)
await asyncio.wait_for(writer.drain(), timeout=15.0)
finally:
writer.close()
try:
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
except asyncio.TimeoutError:
pass
return True
except (OSError, asyncio.TimeoutError) as e:
logger.warning("Send to %s:%s failed: %s", address, port, e)
return False
except Exception as e:
logger.warning("Send to %s:%s unexpected error: %s", address, port, e)
return False
async def _fetch_order_print_payload(order_id: str, printer_id: str | None) -> str | None:
"""GET receipt payload from backend. Returns receipt_bytes_base64 or None."""
global _api_key, _cloud_http_url
if not _api_key or not _cloud_http_url:
logger.warning("Cannot fetch print payload: api_key=%s cloud_http_url=%s", bool(_api_key), bool(_cloud_http_url))
return None
try:
import httpx
path = f"/api/v1/bridges/print-jobs/orders/{order_id}"
if printer_id:
path += f"?printer_id={printer_id}"
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(
_cloud_http_url + path,
headers={"X-Bridge-Token": _api_key},
)
if r.status_code != 200:
logger.warning("Fetch print payload %s: %s", path, r.status_code)
return None
data = r.json()
return data.get("receipt_bytes_base64")
except Exception as e:
logger.warning("Fetch print payload failed: %s", e)
return None
async def _print_order_to_all_printers(payload: dict, ws: Any = None) -> None:
"""Send receipt bytes to target printer(s). Payload may have receipt_bytes_base64 or we fetch via API."""
order_id = payload.get("order_id")
target_printer_id = payload.get("printer_id")
b64 = payload.get("receipt_bytes_base64")
if not b64 and order_id:
b64 = await _fetch_order_print_payload(str(order_id), str(target_printer_id) if target_printer_id else None)
if not b64:
logger.warning("print_order missing receipt_bytes_base64 and could not fetch")
if order_id and ws:
try:
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
except Exception:
pass
return
try:
data = base64.b64decode(b64)
except Exception as e:
logger.warning("print_order invalid receipt_bytes_base64: %s", e)
if order_id and ws:
try:
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
except Exception:
pass
return
printers = list(_printers)
if target_printer_id:
printers = [p for p in printers if str(p.get("id")) == str(target_printer_id)]
if not printers:
if target_printer_id:
logger.warning("Printer %s not found for order %s", target_printer_id, order_id or "?")
else:
logger.warning("No printers configured; order %s not printed", order_id or "?")
at_least_one_ok = False
for p in printers:
address = p.get("address", "")
port = int(p.get("port", 9100))
if address and 1 <= port <= 65535:
ok = await _send_bytes_to_printer(address, port, data)
if ok:
at_least_one_ok = True
logger.info("Order %s sent to printer %s:%s", order_id or "?", address, port)
if order_id and ws:
try:
if at_least_one_ok:
await ws.send(json.dumps({"action": "order_printed", "order_id": str(order_id)}))
else:
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
except Exception as e:
logger.warning("Could not send print result to cloud: %s", e)
async def _check_printer_reachable(address: str, port: int) -> bool:
"""TCP connect to printer at address:port. Returns True if reachable."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(address, port),
timeout=3.0,
)
writer.close()
await writer.wait_closed()
return True
except (OSError, asyncio.TimeoutError):
return False
async def _run_printer_healthcheck(ws: Any) -> None:
"""Periodically check printer reachability and send status to cloud."""
from bridge_core.app_config import get_printer_health_interval
interval = get_printer_health_interval()
global _printers, _health_task
while True:
await asyncio.sleep(interval)
printers = _printers
if not printers:
continue
statuses = []
for p in printers:
printer_id = p.get("id")
address = p.get("address", "")
port = int(p.get("port", 9100))
if not printer_id or not address:
continue
reachable = False
if 1 <= port <= 65535:
reachable = await _check_printer_reachable(address, port)
statuses.append({"printer_id": printer_id, "reachable": reachable})
if statuses:
try:
await ws.send(json.dumps({"action": "printer_status", "statuses": statuses}))
except Exception:
break
def _get_public_key() -> str:
"""Persistent keypair: load or generate and save. Returns PEM public key."""
key_path = os.environ.get("ORDERPY_KEY_PATH", "./data/bridge_key.pem")
data_dir = os.path.dirname(key_path)
if data_dir and not os.path.isdir(data_dir):
os.makedirs(data_dir, exist_ok=True)
priv_path = key_path if key_path.endswith(".pem") else key_path + ".priv"
pub_path = key_path.replace(".pem", ".pub") if ".pem" in key_path else key_path + ".pub"
if os.path.isfile(pub_path):
with open(pub_path, "r") as f:
return f.read()
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
pub_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
with open(priv_path, "wb") as f:
f.write(priv_pem)
with open(pub_path, "wb") as f:
f.write(pub_pem)
return pub_pem.decode()
async def run_cloud_client(cloud_ws_url: str = "", pairing_queue: asyncio.Queue | None = None) -> None:
global _health_task, _api_key, _cloud_http_url
from bridge_core.app_config import get_cloud_url
url = cloud_ws_url or get_cloud_url()
ws_url = f"{url}/api/v1/bridges/connect"
try:
pub_key = _get_public_key()
except Exception as e:
logger.error("Failed to load/generate keypair: %s — bridge cannot connect to cloud", e)
return
while True:
try:
import websockets
async with websockets.connect(ws_url, close_timeout=5) as ws:
set_ws_connected(True)
try:
await ws.send(json.dumps({"pubKey": pub_key, "version": "1.0"}))
msg = await ws.recv()
data = json.loads(msg)
bridge_id = data.get("bridgeId") or data.get("bridge_id")
status = data.get("status", "UNCLAIMED")
if status == "UNCLAIMED":
unpair()
elif status == "ACTIVE":
invalidate_claim_token()
if bridge_id:
set_bridge_id(bridge_id)
logger.info("Registered with Cloud: bridge_id=%s, status=%s", bridge_id, status)
_printers.clear()
_health_task = None
pending_pairing_future: asyncio.Future | None = None
while True:
recv_task = asyncio.create_task(ws.recv())
tasks = [recv_task]
get_task = None
if pairing_queue is not None:
get_task = asyncio.create_task(pairing_queue.get())
tasks.append(get_task)
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
try:
await t
except asyncio.CancelledError:
pass
raw = None
pairing_item = None
recv_failed = False
if recv_task in done:
try:
raw = recv_task.result()
except Exception:
recv_failed = True
if get_task is not None and get_task in done:
try:
pairing_item = get_task.result()
except Exception:
pass
if pairing_item is not None:
code, future = pairing_item
if recv_failed:
if not future.done():
future.set_result({"ok": False, "reason": "connection_lost"})
else:
try:
await ws.send(json.dumps({"action": "submit_pairing_code", "code": code}))
pending_pairing_future = future
except Exception as e:
logger.warning("Failed to send submit_pairing_code: %s", e)
if not future.done():
future.set_result({"ok": False, "reason": "send_failed"})
if recv_failed:
logger.info("WebSocket recv failed, reconnecting…")
break
if raw is None:
continue
msg_data = json.loads(raw)
action = msg_data.get("action")
if action == "pairing_result":
if pending_pairing_future is not None and not pending_pairing_future.done():
pending_pairing_future.set_result({
"ok": bool(msg_data.get("ok", False)),
"reason": msg_data.get("reason"),
})
pending_pairing_future = None
continue
if action == "verify_claim":
token = msg_data.get("claimToken", "")
ok = check_token(token)
if ok:
invalidate_claim_token()
else:
record_verify_failure()
await ws.send(json.dumps({"ok": ok}))
elif action == "config":
_api_key = msg_data.get("api_key") or None
if url.startswith("wss://"):
_cloud_http_url = "https://" + url[6:]
elif url.startswith("ws://"):
_cloud_http_url = "http://" + url[5:]
else:
_cloud_http_url = url
_cloud_http_url = _cloud_http_url.rstrip("/")
set_tenant_name(msg_data.get("tenant_name") or "")
invalidate_claim_token()
printers_list = msg_data.get("printers", [])
if isinstance(printers_list, list):
_printers.clear()
_printers.extend(
{"id": p.get("id"), "address": p.get("address", ""), "port": int(p.get("port", 9100))}
for p in printers_list
if p.get("id") and p.get("address")
)
if _health_task is None and _printers:
_health_task = asyncio.create_task(_run_printer_healthcheck(ws))
elif action == "print_order":
payload = {
"order_id": msg_data.get("order_id"),
"printer_id": msg_data.get("printer_id"),
"receipt_bytes_base64": msg_data.get("receipt_bytes_base64"),
}
asyncio.create_task(_print_order_to_all_printers(payload, ws))
elif action == "print_service_call":
payload = {
"receipt_bytes_base64": msg_data.get("receipt_bytes_base64"),
}
asyncio.create_task(_print_service_call_to_all_printers(payload))
elif action == "config_update":
printers_list = msg_data.get("printers", [])
if isinstance(printers_list, list):
updated = [
{"id": p.get("id"), "address": p.get("address", ""), "port": int(p.get("port", 9100))}
for p in printers_list
if p.get("id") and p.get("address")
]
if not updated:
continue
by_id = {p["id"]: p for p in _printers}
for p in updated:
by_id[p["id"]] = p
_printers[:] = list(by_id.values())
if _printers and updated:
statuses = []
for p in updated:
printer_id = p.get("id")
address = p.get("address", "")
port = int(p.get("port", 9100))
reachable = False
if 1 <= port <= 65535 and address:
reachable = await _check_printer_reachable(address, port)
statuses.append({"printer_id": printer_id, "reachable": reachable})
if statuses:
try:
await ws.send(json.dumps({"action": "printer_status", "statuses": statuses}))
except Exception:
pass
elif action == "unpaired":
_printers.clear()
if _health_task and not _health_task.done():
_health_task.cancel()
try:
await _health_task
except asyncio.CancelledError:
pass
_health_task = None
unpair()
# Keep bridge_id so /setup/info stays 200 until we reconnect and get a new id
logger.info("Unpaired by Cloud; new claim token issued")
break # leave inner loop so we don't send (e.g. submit_pairing_code) on closed connection
elif action == "print_test":
printer_id = msg_data.get("printer_id", "")
address = msg_data.get("address", "")
port = int(msg_data.get("port", 9100))
b64 = msg_data.get("receipt_bytes_base64")
ok = False
if address and 1 <= port <= 65535 and b64:
try:
data = base64.b64decode(b64)
ok = await _send_bytes_to_printer(address, port, data)
except Exception as e:
logger.warning("print_test decode/send failed: %s", e)
await ws.send(json.dumps({
"action": "print_test_result",
"printer_id": printer_id,
"ok": ok,
}))
finally:
set_ws_connected(False)
except Exception as e:
logger.warning("Cloud WebSocket error: %s", e)
_printers.clear()
if _health_task and not _health_task.done():
_health_task.cancel()
try:
await _health_task
except asyncio.CancelledError:
pass
_health_task = None
await asyncio.sleep(5)

206
bridge_core/pairing_ui.py Normal file
View File

@@ -0,0 +1,206 @@
"""Pairing UI (port 8088): 6-digit code entry, submits to cloud via WebSocket; when claimed shows connection info."""
import asyncio
import html
import re
from typing import Any, Callable
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
# IPv4 with optional :port (same as main app)
HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$")
def _connected_html(tenant_name: str, is_online: bool) -> str:
"""HTML when bridge is claimed: status (Online/Offline) + tenant; persists on refresh until unpaired."""
safe_name = html.escape(tenant_name.strip() or "Standort")
if is_online:
badge_class = "badge badge-online"
badge_label = "Online"
badge_dot = '<span class="badge-dot" aria-hidden="true"></span>'
else:
badge_class = "badge badge-offline"
badge_label = "Offline"
badge_dot = '<span class="badge-dot badge-dot-offline" aria-hidden="true"></span>'
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OrderPy Bridge Status</title>
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }}
.card {{ background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; text-align: center; }}
.badge {{ display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; margin-bottom: 1rem; }}
.badge-online {{ background: #dcfce7; color: #166534; }}
.badge-offline {{ background: #f1f5f9; color: #64748b; }}
.badge-dot {{ width: 0.5rem; height: 0.5rem; border-radius: 50%; background: #22c55e; }}
.badge-dot-offline {{ background: #94a3b8; }}
h1 {{ font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; }}
.tenant {{ font-size: 1rem; color: #64748b; }}
.tenant strong {{ color: #1e293b; }}
</style>
</head>
<body>
<div class="card">
<div class="{badge_class}" role="status">{badge_dot} {badge_label}</div>
<h1>Bridge-Status</h1>
<p class="tenant">Verbunden mit <strong>{safe_name}</strong></p>
</div>
</body>
</html>
"""
PAIRING_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OrderPy Bridge Verbinden</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }
.card { background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; }
h1 { font-size: 1.25rem; font-weight: 700; margin: 0 0 0.5rem 0; }
p { font-size: 0.875rem; color: #64748b; margin: 0 0 1.5rem 0; line-height: 1.5; }
.digits { display: flex; gap: 0.5rem; justify-content: center; margin-bottom: 1.5rem; }
.digits input { width: 2.5rem; height: 3rem; text-align: center; font-size: 1.5rem; font-weight: 600; border: 2px solid #e2e8f0; border-radius: 0.5rem; }
.digits input:focus { outline: none; border-color: #f97316; }
.btn { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; color: white; background: #ea580c; border: none; border-radius: 0.5rem; cursor: pointer; }
.btn:hover { background: #c2410c; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.msg { font-size: 0.875rem; margin-top: 1rem; padding: 0.75rem; border-radius: 0.5rem; display: none; }
.msg.success { background: #dcfce7; color: #166534; display: block; }
.msg.error { background: #fee2e2; color: #991b1b; display: block; }
.success-card { display: none; text-align: center; padding: 2rem 0; }
.success-card.visible { display: block; }
.success-card .icon { width: 4rem; height: 4rem; margin: 0 auto 1rem; border-radius: 50%; background: #dcfce7; color: #166534; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; }
.success-card h2 { font-size: 1.5rem; font-weight: 700; color: #166534; margin: 0 0 0.5rem 0; }
.success-card p { color: #15803d; margin: 0; }
.pairing-form { display: block; }
.pairing-form.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div id="successCard" class="success-card" role="status" aria-live="polite">
<div class="icon">✓</div>
<h2>Verbunden!</h2>
<p>Das Gerät wurde erfolgreich mit dem Standort verbunden.</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem; color: #64748b;">Sie können dieses Fenster schließen.</p>
</div>
<div id="pairingForm" class="pairing-form">
<h1>Bridge mit OrderPy verbinden</h1>
<p>Geben Sie den 6-stelligen Code ein, der in der OrderPy-App angezeigt wird.</p>
<form id="pairForm">
<div class="digits" role="group" aria-label="6-stelliger Code">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 1" data-idx="0">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 2" data-idx="1">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 3" data-idx="2">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 4" data-idx="3">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 5" data-idx="4">
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 6" data-idx="5">
</div>
<button type="submit" class="btn" id="submitBtn">Verbinden</button>
</form>
<div id="msg" class="msg" role="status"></div>
</div>
</div>
<script>
const inputs = document.querySelectorAll('.digits input');
const form = document.getElementById('pairForm');
const submitBtn = document.getElementById('submitBtn');
const msgEl = document.getElementById('msg');
const successCard = document.getElementById('successCard');
const pairingForm = document.getElementById('pairingForm');
function getCode() { return Array.from(inputs).map(i => i.value).join('').replace(/\\D/g, '').slice(0, 6); }
function setMsg(text, isError) { msgEl.textContent = text; msgEl.className = 'msg ' + (isError ? 'error' : 'success'); }
function showSuccess() { successCard.classList.add('visible'); pairingForm.classList.add('hidden'); }
inputs.forEach((inp, i) => {
inp.addEventListener('input', () => { if (inp.value.length >= 1) inputs[i + 1]?.focus(); });
inp.addEventListener('keydown', (e) => { if (e.key === 'Backspace' && !inp.value && i > 0) inputs[i - 1].focus(); });
inp.addEventListener('paste', (e) => { e.preventDefault(); const p = (e.clipboardData?.getData('text') || '').replace(/\\D/g, '').slice(0, 6); p.split('').forEach((c, j) => { if (inputs[j]) inputs[j].value = c; }); inputs[Math.min(p.length, 5)].focus(); });
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const code = getCode();
if (code.length !== 6) { setMsg('Bitte 6 Ziffern eingeben.', true); return; }
submitBtn.disabled = true;
setMsg('Verbindung wird hergestellt…', false);
try {
const r = await fetch('/api/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
const data = await r.json();
if (data.ok) { form.reset(); showSuccess(); }
else {
var msg = data.reason === 'code_expired_or_used' ? 'Code abgelaufen oder bereits verwendet.' : data.reason === 'already_connected' ? 'Die Bridge ist bereits mit einem Standort verbunden.' : (data.reason === 'connection_lost' || data.reason === 'send_failed') ? 'Verbindung zum Server unterbrochen. Bitte in wenigen Sekunden erneut versuchen.' : (data.reason || 'Fehler.');
setMsg(msg, true);
}
} catch (err) { setMsg('Netzwerkfehler. Bitte erneut versuchen.', true); }
submitBtn.disabled = false;
});
</script>
</body>
</html>
"""
class PairRequest(BaseModel):
code: str
def create_pairing_app(
pairing_queue: asyncio.Queue,
get_connection_info: Callable[[], dict[str, Any]] | None = None,
) -> FastAPI:
"""Create FastAPI app for pairing UI. pairing_queue sends code to cloud client.
If get_connection_info is set and returns claimed=True, index shows connection info (online + tenant name) instead of the code form."""
app = FastAPI(title="OrderPy Bridge Pairing", version="1.0")
@app.middleware("http")
async def host_header_check(request: Request, call_next):
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("/", response_class=HTMLResponse)
async def index(request: Request) -> Response:
# Always reflect current state; prevent cached pairing form when already connected
no_cache = {"Cache-Control": "no-store, no-cache, must-revalidate"}
if get_connection_info:
info = get_connection_info()
if info.get("claimed"):
html_content = _connected_html(
info.get("tenant_name") or "",
info.get("is_online", False),
)
return HTMLResponse(html_content, headers=no_cache)
return HTMLResponse(PAIRING_HTML, headers=no_cache)
@app.post("/api/pair")
async def pair(body: PairRequest) -> Response:
if get_connection_info:
info = get_connection_info()
if info.get("claimed"):
return JSONResponse(
status_code=409,
content={"ok": False, "reason": "already_connected"},
headers={"Cache-Control": "no-store"},
)
code = "".join(c for c in (body.code or "").strip() if c.isdigit())[:6]
if len(code) != 6 or not code.isdigit():
return JSONResponse({"ok": False, "reason": "invalid_code"})
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
try:
pairing_queue.put_nowait((code, future))
result = await asyncio.wait_for(future, timeout=15.0)
return JSONResponse({"ok": result.get("ok", False), "reason": result.get("reason")})
except asyncio.TimeoutError:
return JSONResponse({"ok": False, "reason": "timeout"})
except asyncio.QueueFull:
return JSONResponse({"ok": False, "reason": "busy"})
return app

44
bridge_core/ssl_util.py Normal file
View File

@@ -0,0 +1,44 @@
"""Generate self-signed certificate for pairing UI (HTTPS on 8088)."""
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def generate_self_signed_cert(
cn: str = "orderpy-bridge",
valid_days: int = 365,
) -> tuple[Path, Path]:
"""Write key and cert to temp files. Returns (key_path, cert_path)."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, cn),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OrderPy Bridge"),
])
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + timedelta(days=valid_days))
.sign(key, hashes.SHA256())
)
tmp = Path(tempfile.mkdtemp(prefix="orderpy_bridge_ssl_"))
key_path = tmp / "key.pem"
cert_path = tmp / "cert.pem"
key_path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
return key_path, cert_path

133
bridge_core/state.py Normal file
View File

@@ -0,0 +1,133 @@
"""In-memory state: OTS, bridge_id from Cloud, Fail2Ban."""
import secrets
import time
from threading import Lock
# Current one-time secret (32 bytes as hex = 64 chars). Only in RAM.
_claim_token: str = ""
_claimed: bool = False # True after successful claim; do not issue new token
_lock = Lock()
# Bridge ID we got from Cloud after WebSocket register
_bridge_id: str = ""
# Tenant name from Cloud after claim (config message), for pairing UI display
_tenant_name: str = ""
# WebSocket connected to Cloud (for pairing UI online/offline badge)
_ws_connected: bool = False
# Fail2Ban: after 5 failed verify_claim responses, lock until lock_until
_fail_count = 0
_lock_until: float = 0
FAIL_THRESHOLD = 5
LOCK_SECONDS = 300 # 5 minutes
def _generate_ots() -> str:
return secrets.token_hex(32)
def get_claim_token() -> str:
with _lock:
global _claim_token, _claimed
if _claimed:
return ""
if not _claim_token:
_claim_token = _generate_ots()
return _claim_token
def rotate_claim_token() -> str:
with _lock:
global _claim_token, _claimed
if _claimed:
return ""
_claim_token = _generate_ots()
return _claim_token
def invalidate_claim_token() -> None:
"""After successful claim, clear OTS and mark as claimed."""
with _lock:
global _claim_token, _claimed
_claim_token = ""
_claimed = True
def unpair() -> str:
"""Called when Cloud sends unpaired. Reset claimed state and return new OTS."""
with _lock:
global _claim_token, _claimed, _tenant_name
_claimed = False
_tenant_name = ""
_claim_token = _generate_ots()
# _ws_connected is set by cloud_client when connection closes
return _claim_token
def set_bridge_id(bridge_id: str) -> None:
global _bridge_id
with _lock:
_bridge_id = bridge_id
def get_bridge_id() -> str:
with _lock:
return _bridge_id
def is_claimed() -> bool:
with _lock:
return _claimed
def set_tenant_name(name: str) -> None:
global _tenant_name
with _lock:
_tenant_name = name or ""
def get_tenant_name() -> str:
with _lock:
return _tenant_name
def set_ws_connected(connected: bool) -> None:
global _ws_connected
with _lock:
_ws_connected = connected
def get_ws_connected() -> bool:
with _lock:
return _ws_connected
def check_token(claimed: str) -> bool:
with _lock:
return bool(_claim_token and secrets.compare_digest(_claim_token, claimed))
def record_verify_failure() -> None:
"""Call when we responded ok: false to verify_claim. Fail2Ban logic."""
global _fail_count, _lock_until
with _lock:
_fail_count += 1
if _fail_count >= FAIL_THRESHOLD:
_lock_until = time.monotonic() + LOCK_SECONDS
def is_locked() -> bool:
with _lock:
global _fail_count
if _lock_until > 0 and time.monotonic() < _lock_until:
return True
if time.monotonic() >= _lock_until:
_fail_count = 0
return False
return False
def reset_lock_after_timeout() -> None:
"""Reset fail count after lock period expires."""
with _lock:
global _fail_count
if _lock_until > 0 and time.monotonic() >= _lock_until:
_fail_count = 0