commit 46fb96886aef2301cc3f27853861952cb95694e0 Author: Nick Adam Date: Sat Mar 14 13:01:24 2026 +0100 all diff --git a/README.md b/README.md new file mode 100644 index 0000000..23c1d76 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# OrderPy Bridge + +Verbindet lokale Drucker mit der OrderPy-Cloud: WebSocket zur Cloud, lokale HTTP-API für Bridge-Erkennung und Claim, Versand von Belegbytes an Netzwerkdrucker (ESC/POS). + +## Bridge-Typen + +| Typ | Beschreibung | Einsatz | +|-----|--------------|---------| +| **Container** | Docker-Image, läuft in einem Container | Einfache Bereitstellung mit Docker/docker-compose | +| **Daemon** | systemd-Service, nativ auf dem Host | ARM/AMD64 ohne Container, Installation per Script oder .deb | +| **Android** | App mit Foreground Service, WebSocket im Hintergrund | Mobilgerät, läuft auch bei geschlossener App | + +- **Container:** siehe `container-bridge/` und `docker-compose.yml`. Build-Kontext: Wurzel dieses Repos. +- **Daemon:** siehe [daemon/README.md](daemon/README.md) – Installation per `daemon/packaging/install.sh` oder .deb-Paket. +- **Android:** siehe `../android-bridge/` im Repo – eigenständiges Android-Projekt (Kotlin, Compose). + +Container und Daemon nutzen dieselbe Logik aus dem gemeinsamen Modul `bridge_core/`. Die Android-App implementiert das gleiche WebSocket-Protokoll eigenständig. diff --git a/bridge_core/__init__.py b/bridge_core/__init__.py new file mode 100644 index 0000000..bf927f0 --- /dev/null +++ b/bridge_core/__init__.py @@ -0,0 +1 @@ +# Shared core for OrderPy Bridge (container and daemon). diff --git a/bridge_core/app_config.py b/bridge_core/app_config.py new file mode 100644 index 0000000..5b104bb --- /dev/null +++ b/bridge_core/app_config.py @@ -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()] diff --git a/bridge_core/cloud_client.py b/bridge_core/cloud_client.py new file mode 100644 index 0000000..ae9ce74 --- /dev/null +++ b/bridge_core/cloud_client.py @@ -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) diff --git a/bridge_core/pairing_ui.py b/bridge_core/pairing_ui.py new file mode 100644 index 0000000..2de3c5b --- /dev/null +++ b/bridge_core/pairing_ui.py @@ -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 = '' + else: + badge_class = "badge badge-offline" + badge_label = "Offline" + badge_dot = '' + return f""" + + + + + OrderPy Bridge – Status + + + +
+
{badge_dot} {badge_label}
+

Bridge-Status

+

Verbunden mit {safe_name}

+
+ + +""" + +PAIRING_HTML = """ + + + + + OrderPy Bridge – Verbinden + + + +
+
+
+

Verbunden!

+

Das Gerät wurde erfolgreich mit dem Standort verbunden.

+

Sie können dieses Fenster schließen.

+
+
+

Bridge mit OrderPy verbinden

+

Geben Sie den 6-stelligen Code ein, der in der OrderPy-App angezeigt wird.

