all
This commit is contained in:
1
bridge_core/__init__.py
Normal file
1
bridge_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Shared core for OrderPy Bridge (container and daemon).
|
||||
21
bridge_core/app_config.py
Normal file
21
bridge_core/app_config.py
Normal 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
423
bridge_core/cloud_client.py
Normal 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
206
bridge_core/pairing_ui.py
Normal 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
44
bridge_core/ssl_util.py
Normal 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
133
bridge_core/state.py
Normal 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
|
||||
Reference in New Issue
Block a user