all
This commit is contained in:
17
README.md
Normal file
17
README.md
Normal 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
1
bridge_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Shared core for OrderPy Bridge (container and daemon).
|
||||||
21
bridge_core/app_config.py
Normal file
21
bridge_core/app_config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_cloud_url() -> str:
|
||||||
|
return os.environ.get("ORDERPY_CLOUD_URL", "http://localhost:8001").rstrip("/").replace("http://", "ws://").replace("https://", "wss://")
|
||||||
|
|
||||||
|
|
||||||
|
def get_printer_health_interval() -> int:
|
||||||
|
"""Seconds between printer health checks. Default 10."""
|
||||||
|
try:
|
||||||
|
return max(5, int(os.environ.get("ORDERPY_PRINTER_HEALTH_INTERVAL", "10")))
|
||||||
|
except ValueError:
|
||||||
|
return 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_allowed_origins() -> list[str]:
|
||||||
|
raw = os.environ.get(
|
||||||
|
"ORDERPY_ALLOWED_ORIGINS",
|
||||||
|
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173",
|
||||||
|
)
|
||||||
|
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||||
423
bridge_core/cloud_client.py
Normal file
423
bridge_core/cloud_client.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""WebSocket client to OrderPy Cloud. Sends pubKey, receives bridgeId; handles verify_claim, print_test, config, printer_status, print_order."""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bridge_core.state import (
|
||||||
|
check_token,
|
||||||
|
get_bridge_id,
|
||||||
|
invalidate_claim_token,
|
||||||
|
record_verify_failure,
|
||||||
|
set_bridge_id,
|
||||||
|
set_tenant_name,
|
||||||
|
set_ws_connected,
|
||||||
|
unpair,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Printers for healthcheck: [{id, address, port}], updated on config
|
||||||
|
_printers: list[dict[str, Any]] = []
|
||||||
|
_health_task: asyncio.Task[None] | None = None
|
||||||
|
# API key from config; used to fetch print payload when backend sends minimal print_order
|
||||||
|
_api_key: str | None = None
|
||||||
|
_cloud_http_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_service_call_to_all_printers(payload: dict) -> None:
|
||||||
|
"""Send receipt bytes to all configured printers (no ack to cloud). Payload must have receipt_bytes_base64."""
|
||||||
|
b64 = payload.get("receipt_bytes_base64")
|
||||||
|
if not b64:
|
||||||
|
logger.warning("print_service_call missing receipt_bytes_base64")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("print_service_call invalid receipt_bytes_base64: %s", e)
|
||||||
|
return
|
||||||
|
for p in _printers:
|
||||||
|
address = p.get("address", "")
|
||||||
|
port = int(p.get("port", 9100))
|
||||||
|
if address and 1 <= port <= 65535:
|
||||||
|
ok = await _send_bytes_to_printer(address, port, data)
|
||||||
|
if ok:
|
||||||
|
logger.info("Service call sent to printer %s:%s", address, port)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_bytes_to_printer(address: str, port: int, data: bytes) -> bool:
|
||||||
|
"""Send raw ESC/POS bytes to printer. Returns True on success."""
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(address, port),
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
writer.write(data)
|
||||||
|
await asyncio.wait_for(writer.drain(), timeout=15.0)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except (OSError, asyncio.TimeoutError) as e:
|
||||||
|
logger.warning("Send to %s:%s failed: %s", address, port, e)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Send to %s:%s unexpected error: %s", address, port, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_order_print_payload(order_id: str, printer_id: str | None) -> str | None:
|
||||||
|
"""GET receipt payload from backend. Returns receipt_bytes_base64 or None."""
|
||||||
|
global _api_key, _cloud_http_url
|
||||||
|
if not _api_key or not _cloud_http_url:
|
||||||
|
logger.warning("Cannot fetch print payload: api_key=%s cloud_http_url=%s", bool(_api_key), bool(_cloud_http_url))
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
path = f"/api/v1/bridges/print-jobs/orders/{order_id}"
|
||||||
|
if printer_id:
|
||||||
|
path += f"?printer_id={printer_id}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
r = await client.get(
|
||||||
|
_cloud_http_url + path,
|
||||||
|
headers={"X-Bridge-Token": _api_key},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning("Fetch print payload %s: %s", path, r.status_code)
|
||||||
|
return None
|
||||||
|
data = r.json()
|
||||||
|
return data.get("receipt_bytes_base64")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Fetch print payload failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_order_to_all_printers(payload: dict, ws: Any = None) -> None:
|
||||||
|
"""Send receipt bytes to target printer(s). Payload may have receipt_bytes_base64 or we fetch via API."""
|
||||||
|
order_id = payload.get("order_id")
|
||||||
|
target_printer_id = payload.get("printer_id")
|
||||||
|
b64 = payload.get("receipt_bytes_base64")
|
||||||
|
if not b64 and order_id:
|
||||||
|
b64 = await _fetch_order_print_payload(str(order_id), str(target_printer_id) if target_printer_id else None)
|
||||||
|
if not b64:
|
||||||
|
logger.warning("print_order missing receipt_bytes_base64 and could not fetch")
|
||||||
|
if order_id and ws:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("print_order invalid receipt_bytes_base64: %s", e)
|
||||||
|
if order_id and ws:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
printers = list(_printers)
|
||||||
|
if target_printer_id:
|
||||||
|
printers = [p for p in printers if str(p.get("id")) == str(target_printer_id)]
|
||||||
|
if not printers:
|
||||||
|
if target_printer_id:
|
||||||
|
logger.warning("Printer %s not found for order %s", target_printer_id, order_id or "?")
|
||||||
|
else:
|
||||||
|
logger.warning("No printers configured; order %s not printed", order_id or "?")
|
||||||
|
at_least_one_ok = False
|
||||||
|
for p in printers:
|
||||||
|
address = p.get("address", "")
|
||||||
|
port = int(p.get("port", 9100))
|
||||||
|
if address and 1 <= port <= 65535:
|
||||||
|
ok = await _send_bytes_to_printer(address, port, data)
|
||||||
|
if ok:
|
||||||
|
at_least_one_ok = True
|
||||||
|
logger.info("Order %s sent to printer %s:%s", order_id or "?", address, port)
|
||||||
|
if order_id and ws:
|
||||||
|
try:
|
||||||
|
if at_least_one_ok:
|
||||||
|
await ws.send(json.dumps({"action": "order_printed", "order_id": str(order_id)}))
|
||||||
|
else:
|
||||||
|
await ws.send(json.dumps({"action": "order_print_failed", "order_id": str(order_id)}))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not send print result to cloud: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_printer_reachable(address: str, port: int) -> bool:
|
||||||
|
"""TCP connect to printer at address:port. Returns True if reachable."""
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(address, port),
|
||||||
|
timeout=3.0,
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return True
|
||||||
|
except (OSError, asyncio.TimeoutError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_printer_healthcheck(ws: Any) -> None:
|
||||||
|
"""Periodically check printer reachability and send status to cloud."""
|
||||||
|
from bridge_core.app_config import get_printer_health_interval
|
||||||
|
interval = get_printer_health_interval()
|
||||||
|
global _printers, _health_task
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
printers = _printers
|
||||||
|
if not printers:
|
||||||
|
continue
|
||||||
|
statuses = []
|
||||||
|
for p in printers:
|
||||||
|
printer_id = p.get("id")
|
||||||
|
address = p.get("address", "")
|
||||||
|
port = int(p.get("port", 9100))
|
||||||
|
if not printer_id or not address:
|
||||||
|
continue
|
||||||
|
reachable = False
|
||||||
|
if 1 <= port <= 65535:
|
||||||
|
reachable = await _check_printer_reachable(address, port)
|
||||||
|
statuses.append({"printer_id": printer_id, "reachable": reachable})
|
||||||
|
if statuses:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"action": "printer_status", "statuses": statuses}))
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _get_public_key() -> str:
|
||||||
|
"""Persistent keypair: load or generate and save. Returns PEM public key."""
|
||||||
|
key_path = os.environ.get("ORDERPY_KEY_PATH", "./data/bridge_key.pem")
|
||||||
|
data_dir = os.path.dirname(key_path)
|
||||||
|
if data_dir and not os.path.isdir(data_dir):
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
priv_path = key_path if key_path.endswith(".pem") else key_path + ".priv"
|
||||||
|
pub_path = key_path.replace(".pem", ".pub") if ".pem" in key_path else key_path + ".pub"
|
||||||
|
if os.path.isfile(pub_path):
|
||||||
|
with open(pub_path, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
priv_pem = key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
pub_pem = key.public_key().public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
with open(priv_path, "wb") as f:
|
||||||
|
f.write(priv_pem)
|
||||||
|
with open(pub_path, "wb") as f:
|
||||||
|
f.write(pub_pem)
|
||||||
|
return pub_pem.decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_cloud_client(cloud_ws_url: str = "", pairing_queue: asyncio.Queue | None = None) -> None:
|
||||||
|
global _health_task, _api_key, _cloud_http_url
|
||||||
|
from bridge_core.app_config import get_cloud_url
|
||||||
|
url = cloud_ws_url or get_cloud_url()
|
||||||
|
ws_url = f"{url}/api/v1/bridges/connect"
|
||||||
|
try:
|
||||||
|
pub_key = _get_public_key()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load/generate keypair: %s — bridge cannot connect to cloud", e)
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
async with websockets.connect(ws_url, close_timeout=5) as ws:
|
||||||
|
set_ws_connected(True)
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"pubKey": pub_key, "version": "1.0"}))
|
||||||
|
msg = await ws.recv()
|
||||||
|
data = json.loads(msg)
|
||||||
|
bridge_id = data.get("bridgeId") or data.get("bridge_id")
|
||||||
|
status = data.get("status", "UNCLAIMED")
|
||||||
|
if status == "UNCLAIMED":
|
||||||
|
unpair()
|
||||||
|
elif status == "ACTIVE":
|
||||||
|
invalidate_claim_token()
|
||||||
|
if bridge_id:
|
||||||
|
set_bridge_id(bridge_id)
|
||||||
|
logger.info("Registered with Cloud: bridge_id=%s, status=%s", bridge_id, status)
|
||||||
|
_printers.clear()
|
||||||
|
_health_task = None
|
||||||
|
pending_pairing_future: asyncio.Future | None = None
|
||||||
|
while True:
|
||||||
|
recv_task = asyncio.create_task(ws.recv())
|
||||||
|
tasks = [recv_task]
|
||||||
|
get_task = None
|
||||||
|
if pairing_queue is not None:
|
||||||
|
get_task = asyncio.create_task(pairing_queue.get())
|
||||||
|
tasks.append(get_task)
|
||||||
|
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
raw = None
|
||||||
|
pairing_item = None
|
||||||
|
recv_failed = False
|
||||||
|
if recv_task in done:
|
||||||
|
try:
|
||||||
|
raw = recv_task.result()
|
||||||
|
except Exception:
|
||||||
|
recv_failed = True
|
||||||
|
if get_task is not None and get_task in done:
|
||||||
|
try:
|
||||||
|
pairing_item = get_task.result()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if pairing_item is not None:
|
||||||
|
code, future = pairing_item
|
||||||
|
if recv_failed:
|
||||||
|
if not future.done():
|
||||||
|
future.set_result({"ok": False, "reason": "connection_lost"})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"action": "submit_pairing_code", "code": code}))
|
||||||
|
pending_pairing_future = future
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to send submit_pairing_code: %s", e)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result({"ok": False, "reason": "send_failed"})
|
||||||
|
if recv_failed:
|
||||||
|
logger.info("WebSocket recv failed, reconnecting…")
|
||||||
|
break
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
msg_data = json.loads(raw)
|
||||||
|
action = msg_data.get("action")
|
||||||
|
if action == "pairing_result":
|
||||||
|
if pending_pairing_future is not None and not pending_pairing_future.done():
|
||||||
|
pending_pairing_future.set_result({
|
||||||
|
"ok": bool(msg_data.get("ok", False)),
|
||||||
|
"reason": msg_data.get("reason"),
|
||||||
|
})
|
||||||
|
pending_pairing_future = None
|
||||||
|
continue
|
||||||
|
if action == "verify_claim":
|
||||||
|
token = msg_data.get("claimToken", "")
|
||||||
|
ok = check_token(token)
|
||||||
|
if ok:
|
||||||
|
invalidate_claim_token()
|
||||||
|
else:
|
||||||
|
record_verify_failure()
|
||||||
|
await ws.send(json.dumps({"ok": ok}))
|
||||||
|
elif action == "config":
|
||||||
|
_api_key = msg_data.get("api_key") or None
|
||||||
|
if url.startswith("wss://"):
|
||||||
|
_cloud_http_url = "https://" + url[6:]
|
||||||
|
elif url.startswith("ws://"):
|
||||||
|
_cloud_http_url = "http://" + url[5:]
|
||||||
|
else:
|
||||||
|
_cloud_http_url = url
|
||||||
|
_cloud_http_url = _cloud_http_url.rstrip("/")
|
||||||
|
set_tenant_name(msg_data.get("tenant_name") or "")
|
||||||
|
invalidate_claim_token()
|
||||||
|
printers_list = msg_data.get("printers", [])
|
||||||
|
if isinstance(printers_list, list):
|
||||||
|
_printers.clear()
|
||||||
|
_printers.extend(
|
||||||
|
{"id": p.get("id"), "address": p.get("address", ""), "port": int(p.get("port", 9100))}
|
||||||
|
for p in printers_list
|
||||||
|
if p.get("id") and p.get("address")
|
||||||
|
)
|
||||||
|
if _health_task is None and _printers:
|
||||||
|
_health_task = asyncio.create_task(_run_printer_healthcheck(ws))
|
||||||
|
elif action == "print_order":
|
||||||
|
payload = {
|
||||||
|
"order_id": msg_data.get("order_id"),
|
||||||
|
"printer_id": msg_data.get("printer_id"),
|
||||||
|
"receipt_bytes_base64": msg_data.get("receipt_bytes_base64"),
|
||||||
|
}
|
||||||
|
asyncio.create_task(_print_order_to_all_printers(payload, ws))
|
||||||
|
elif action == "print_service_call":
|
||||||
|
payload = {
|
||||||
|
"receipt_bytes_base64": msg_data.get("receipt_bytes_base64"),
|
||||||
|
}
|
||||||
|
asyncio.create_task(_print_service_call_to_all_printers(payload))
|
||||||
|
elif action == "config_update":
|
||||||
|
printers_list = msg_data.get("printers", [])
|
||||||
|
if isinstance(printers_list, list):
|
||||||
|
updated = [
|
||||||
|
{"id": p.get("id"), "address": p.get("address", ""), "port": int(p.get("port", 9100))}
|
||||||
|
for p in printers_list
|
||||||
|
if p.get("id") and p.get("address")
|
||||||
|
]
|
||||||
|
if not updated:
|
||||||
|
continue
|
||||||
|
by_id = {p["id"]: p for p in _printers}
|
||||||
|
for p in updated:
|
||||||
|
by_id[p["id"]] = p
|
||||||
|
_printers[:] = list(by_id.values())
|
||||||
|
if _printers and updated:
|
||||||
|
statuses = []
|
||||||
|
for p in updated:
|
||||||
|
printer_id = p.get("id")
|
||||||
|
address = p.get("address", "")
|
||||||
|
port = int(p.get("port", 9100))
|
||||||
|
reachable = False
|
||||||
|
if 1 <= port <= 65535 and address:
|
||||||
|
reachable = await _check_printer_reachable(address, port)
|
||||||
|
statuses.append({"printer_id": printer_id, "reachable": reachable})
|
||||||
|
if statuses:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"action": "printer_status", "statuses": statuses}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif action == "unpaired":
|
||||||
|
_printers.clear()
|
||||||
|
if _health_task and not _health_task.done():
|
||||||
|
_health_task.cancel()
|
||||||
|
try:
|
||||||
|
await _health_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_health_task = None
|
||||||
|
unpair()
|
||||||
|
# Keep bridge_id so /setup/info stays 200 until we reconnect and get a new id
|
||||||
|
logger.info("Unpaired by Cloud; new claim token issued")
|
||||||
|
break # leave inner loop so we don't send (e.g. submit_pairing_code) on closed connection
|
||||||
|
elif action == "print_test":
|
||||||
|
printer_id = msg_data.get("printer_id", "")
|
||||||
|
address = msg_data.get("address", "")
|
||||||
|
port = int(msg_data.get("port", 9100))
|
||||||
|
b64 = msg_data.get("receipt_bytes_base64")
|
||||||
|
ok = False
|
||||||
|
if address and 1 <= port <= 65535 and b64:
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(b64)
|
||||||
|
ok = await _send_bytes_to_printer(address, port, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("print_test decode/send failed: %s", e)
|
||||||
|
await ws.send(json.dumps({
|
||||||
|
"action": "print_test_result",
|
||||||
|
"printer_id": printer_id,
|
||||||
|
"ok": ok,
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
set_ws_connected(False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cloud WebSocket error: %s", e)
|
||||||
|
_printers.clear()
|
||||||
|
if _health_task and not _health_task.done():
|
||||||
|
_health_task.cancel()
|
||||||
|
try:
|
||||||
|
await _health_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_health_task = None
|
||||||
|
await asyncio.sleep(5)
|
||||||
206
bridge_core/pairing_ui.py
Normal file
206
bridge_core/pairing_ui.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Pairing UI (port 8088): 6-digit code entry, submits to cloud via WebSocket; when claimed shows connection info."""
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# IPv4 with optional :port (same as main app)
|
||||||
|
HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_html(tenant_name: str, is_online: bool) -> str:
|
||||||
|
"""HTML when bridge is claimed: status (Online/Offline) + tenant; persists on refresh until unpaired."""
|
||||||
|
safe_name = html.escape(tenant_name.strip() or "Standort")
|
||||||
|
if is_online:
|
||||||
|
badge_class = "badge badge-online"
|
||||||
|
badge_label = "Online"
|
||||||
|
badge_dot = '<span class="badge-dot" aria-hidden="true"></span>'
|
||||||
|
else:
|
||||||
|
badge_class = "badge badge-offline"
|
||||||
|
badge_label = "Offline"
|
||||||
|
badge_dot = '<span class="badge-dot badge-dot-offline" aria-hidden="true"></span>'
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OrderPy Bridge – Status</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{ font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }}
|
||||||
|
.card {{ background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; text-align: center; }}
|
||||||
|
.badge {{ display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; margin-bottom: 1rem; }}
|
||||||
|
.badge-online {{ background: #dcfce7; color: #166534; }}
|
||||||
|
.badge-offline {{ background: #f1f5f9; color: #64748b; }}
|
||||||
|
.badge-dot {{ width: 0.5rem; height: 0.5rem; border-radius: 50%; background: #22c55e; }}
|
||||||
|
.badge-dot-offline {{ background: #94a3b8; }}
|
||||||
|
h1 {{ font-size: 1.25rem; font-weight: 700; margin: 0 0 1rem 0; }}
|
||||||
|
.tenant {{ font-size: 1rem; color: #64748b; }}
|
||||||
|
.tenant strong {{ color: #1e293b; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="{badge_class}" role="status">{badge_dot} {badge_label}</div>
|
||||||
|
<h1>Bridge-Status</h1>
|
||||||
|
<p class="tenant">Verbunden mit <strong>{safe_name}</strong></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
PAIRING_HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OrderPy Bridge – Verbinden</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f8fafc; color: #1e293b; padding: 1rem; }
|
||||||
|
.card { background: white; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); padding: 2rem; max-width: 24rem; width: 100%; }
|
||||||
|
h1 { font-size: 1.25rem; font-weight: 700; margin: 0 0 0.5rem 0; }
|
||||||
|
p { font-size: 0.875rem; color: #64748b; margin: 0 0 1.5rem 0; line-height: 1.5; }
|
||||||
|
.digits { display: flex; gap: 0.5rem; justify-content: center; margin-bottom: 1.5rem; }
|
||||||
|
.digits input { width: 2.5rem; height: 3rem; text-align: center; font-size: 1.5rem; font-weight: 600; border: 2px solid #e2e8f0; border-radius: 0.5rem; }
|
||||||
|
.digits input:focus { outline: none; border-color: #f97316; }
|
||||||
|
.btn { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; color: white; background: #ea580c; border: none; border-radius: 0.5rem; cursor: pointer; }
|
||||||
|
.btn:hover { background: #c2410c; }
|
||||||
|
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.msg { font-size: 0.875rem; margin-top: 1rem; padding: 0.75rem; border-radius: 0.5rem; display: none; }
|
||||||
|
.msg.success { background: #dcfce7; color: #166534; display: block; }
|
||||||
|
.msg.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||||
|
.success-card { display: none; text-align: center; padding: 2rem 0; }
|
||||||
|
.success-card.visible { display: block; }
|
||||||
|
.success-card .icon { width: 4rem; height: 4rem; margin: 0 auto 1rem; border-radius: 50%; background: #dcfce7; color: #166534; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; }
|
||||||
|
.success-card h2 { font-size: 1.5rem; font-weight: 700; color: #166534; margin: 0 0 0.5rem 0; }
|
||||||
|
.success-card p { color: #15803d; margin: 0; }
|
||||||
|
.pairing-form { display: block; }
|
||||||
|
.pairing-form.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div id="successCard" class="success-card" role="status" aria-live="polite">
|
||||||
|
<div class="icon">✓</div>
|
||||||
|
<h2>Verbunden!</h2>
|
||||||
|
<p>Das Gerät wurde erfolgreich mit dem Standort verbunden.</p>
|
||||||
|
<p style="margin-top: 0.5rem; font-size: 0.875rem; color: #64748b;">Sie können dieses Fenster schließen.</p>
|
||||||
|
</div>
|
||||||
|
<div id="pairingForm" class="pairing-form">
|
||||||
|
<h1>Bridge mit OrderPy verbinden</h1>
|
||||||
|
<p>Geben Sie den 6-stelligen Code ein, der in der OrderPy-App angezeigt wird.</p>
|
||||||
|
<form id="pairForm">
|
||||||
|
<div class="digits" role="group" aria-label="6-stelliger Code">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 1" data-idx="0">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 2" data-idx="1">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 3" data-idx="2">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 4" data-idx="3">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 5" data-idx="4">
|
||||||
|
<input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" aria-label="Ziffer 6" data-idx="5">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" id="submitBtn">Verbinden</button>
|
||||||
|
</form>
|
||||||
|
<div id="msg" class="msg" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const inputs = document.querySelectorAll('.digits input');
|
||||||
|
const form = document.getElementById('pairForm');
|
||||||
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
|
const msgEl = document.getElementById('msg');
|
||||||
|
const successCard = document.getElementById('successCard');
|
||||||
|
const pairingForm = document.getElementById('pairingForm');
|
||||||
|
function getCode() { return Array.from(inputs).map(i => i.value).join('').replace(/\\D/g, '').slice(0, 6); }
|
||||||
|
function setMsg(text, isError) { msgEl.textContent = text; msgEl.className = 'msg ' + (isError ? 'error' : 'success'); }
|
||||||
|
function showSuccess() { successCard.classList.add('visible'); pairingForm.classList.add('hidden'); }
|
||||||
|
inputs.forEach((inp, i) => {
|
||||||
|
inp.addEventListener('input', () => { if (inp.value.length >= 1) inputs[i + 1]?.focus(); });
|
||||||
|
inp.addEventListener('keydown', (e) => { if (e.key === 'Backspace' && !inp.value && i > 0) inputs[i - 1].focus(); });
|
||||||
|
inp.addEventListener('paste', (e) => { e.preventDefault(); const p = (e.clipboardData?.getData('text') || '').replace(/\\D/g, '').slice(0, 6); p.split('').forEach((c, j) => { if (inputs[j]) inputs[j].value = c; }); inputs[Math.min(p.length, 5)].focus(); });
|
||||||
|
});
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const code = getCode();
|
||||||
|
if (code.length !== 6) { setMsg('Bitte 6 Ziffern eingeben.', true); return; }
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
setMsg('Verbindung wird hergestellt…', false);
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) { form.reset(); showSuccess(); }
|
||||||
|
else {
|
||||||
|
var msg = data.reason === 'code_expired_or_used' ? 'Code abgelaufen oder bereits verwendet.' : data.reason === 'already_connected' ? 'Die Bridge ist bereits mit einem Standort verbunden.' : (data.reason === 'connection_lost' || data.reason === 'send_failed') ? 'Verbindung zum Server unterbrochen. Bitte in wenigen Sekunden erneut versuchen.' : (data.reason || 'Fehler.');
|
||||||
|
setMsg(msg, true);
|
||||||
|
}
|
||||||
|
} catch (err) { setMsg('Netzwerkfehler. Bitte erneut versuchen.', true); }
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PairRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
def create_pairing_app(
|
||||||
|
pairing_queue: asyncio.Queue,
|
||||||
|
get_connection_info: Callable[[], dict[str, Any]] | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
"""Create FastAPI app for pairing UI. pairing_queue sends code to cloud client.
|
||||||
|
If get_connection_info is set and returns claimed=True, index shows connection info (online + tenant name) instead of the code form."""
|
||||||
|
app = FastAPI(title="OrderPy Bridge Pairing", version="1.0")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def host_header_check(request: Request, call_next):
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
if not HOST_IP_PATTERN.match(host):
|
||||||
|
return Response("Forbidden: Direct IP access only", status_code=403)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request) -> Response:
|
||||||
|
# Always reflect current state; prevent cached pairing form when already connected
|
||||||
|
no_cache = {"Cache-Control": "no-store, no-cache, must-revalidate"}
|
||||||
|
if get_connection_info:
|
||||||
|
info = get_connection_info()
|
||||||
|
if info.get("claimed"):
|
||||||
|
html_content = _connected_html(
|
||||||
|
info.get("tenant_name") or "",
|
||||||
|
info.get("is_online", False),
|
||||||
|
)
|
||||||
|
return HTMLResponse(html_content, headers=no_cache)
|
||||||
|
return HTMLResponse(PAIRING_HTML, headers=no_cache)
|
||||||
|
|
||||||
|
@app.post("/api/pair")
|
||||||
|
async def pair(body: PairRequest) -> Response:
|
||||||
|
if get_connection_info:
|
||||||
|
info = get_connection_info()
|
||||||
|
if info.get("claimed"):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={"ok": False, "reason": "already_connected"},
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
code = "".join(c for c in (body.code or "").strip() if c.isdigit())[:6]
|
||||||
|
if len(code) != 6 or not code.isdigit():
|
||||||
|
return JSONResponse({"ok": False, "reason": "invalid_code"})
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
future: asyncio.Future = loop.create_future()
|
||||||
|
try:
|
||||||
|
pairing_queue.put_nowait((code, future))
|
||||||
|
result = await asyncio.wait_for(future, timeout=15.0)
|
||||||
|
return JSONResponse({"ok": result.get("ok", False), "reason": result.get("reason")})
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return JSONResponse({"ok": False, "reason": "timeout"})
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
return JSONResponse({"ok": False, "reason": "busy"})
|
||||||
|
|
||||||
|
return app
|
||||||
44
bridge_core/ssl_util.py
Normal file
44
bridge_core/ssl_util.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Generate self-signed certificate for pairing UI (HTTPS on 8088)."""
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
|
||||||
|
def generate_self_signed_cert(
|
||||||
|
cn: str = "orderpy-bridge",
|
||||||
|
valid_days: int = 365,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Write key and cert to temp files. Returns (key_path, cert_path)."""
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
subject = issuer = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, cn),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "OrderPy Bridge"),
|
||||||
|
])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(issuer)
|
||||||
|
.public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now)
|
||||||
|
.not_valid_after(now + timedelta(days=valid_days))
|
||||||
|
.sign(key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="orderpy_bridge_ssl_"))
|
||||||
|
key_path = tmp / "key.pem"
|
||||||
|
cert_path = tmp / "cert.pem"
|
||||||
|
key_path.write_bytes(
|
||||||
|
key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
return key_path, cert_path
|
||||||
133
bridge_core/state.py
Normal file
133
bridge_core/state.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""In-memory state: OTS, bridge_id from Cloud, Fail2Ban."""
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
# Current one-time secret (32 bytes as hex = 64 chars). Only in RAM.
|
||||||
|
_claim_token: str = ""
|
||||||
|
_claimed: bool = False # True after successful claim; do not issue new token
|
||||||
|
_lock = Lock()
|
||||||
|
# Bridge ID we got from Cloud after WebSocket register
|
||||||
|
_bridge_id: str = ""
|
||||||
|
# Tenant name from Cloud after claim (config message), for pairing UI display
|
||||||
|
_tenant_name: str = ""
|
||||||
|
# WebSocket connected to Cloud (for pairing UI online/offline badge)
|
||||||
|
_ws_connected: bool = False
|
||||||
|
# Fail2Ban: after 5 failed verify_claim responses, lock until lock_until
|
||||||
|
_fail_count = 0
|
||||||
|
_lock_until: float = 0
|
||||||
|
FAIL_THRESHOLD = 5
|
||||||
|
LOCK_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_ots() -> str:
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
|
def get_claim_token() -> str:
|
||||||
|
with _lock:
|
||||||
|
global _claim_token, _claimed
|
||||||
|
if _claimed:
|
||||||
|
return ""
|
||||||
|
if not _claim_token:
|
||||||
|
_claim_token = _generate_ots()
|
||||||
|
return _claim_token
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_claim_token() -> str:
|
||||||
|
with _lock:
|
||||||
|
global _claim_token, _claimed
|
||||||
|
if _claimed:
|
||||||
|
return ""
|
||||||
|
_claim_token = _generate_ots()
|
||||||
|
return _claim_token
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_claim_token() -> None:
|
||||||
|
"""After successful claim, clear OTS and mark as claimed."""
|
||||||
|
with _lock:
|
||||||
|
global _claim_token, _claimed
|
||||||
|
_claim_token = ""
|
||||||
|
_claimed = True
|
||||||
|
|
||||||
|
|
||||||
|
def unpair() -> str:
|
||||||
|
"""Called when Cloud sends unpaired. Reset claimed state and return new OTS."""
|
||||||
|
with _lock:
|
||||||
|
global _claim_token, _claimed, _tenant_name
|
||||||
|
_claimed = False
|
||||||
|
_tenant_name = ""
|
||||||
|
_claim_token = _generate_ots()
|
||||||
|
# _ws_connected is set by cloud_client when connection closes
|
||||||
|
return _claim_token
|
||||||
|
|
||||||
|
|
||||||
|
def set_bridge_id(bridge_id: str) -> None:
|
||||||
|
global _bridge_id
|
||||||
|
with _lock:
|
||||||
|
_bridge_id = bridge_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_bridge_id() -> str:
|
||||||
|
with _lock:
|
||||||
|
return _bridge_id
|
||||||
|
|
||||||
|
|
||||||
|
def is_claimed() -> bool:
|
||||||
|
with _lock:
|
||||||
|
return _claimed
|
||||||
|
|
||||||
|
|
||||||
|
def set_tenant_name(name: str) -> None:
|
||||||
|
global _tenant_name
|
||||||
|
with _lock:
|
||||||
|
_tenant_name = name or ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_name() -> str:
|
||||||
|
with _lock:
|
||||||
|
return _tenant_name
|
||||||
|
|
||||||
|
|
||||||
|
def set_ws_connected(connected: bool) -> None:
|
||||||
|
global _ws_connected
|
||||||
|
with _lock:
|
||||||
|
_ws_connected = connected
|
||||||
|
|
||||||
|
|
||||||
|
def get_ws_connected() -> bool:
|
||||||
|
with _lock:
|
||||||
|
return _ws_connected
|
||||||
|
|
||||||
|
|
||||||
|
def check_token(claimed: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
return bool(_claim_token and secrets.compare_digest(_claim_token, claimed))
|
||||||
|
|
||||||
|
|
||||||
|
def record_verify_failure() -> None:
|
||||||
|
"""Call when we responded ok: false to verify_claim. Fail2Ban logic."""
|
||||||
|
global _fail_count, _lock_until
|
||||||
|
with _lock:
|
||||||
|
_fail_count += 1
|
||||||
|
if _fail_count >= FAIL_THRESHOLD:
|
||||||
|
_lock_until = time.monotonic() + LOCK_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked() -> bool:
|
||||||
|
with _lock:
|
||||||
|
global _fail_count
|
||||||
|
if _lock_until > 0 and time.monotonic() < _lock_until:
|
||||||
|
return True
|
||||||
|
if time.monotonic() >= _lock_until:
|
||||||
|
_fail_count = 0
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reset_lock_after_timeout() -> None:
|
||||||
|
"""Reset fail count after lock period expires."""
|
||||||
|
with _lock:
|
||||||
|
global _fail_count
|
||||||
|
if _lock_until > 0 and time.monotonic() >= _lock_until:
|
||||||
|
_fail_count = 0
|
||||||
21
container-bridge/Dockerfile
Normal file
21
container-bridge/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Build context must be orderpy-bridge (parent), e.g. docker-compose with context: . and dockerfile: container-bridge/Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends gosu \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd --create-home --uid 1000 app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY bridge_core /app/bridge_core
|
||||||
|
COPY container-bridge/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY container-bridge/main.py /app/main.py
|
||||||
|
COPY container-bridge/docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
RUN mkdir -p /app/data && chown -R app:app /app/data
|
||||||
|
|
||||||
|
ENV ORDERPY_KEY_PATH=/app/data/bridge_key.pem
|
||||||
|
EXPOSE 8080 8088
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
7
container-bridge/docker-entrypoint.sh
Normal file
7
container-bridge/docker-entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Ensure data dir exists and is writable by app user (fixes bind-mount permissions)
|
||||||
|
mkdir -p /app/data
|
||||||
|
chown -R app:app /app/data 2>/dev/null || true
|
||||||
|
chmod 777 /app/data
|
||||||
|
exec gosu app "$@"
|
||||||
125
container-bridge/main.py
Normal file
125
container-bridge/main.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Bridge local HTTP API and Cloud WebSocket client."""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from uvicorn import Config, Server
|
||||||
|
|
||||||
|
from bridge_core.app_config import get_allowed_origins, get_cloud_url
|
||||||
|
from bridge_core.cloud_client import run_cloud_client
|
||||||
|
from bridge_core.pairing_ui import create_pairing_app
|
||||||
|
from bridge_core.ssl_util import generate_self_signed_cert
|
||||||
|
from bridge_core.state import get_bridge_id, get_claim_token, get_tenant_name, get_ws_connected, is_claimed, is_locked, rotate_claim_token
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# IPv4 with optional :port
|
||||||
|
HOST_IP_PATTERN = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?::\d+)?$")
|
||||||
|
|
||||||
|
PAIRING_PORT = 8088
|
||||||
|
|
||||||
|
allowed_origins = get_allowed_origins()
|
||||||
|
|
||||||
|
|
||||||
|
async def _rotate_ots_periodically() -> None:
|
||||||
|
"""Rotate OTS every 15 minutes while still unclaimed."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(900) # 15 min
|
||||||
|
if get_claim_token():
|
||||||
|
rotate_claim_token()
|
||||||
|
logger.debug("OTS rotated")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
cloud_url = get_cloud_url()
|
||||||
|
pairing_queue = asyncio.Queue()
|
||||||
|
ws_task = asyncio.create_task(run_cloud_client(cloud_url, pairing_queue))
|
||||||
|
rot_task = asyncio.create_task(_rotate_ots_periodically())
|
||||||
|
pairing_task = None
|
||||||
|
try:
|
||||||
|
key_path, cert_path = generate_self_signed_cert()
|
||||||
|
def get_connection_info():
|
||||||
|
return {"claimed": is_claimed(), "tenant_name": get_tenant_name(), "is_online": get_ws_connected()}
|
||||||
|
pairing_app = create_pairing_app(pairing_queue, get_connection_info)
|
||||||
|
pairing_config = Config(
|
||||||
|
pairing_app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=PAIRING_PORT,
|
||||||
|
ssl_keyfile=str(key_path),
|
||||||
|
ssl_certfile=str(cert_path),
|
||||||
|
)
|
||||||
|
pairing_server = Server(pairing_config)
|
||||||
|
pairing_task = asyncio.create_task(pairing_server.serve())
|
||||||
|
logger.info("Pairing UI (HTTPS) listening on port %s", PAIRING_PORT)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not start pairing UI on %s: %s", PAIRING_PORT, e)
|
||||||
|
yield
|
||||||
|
ws_task.cancel()
|
||||||
|
rot_task.cancel()
|
||||||
|
if pairing_task is not None:
|
||||||
|
pairing_task.cancel()
|
||||||
|
try:
|
||||||
|
await ws_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await rot_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if pairing_task is not None:
|
||||||
|
try:
|
||||||
|
await pairing_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="OrderPy Bridge", version="1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origin_regex=None,
|
||||||
|
allow_origins=allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def host_header_check(request: Request, call_next):
|
||||||
|
"""DNS Rebinding: only allow direct IP access."""
|
||||||
|
host = request.headers.get("host", "")
|
||||||
|
if not HOST_IP_PATTERN.match(host):
|
||||||
|
return Response("Forbidden: Direct IP access only", status_code=403)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/setup/info")
|
||||||
|
async def setup_info(request: Request):
|
||||||
|
"""Return bridgeId and claimToken for discovery. Fail2Ban: 503 when locked."""
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if origin and origin not in allowed_origins:
|
||||||
|
return Response("Forbidden", status_code=403)
|
||||||
|
if is_locked():
|
||||||
|
return Response("Service temporarily unavailable", status_code=503)
|
||||||
|
bridge_id = get_bridge_id()
|
||||||
|
if not bridge_id:
|
||||||
|
# Bridge has not yet received bridgeId from Cloud (WebSocket not connected or still handshaking)
|
||||||
|
return Response(
|
||||||
|
"Bridge not yet registered with Cloud; wait a few seconds and retry",
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "3"},
|
||||||
|
)
|
||||||
|
# Discovery: bridgeId only. claimToken kept for backward compatibility during transition.
|
||||||
|
token = get_claim_token()
|
||||||
|
return {"bridgeId": bridge_id, "claimToken": token, "pairing_ui": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
5
container-bridge/requirements.txt
Normal file
5
container-bridge/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.109
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
|
websockets>=12.0
|
||||||
|
cryptography>=41.0
|
||||||
|
httpx>=0.26
|
||||||
71
daemon/README.md
Normal file
71
daemon/README.md
Normal 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
124
daemon/main.py
Normal 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"}
|
||||||
8
daemon/orderpy-bridge.env.example
Normal file
8
daemon/orderpy-bridge.env.example
Normal 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
191
daemon/packaging/install.sh
Executable 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
5
daemon/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.109
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
|
websockets>=12.0
|
||||||
|
cryptography>=41.0
|
||||||
|
httpx>=0.26
|
||||||
21
daemon/systemd/orderpy-bridge.service
Normal file
21
daemon/systemd/orderpy-bridge.service
Normal 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
27
data/bridge_key.pem
Normal 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
9
data/bridge_key.pub
Normal 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
5
debian/changelog
vendored
Normal 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
1
debian/compat
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
13
|
||||||
14
debian/control
vendored
Normal file
14
debian/control
vendored
Normal 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
34
debian/postinst
vendored
Normal 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
12
debian/rules
vendored
Executable 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
21
docker-compose.yml
Normal 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
|
||||||
Reference in New Issue
Block a user