+
+
+ + + + + + +
+ +
+
+
+
+ + + +""" + + +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 diff --git a/bridge_core/ssl_util.py b/bridge_core/ssl_util.py new file mode 100644 index 0000000..3f7f4f0 --- /dev/null +++ b/bridge_core/ssl_util.py @@ -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 diff --git a/bridge_core/state.py b/bridge_core/state.py new file mode 100644 index 0000000..e36b3aa --- /dev/null +++ b/bridge_core/state.py @@ -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 diff --git a/container-bridge/Dockerfile b/container-bridge/Dockerfile new file mode 100644 index 0000000..d27da4c --- /dev/null +++ b/container-bridge/Dockerfile @@ -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"] diff --git a/container-bridge/docker-entrypoint.sh b/container-bridge/docker-entrypoint.sh new file mode 100644 index 0000000..dec33ac --- /dev/null +++ b/container-bridge/docker-entrypoint.sh @@ -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 "$@" diff --git a/container-bridge/main.py b/container-bridge/main.py new file mode 100644 index 0000000..adf7180 --- /dev/null +++ b/container-bridge/main.py @@ -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"} diff --git a/container-bridge/requirements.txt b/container-bridge/requirements.txt new file mode 100644 index 0000000..211c6f9 --- /dev/null +++ b/container-bridge/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.109 +uvicorn[standard]>=0.27 +websockets>=12.0 +cryptography>=41.0 +httpx>=0.26 diff --git a/daemon/README.md b/daemon/README.md new file mode 100644 index 0000000..1810dda --- /dev/null +++ b/daemon/README.md @@ -0,0 +1,71 @@ +# OrderPy Bridge (Linux Daemon) + +Systemd-Daemon-Variante der OrderPy Bridge: gleiche Funktionalität wie die Container-Variante, läuft nativ auf dem Host (ARM64 und AMD64). + +## Voraussetzungen + +- Linux mit systemd +- Python 3.11+ +- Netzwerkzugriff zur OrderPy-Cloud und zu den Druckern (TCP) + +## Installation + +### Mit Install-Script (aus dem Repo) + +```bash +cd /path/to/orderpy-bridge/daemon/packaging +sudo ./install.sh +``` + +Das Script legt User `orderpy-bridge`, Verzeichnisse (`/etc/orderpy-bridge`, `/var/lib/orderpy-bridge`, `/opt/orderpy-bridge`), eine Python-venv und die systemd-Unit an. + +### Mit .deb-Paket + +Aus dem Repo-Baustein bauen (im Verzeichnis `orderpy-bridge`): + +```bash +dpkg-buildpackage -us -uc -b +``` + +Installation der erzeugten `.deb`-Datei: + +```bash +sudo dpkg -i ../orderpy-bridge_1.0.0_all.deb +``` + +Architektur ist `all` (reines Python), gleiches Paket für ARM64 und AMD64. + +## Konfiguration + +Umgebungsvariablen werden über eine Env-Datei gesetzt: + +- **Konfiguration:** `/etc/orderpy-bridge/orderpy-bridge.env` +- **Persistente Daten (Schlüssel):** `/var/lib/orderpy-bridge/` + +Mindestens setzen: + +- `ORDERPY_CLOUD_URL` – z.B. `https://api.orderpy.com` +- `ORDERPY_KEY_PATH` – z.B. `/var/lib/orderpy-bridge/bridge_key.pem` +- `ORDERPY_ALLOWED_ORIGINS` – z.B. `https://admin.orderpy.com` + +Vor dem ersten Start die Datei anpassen: + +```bash +sudo nano /etc/orderpy-bridge/orderpy-bridge.env +``` + +## Start und Status + +```bash +sudo systemctl enable --now orderpy-bridge +sudo systemctl status orderpy-bridge +sudo journalctl -u orderpy-bridge -f +``` + +Die Bridge hört auf Port **8080** (HTTP für `/setup/info` und `/health`). + +## Rechte + +- Läuft unter User/Group `orderpy-bridge` (kein Root zur Laufzeit). +- Lesezugriff nur auf `/etc/orderpy-bridge/`, Schreibzugriff nur auf `/var/lib/orderpy-bridge/`. +- systemd-Optionen: `ProtectSystem=strict`, `ReadWritePaths=/var/lib/orderpy-bridge`, `PrivateTmp=yes`, `NoNewPrivileges=yes`. diff --git a/daemon/main.py b/daemon/main.py new file mode 100644 index 0000000..ba71e6b --- /dev/null +++ b/daemon/main.py @@ -0,0 +1,124 @@ +"""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"} diff --git a/daemon/orderpy-bridge.env.example b/daemon/orderpy-bridge.env.example new file mode 100644 index 0000000..ef539a1 --- /dev/null +++ b/daemon/orderpy-bridge.env.example @@ -0,0 +1,8 @@ +# Copy to /etc/orderpy-bridge/orderpy-bridge.env and adjust. +# Required: +ORDERPY_CLOUD_URL=https://api.orderpy.com +ORDERPY_KEY_PATH=/var/lib/orderpy-bridge/bridge_key.pem +ORDERPY_ALLOWED_ORIGINS=https://admin.orderpy.com + +# Optional (defaults shown): +# ORDERPY_PRINTER_HEALTH_INTERVAL=10 diff --git a/daemon/packaging/install.sh b/daemon/packaging/install.sh new file mode 100755 index 0000000..e033f0c --- /dev/null +++ b/daemon/packaging/install.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Install OrderPy Bridge daemon (systemd). Run with sudo. +set -e + +INSTALL_PREFIX="${INSTALL_PREFIX:-/opt/orderpy-bridge}" +CONFIG_DIR="/etc/orderpy-bridge" +DATA_DIR="/var/lib/orderpy-bridge" +SERVICE_USER="orderpy-bridge" +SERVICE_GROUP="orderpy-bridge" + +# Optional colors and formatting (disable if not a tty or TERM is dumb) +if [[ -t 1 ]] && [[ "${TERM:-dumb}" != "dumb" ]]; then + RED="$(tput setaf 1 2>/dev/null || true)" + GREEN="$(tput setaf 2 2>/dev/null || true)" + YELLOW="$(tput setaf 3 2>/dev/null || true)" + BOLD="$(tput bold 2>/dev/null || true)" + SGR0="$(tput sgr0 2>/dev/null || true)" +else + RED="" GREEN="" YELLOW="" BOLD="" SGR0="" +fi + +# Preflight: print check label (no newline), then call ok_line or fail_line with result (overwrites line with \r) +preflight_check() { printf " %-28s " "$1"; } +ok_line() { printf "\r %-28s ${GREEN}[ OK ]${SGR0} %s\n" "$PREFLIGHT_LABEL" "$1"; } +fail_line() { printf "\r %-28s ${RED}[ FAIL ]${SGR0} %s\n" "$PREFLIGHT_LABEL" "$1" >&2; return 1; } + +# Resolve orderpy-bridge repo root (directory containing bridge_core and daemon/) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BRIDGE_ROOT="$SCRIPT_DIR" +while [[ -n "$BRIDGE_ROOT" ]]; do + if [[ -d "$BRIDGE_ROOT/bridge_core" ]] && [[ -f "$BRIDGE_ROOT/daemon/main.py" ]]; then + break + fi + BRIDGE_ROOT="${BRIDGE_ROOT%/*}" + [[ "$BRIDGE_ROOT" == "${BRIDGE_ROOT%/*}" ]] && BRIDGE_ROOT="" +done +if [[ -z "$BRIDGE_ROOT" ]] || [[ ! -d "$BRIDGE_ROOT/bridge_core" ]]; then + echo "${RED}Error:${SGR0} Run from orderpy-bridge repo (bridge_core and daemon/ must exist)." >&2 + exit 1 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + echo "${RED}Error:${SGR0} This script must be run with sudo." >&2 + exit 1 +fi + +# --- Preflight checks --- +echo "" +echo "${BOLD}Preflight checks${SGR0}" +PREFLIGHT_FAIL=0 + +# Python 3.11+ +PREFLIGHT_LABEL="Python 3.11+" +preflight_check "$PREFLIGHT_LABEL" +PYVER="" +if command -v python3 >/dev/null 2>&1; then + PYVER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)" || true +fi +if [[ -z "$PYVER" ]]; then + fail_line "python3 not found. Install Python 3.11+ (e.g. apt install python3 python3-venv)." || PREFLIGHT_FAIL=1 +else + MAJOR="${PYVER%%.*}"; MINOR="${PYVER#*.}"; MINOR="${MINOR%%.*}" + if [[ "$MAJOR" -lt 3 ]] || { [[ "$MAJOR" -eq 3 ]] && [[ "$MINOR" -lt 11 ]]; }; then + fail_line "Python $PYVER found; 3.11+ required. Install a newer python3." || PREFLIGHT_FAIL=1 + else + ok_line "Python $PYVER" + fi +fi + +# python3-venv +PREFLIGHT_LABEL="python3-venv" +preflight_check "$PREFLIGHT_LABEL" +if python3 -m venv --help >/dev/null 2>&1; then + ok_line "available" +else + fail_line "not available. Install it (e.g. apt install python3-venv)." || PREFLIGHT_FAIL=1 +fi + +# systemd +PREFLIGHT_LABEL="systemd" +preflight_check "$PREFLIGHT_LABEL" +if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then + ok_line "available" +else + fail_line "not found or not running. This installer is for systemd-based systems." || PREFLIGHT_FAIL=1 +fi + +# Write access to target dirs +PREFLIGHT_LABEL="Write access (prefix)" +preflight_check "$PREFLIGHT_LABEL" +PARENT="$(dirname "$INSTALL_PREFIX")" +if [[ -w "$PARENT" ]] || [[ ! -e "$PARENT" ]]; then + ok_line "Can install to $INSTALL_PREFIX" +else + fail_line "No write access to $(dirname "$INSTALL_PREFIX"). Set INSTALL_PREFIX or fix permissions." || PREFLIGHT_FAIL=1 +fi + +PREFLIGHT_LABEL="Config directory" +preflight_check "$PREFLIGHT_LABEL" +if [[ -w /etc ]] || [[ -d "$CONFIG_DIR" && -w "$CONFIG_DIR" ]]; then + ok_line "/etc/orderpy-bridge can be created" +else + fail_line "Cannot create $CONFIG_DIR. Run with sudo." || PREFLIGHT_FAIL=1 +fi + +echo "" +if [[ "$PREFLIGHT_FAIL" -ne 0 ]]; then + echo "${RED}${BOLD}Preflight failed.${SGR0} Fix the issues above and run the script again." >&2 + exit 1 +fi +echo "${GREEN}All preflight checks passed.${SGR0}" +echo "" + +# Installation runs in background; we show a progress bar until it finishes. +ENV_FILE="$CONFIG_DIR/orderpy-bridge.env" +INSTALL_LOG="$(mktemp)" +INSTALL_EXIT_FILE="$(mktemp)" +trap 'rm -f "$INSTALL_LOG" "$INSTALL_EXIT_FILE"' EXIT + +do_install() { + set +e + # User/group + if ! getent group "$SERVICE_GROUP" >/dev/null; then + groupadd --system "$SERVICE_GROUP" + fi + if ! getent passwd "$SERVICE_USER" >/dev/null; then + useradd --system --no-create-home --gid "$SERVICE_GROUP" "$SERVICE_USER" + fi + # Directories + install -d -m 755 "$CONFIG_DIR" + install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR" + install -d -m 755 "$(dirname "$INSTALL_PREFIX")" + install -d -m 755 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$INSTALL_PREFIX" + # Config: env file only if missing + if [[ ! -f "$ENV_FILE" ]]; then + install -m 640 -o root -g "$SERVICE_GROUP" "$BRIDGE_ROOT/daemon/orderpy-bridge.env.example" "$ENV_FILE" + fi + # Application files + cp -r "$BRIDGE_ROOT/bridge_core" "$INSTALL_PREFIX/" + install -m 644 "$BRIDGE_ROOT/daemon/main.py" "$INSTALL_PREFIX/" + install -m 644 "$BRIDGE_ROOT/daemon/requirements.txt" "$INSTALL_PREFIX/" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_PREFIX" + # Python venv (pip quiet to keep log small) + if [[ ! -x "$INSTALL_PREFIX/venv/bin/uvicorn" ]]; then + python3 -m venv "$INSTALL_PREFIX/venv" + "$INSTALL_PREFIX/venv/bin/pip" install --disable-pip-version-check -q -r "$INSTALL_PREFIX/requirements.txt" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_PREFIX/venv" + fi + # systemd unit + install -m 644 "$BRIDGE_ROOT/daemon/systemd/orderpy-bridge.service" /etc/systemd/system/ + systemctl daemon-reload + exit 0 +} + +( do_install; echo $? > "$INSTALL_EXIT_FILE" ) >> "$INSTALL_LOG" 2>&1 & +INSTALL_PID=$! + +# Progress bar (indeterminate: moving cursor) +BAR_W=24 +bar_pos=0 +printf "\n Installing to %s\n " "$INSTALL_PREFIX" +while kill -0 "$INSTALL_PID" 2>/dev/null; do + bar="" + for (( i=0; i/dev/null || true +EXIT_CODE=0 +[[ -f "$INSTALL_EXIT_FILE" ]] && read -r EXIT_CODE < "$INSTALL_EXIT_FILE" || true + +if [[ "$EXIT_CODE" -ne 0 ]]; then + printf "\r [%*s] ${RED}Installation failed.${SGR0}\n" "$BAR_W" " " + echo "" + echo "Last lines of install log:" + tail -n 20 "$INSTALL_LOG" | sed 's/^/ /' + cp "$INSTALL_LOG" /tmp/orderpy-bridge-install.log 2>/dev/null || true + echo "" + echo "Full log: /tmp/orderpy-bridge-install.log" + exit "$EXIT_CODE" +fi + +printf "\r [%s] ${GREEN}Done!${SGR0} \n\n" "$(printf '%*s' "$BAR_W" '' | tr ' ' '=')" +echo "If this is a fresh install, edit $ENV_FILE and set ORDERPY_CLOUD_URL, ORDERPY_ALLOWED_ORIGINS." +echo "" +echo "Next steps:" +echo " sudo systemctl enable --now orderpy-bridge" +echo " sudo systemctl status orderpy-bridge" diff --git a/daemon/requirements.txt b/daemon/requirements.txt new file mode 100644 index 0000000..211c6f9 --- /dev/null +++ b/daemon/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.109 +uvicorn[standard]>=0.27 +websockets>=12.0 +cryptography>=41.0 +httpx>=0.26 diff --git a/daemon/systemd/orderpy-bridge.service b/daemon/systemd/orderpy-bridge.service new file mode 100644 index 0000000..9ebf120 --- /dev/null +++ b/daemon/systemd/orderpy-bridge.service @@ -0,0 +1,21 @@ +[Unit] +Description=OrderPy Bridge daemon (WebSocket + local setup API) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=orderpy-bridge +Group=orderpy-bridge +EnvironmentFile=/etc/orderpy-bridge/orderpy-bridge.env +WorkingDirectory=/opt/orderpy-bridge +ExecStart=/opt/orderpy-bridge/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080 +Restart=on-failure +RestartSec=5 +ProtectSystem=strict +ReadWritePaths=/var/lib/orderpy-bridge +PrivateTmp=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/data/bridge_key.pem b/data/bridge_key.pem new file mode 100644 index 0000000..7b7fe4c --- /dev/null +++ b/data/bridge_key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAlUQ89seNmZC/e4oaH0JXLYGli/Z+dIKVagAUjn0sBr0ucmJk +6mdSIhTrLEaRoXlHBBrgu8HfozkyaxsJOq/i36IqfD7kTVM7N+f6WivFfNS/Z3ef +25Ef2S7iToMDXNNzSLPlyrmh4OTygJMMj35Xtm64srZYOcWnuSyq0O1iLktKKzlW +YgzFcl+qVxrX7Rm0d13UgsqtcGHeqatMJNQtKgOZe367NV/QnHmnFRZGiAxwxm4K +BEZJcGaSUhCa7lG3x5pdaIdbPiuEtL653/6Q8P9dRWtZMPEKIi4umaeWYLYo26L4 +8ImrnbHDTvQU66S50rrutVVVmCpF8EpFJAHV9wIDAQABAoIBAAL8flhqQ0/PC2MD +hHnwoAGLO6PSYX2fk9wENrpnuwbtfQLmgLdcgXz+EdBtHwJt2eO1TUlkW77jvjmn +V6bCwxVkpK30rNOhNn5BWAsQH3TbusftUZeZI9vP/3trophrqz2en8UlCUiQZrZM +0C5aFujRoUSuDOjRpq/ikA5QWYEB58btqhjjLsU3cv4vtkZWMyW83cTtktvHRqDy +ZufK3YRTKosOZzI/Va3uoBWydUU8AUBOL6pN/v7RfzDAV0poSpOgXlYL3oPHoYg4 +9nFf7q6CVy1bepPjPtLvAkXiBpk87mYkm/0s/mCuZrqb26Aik80/PYiL47wVqj94 +qxQ+k9UCgYEAzL7hhTVzoNGeV75a0p8Ts7ZLSFR56/iJfwxNFx80IowFjYDqCvU1 +M/xFaVx5y9J+4fh81wTUnYU0kwAiRBNeGhzdIxo6mQLL5KibThTHDtGTGhPOIEDG +co+Gsi0ZeR++WjFOW94RmpuYc1TyUUT1GFPfk6q98sRND5g+jsxKFeUCgYEAuqH7 +ZUpqi810xyHOJq6s1/4VijxvthuQtC2Ew7V01OuAdt13lH8P6PKJ0hPhX/ztOy24 ++V6YTeEC8Pxwj2VEc2mJqFf0+9U7P5/gIGVB+bjqGDvNM1Wp6ozMBZKq2gFP3nc9 +UiwVCKS/15sAtbFBO0k4mI2NJVSEqxtfhyzc/qsCgYEAs+CrLyXTrSEcNMg28L3z +SDrKjwQwjUCwQ58iB0NRwVw08KmmdPQSxtZGgRdOpeQLtylhPGKxDKbflppSgG5n +iRd8rH85pf4P9Zavwvx8GafDzfBCcpGWB0XTN6xpqcFasdCJoCpMWwGCASlLLl0f +2zysuwYRlTwi26WMqFYQbIUCgYAnWOlAlKzb5qgdJ5Jn82G7c/UknNNMiIk8g3A/ +Nq14CmKeLNj+NL+s9B18bfaRHykA1gXuhTQFD1BocEBm6wnAb1q3ZDvhMDZ6loFR +Myfytzqbe1gq33+gVKja7+4XYjlthKQoA+U3Wkyb7zD6HXHMRwaomwdL/IKv9Ghy +flHlWwKBgQCOkOaA2SrIoDFyByuElgtAgSMq/sZKO4wZCBEyJCvD3DFKi2OmX+op +rnwtz2b4JOczimiS/MLMnHTJgg1ksn1S0syjioj03Lg9lCd/R+Q/GCnbLOoXZyrM +M3XHdybIjCCitS4wGOilFVZQUKM3JEgp7r1aRWIDrfHmheyraCtLGA== +-----END RSA PRIVATE KEY----- diff --git a/data/bridge_key.pub b/data/bridge_key.pub new file mode 100644 index 0000000..698cd6b --- /dev/null +++ b/data/bridge_key.pub @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlUQ89seNmZC/e4oaH0JX +LYGli/Z+dIKVagAUjn0sBr0ucmJk6mdSIhTrLEaRoXlHBBrgu8HfozkyaxsJOq/i +36IqfD7kTVM7N+f6WivFfNS/Z3ef25Ef2S7iToMDXNNzSLPlyrmh4OTygJMMj35X +tm64srZYOcWnuSyq0O1iLktKKzlWYgzFcl+qVxrX7Rm0d13UgsqtcGHeqatMJNQt +KgOZe367NV/QnHmnFRZGiAxwxm4KBEZJcGaSUhCa7lG3x5pdaIdbPiuEtL653/6Q +8P9dRWtZMPEKIi4umaeWYLYo26L48ImrnbHDTvQU66S50rrutVVVmCpF8EpFJAHV +9wIDAQAB +-----END PUBLIC KEY----- diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..16c9b1e --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +orderpy-bridge (1.0.0) unstable; urgency=medium + + * Initial daemon package (systemd, /opt/orderpy-bridge, venv). + + -- OrderPy Sat, 21 Feb 2026 12:00:00 +0000 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..b1bd38b --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +13 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..b73d5e8 --- /dev/null +++ b/debian/control @@ -0,0 +1,14 @@ +Source: orderpy-bridge +Section: net +Priority: optional +Maintainer: OrderPy +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 + +Package: orderpy-bridge +Architecture: all +Depends: python3 (>= 3.11), python3-venv, ${misc:Depends} +Description: OrderPy Bridge daemon (WebSocket + local setup API) + Connects to OrderPy Cloud via WebSocket and exposes a local HTTP API + for bridge discovery and claim. Sends receipt bytes to configured + network printers. Runs as a systemd service with minimal privileges. diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..d2fba2d --- /dev/null +++ b/debian/postinst @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +SERVICE_USER="orderpy-bridge" +SERVICE_GROUP="orderpy-bridge" +DATA_DIR="/var/lib/orderpy-bridge" +INSTALL_DIR="/opt/orderpy-bridge" +ENV_FILE="/etc/orderpy-bridge/orderpy-bridge.env" + +if ! getent group "$SERVICE_GROUP" >/dev/null; then + groupadd --system "$SERVICE_GROUP" +fi +if ! getent passwd "$SERVICE_USER" >/dev/null; then + useradd --system --no-create-home --gid "$SERVICE_GROUP" "$SERVICE_USER" +fi + +install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR" + +if [[ ! -f "$ENV_FILE" ]]; then + install -m 640 -o root -g "$SERVICE_GROUP" /etc/orderpy-bridge/orderpy-bridge.env.example "$ENV_FILE" + echo "Created $ENV_FILE — please edit and set ORDERPY_CLOUD_URL, ORDERPY_ALLOWED_ORIGINS." +fi + +chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR" + +if [[ ! -x "$INSTALL_DIR/venv/bin/uvicorn" ]]; then + python3 -m venv "$INSTALL_DIR/venv" + "$INSTALL_DIR/venv/bin/pip" install --disable-pip-version-check -r "$INSTALL_DIR/requirements.txt" + chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR/venv" +fi + +systemctl daemon-reload + +echo "OrderPy Bridge installed. Edit $ENV_FILE then: systemctl enable --now orderpy-bridge" diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..810f2f8 --- /dev/null +++ b/debian/rules @@ -0,0 +1,12 @@ +#!/usr/bin/make -f +%: + dh $@ + +override_dh_auto_install: + install -d debian/orderpy-bridge/opt/orderpy-bridge + cp -r bridge_core debian/orderpy-bridge/opt/orderpy-bridge/ + install -m 644 daemon/main.py daemon/requirements.txt debian/orderpy-bridge/opt/orderpy-bridge/ + install -d debian/orderpy-bridge/etc/orderpy-bridge + install -m 640 daemon/orderpy-bridge.env.example debian/orderpy-bridge/etc/orderpy-bridge/orderpy-bridge.env.example + install -d debian/orderpy-bridge/etc/systemd/system + install -m 644 daemon/systemd/orderpy-bridge.service debian/orderpy-bridge/etc/systemd/system/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..974cdaf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + orderpy-bridge: + build: + context: . + dockerfile: container-bridge/Dockerfile + container_name: orderpy-bridge + restart: unless-stopped + ports: + - "8080:8080" + - "8088:8088" + volumes: + # Da das Compose-File eins weiter oben liegt, + # wird der 'data' Ordner nun dort erstellt, wo auch das Compose-File liegt. + - ./data:/app/data + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - ORDERPY_KEY_PATH=/app/data/bridge_key.pem + # Lokale Dev: App (5173) und Platform (5174), jeweils localhost + 127.0.0.1 für Discovery/CORS + - ORDERPY_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174 + - ORDERPY_CLOUD_URL=http://host.docker.internal:8001 \ No newline at end of file