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

17
README.md Normal file
View File

@@ -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.

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

View File

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

View File

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

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

@@ -0,0 +1,125 @@
"""Bridge local HTTP API and Cloud WebSocket client."""
import asyncio
import re
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config, Server
from bridge_core.app_config import get_allowed_origins, get_cloud_url
from bridge_core.cloud_client import run_cloud_client
from bridge_core.pairing_ui import create_pairing_app
from bridge_core.ssl_util import generate_self_signed_cert
from bridge_core.state import get_bridge_id, get_claim_token, get_tenant_name, get_ws_connected, is_claimed, is_locked, rotate_claim_token
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# IPv4 with optional :port
HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$")
PAIRING_PORT = 8088
allowed_origins = get_allowed_origins()
async def _rotate_ots_periodically() -> None:
"""Rotate OTS every 15 minutes while still unclaimed."""
while True:
await asyncio.sleep(900) # 15 min
if get_claim_token():
rotate_claim_token()
logger.debug("OTS rotated")
@asynccontextmanager
async def lifespan(app: FastAPI):
cloud_url = get_cloud_url()
pairing_queue = asyncio.Queue()
ws_task = asyncio.create_task(run_cloud_client(cloud_url, pairing_queue))
rot_task = asyncio.create_task(_rotate_ots_periodically())
pairing_task = None
try:
key_path, cert_path = generate_self_signed_cert()
def get_connection_info():
return {"claimed": is_claimed(), "tenant_name": get_tenant_name(), "is_online": get_ws_connected()}
pairing_app = create_pairing_app(pairing_queue, get_connection_info)
pairing_config = Config(
pairing_app,
host="0.0.0.0",
port=PAIRING_PORT,
ssl_keyfile=str(key_path),
ssl_certfile=str(cert_path),
)
pairing_server = Server(pairing_config)
pairing_task = asyncio.create_task(pairing_server.serve())
logger.info("Pairing UI (HTTPS) listening on port %s", PAIRING_PORT)
except Exception as e:
logger.warning("Could not start pairing UI on %s: %s", PAIRING_PORT, e)
yield
ws_task.cancel()
rot_task.cancel()
if pairing_task is not None:
pairing_task.cancel()
try:
await ws_task
except asyncio.CancelledError:
pass
try:
await rot_task
except asyncio.CancelledError:
pass
if pairing_task is not None:
try:
await pairing_task
except asyncio.CancelledError:
pass
app = FastAPI(title="OrderPy Bridge", version="1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=None,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "OPTIONS"],
allow_headers=["*"],
)
@app.middleware("http")
async def host_header_check(request: Request, call_next):
"""DNS Rebinding: only allow direct IP access."""
host = request.headers.get("host", "")
if not HOST_IP_PATTERN.match(host):
return Response("Forbidden: Direct IP access only", status_code=403)
return await call_next(request)
@app.get("/setup/info")
async def setup_info(request: Request):
"""Return bridgeId and claimToken for discovery. Fail2Ban: 503 when locked."""
origin = request.headers.get("origin")
if origin and origin not in allowed_origins:
return Response("Forbidden", status_code=403)
if is_locked():
return Response("Service temporarily unavailable", status_code=503)
bridge_id = get_bridge_id()
if not bridge_id:
# Bridge has not yet received bridgeId from Cloud (WebSocket not connected or still handshaking)
return Response(
"Bridge not yet registered with Cloud; wait a few seconds and retry",
status_code=503,
headers={"Retry-After": "3"},
)
# Discovery: bridgeId only. claimToken kept for backward compatibility during transition.
token = get_claim_token()
return {"bridgeId": bridge_id, "claimToken": token, "pairing_ui": True}
@app.get("/health")
async def health():
return {"status": "ok"}

View File

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

71
daemon/README.md Normal file
View File

@@ -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`.

124
daemon/main.py Normal file
View File

@@ -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"}

View File

@@ -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

191
daemon/packaging/install.sh Executable file
View File

@@ -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<BAR_W; i++ )); do
[[ $i -eq $bar_pos ]] && bar="${bar}>" || bar="${bar} "
done
printf "\r [%s] Installing... " "$bar"
bar_pos=$(( (bar_pos + 1) % BAR_W ))
sleep 0.08
done
wait "$INSTALL_PID" 2>/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"

5
daemon/requirements.txt Normal file
View File

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

View File

@@ -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

27
data/bridge_key.pem Normal file
View File

@@ -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-----

9
data/bridge_key.pub Normal file
View File

@@ -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-----

5
debian/changelog vendored Normal file
View File

@@ -0,0 +1,5 @@
orderpy-bridge (1.0.0) unstable; urgency=medium
* Initial daemon package (systemd, /opt/orderpy-bridge, venv).
-- OrderPy <support@orderpy.com> Sat, 21 Feb 2026 12:00:00 +0000

1
debian/compat vendored Normal file
View File

@@ -0,0 +1 @@
13

14
debian/control vendored Normal file
View File

@@ -0,0 +1,14 @@
Source: orderpy-bridge
Section: net
Priority: optional
Maintainer: OrderPy <support@orderpy.com>
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.

34
debian/postinst vendored Normal file
View File

@@ -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"

12
debian/rules vendored Executable file
View File

@@ -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/

21
docker-compose.yml Normal file
View File

@@ -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