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