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

71
daemon/README.md Normal file
View File

@@ -0,0 +1,71 @@
# OrderPy Bridge (Linux Daemon)
Systemd-Daemon-Variante der OrderPy Bridge: gleiche Funktionalität wie die Container-Variante, läuft nativ auf dem Host (ARM64 und AMD64).
## Voraussetzungen
- Linux mit systemd
- Python 3.11+
- Netzwerkzugriff zur OrderPy-Cloud und zu den Druckern (TCP)
## Installation
### Mit Install-Script (aus dem Repo)
```bash
cd /path/to/orderpy-bridge/daemon/packaging
sudo ./install.sh
```
Das Script legt User `orderpy-bridge`, Verzeichnisse (`/etc/orderpy-bridge`, `/var/lib/orderpy-bridge`, `/opt/orderpy-bridge`), eine Python-venv und die systemd-Unit an.
### Mit .deb-Paket
Aus dem Repo-Baustein bauen (im Verzeichnis `orderpy-bridge`):
```bash
dpkg-buildpackage -us -uc -b
```
Installation der erzeugten `.deb`-Datei:
```bash
sudo dpkg -i ../orderpy-bridge_1.0.0_all.deb
```
Architektur ist `all` (reines Python), gleiches Paket für ARM64 und AMD64.
## Konfiguration
Umgebungsvariablen werden über eine Env-Datei gesetzt:
- **Konfiguration:** `/etc/orderpy-bridge/orderpy-bridge.env`
- **Persistente Daten (Schlüssel):** `/var/lib/orderpy-bridge/`
Mindestens setzen:
- `ORDERPY_CLOUD_URL` z.B. `https://api.orderpy.com`
- `ORDERPY_KEY_PATH` z.B. `/var/lib/orderpy-bridge/bridge_key.pem`
- `ORDERPY_ALLOWED_ORIGINS` z.B. `https://admin.orderpy.com`
Vor dem ersten Start die Datei anpassen:
```bash
sudo nano /etc/orderpy-bridge/orderpy-bridge.env
```
## Start und Status
```bash
sudo systemctl enable --now orderpy-bridge
sudo systemctl status orderpy-bridge
sudo journalctl -u orderpy-bridge -f
```
Die Bridge hört auf Port **8080** (HTTP für `/setup/info` und `/health`).
## Rechte
- Läuft unter User/Group `orderpy-bridge` (kein Root zur Laufzeit).
- Lesezugriff nur auf `/etc/orderpy-bridge/`, Schreibzugriff nur auf `/var/lib/orderpy-bridge/`.
- systemd-Optionen: `ProtectSystem=strict`, `ReadWritePaths=/var/lib/orderpy-bridge`, `PrivateTmp=yes`, `NoNewPrivileges=yes`.

124
daemon/main.py Normal file
View File

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

View File

@@ -0,0 +1,8 @@
# Copy to /etc/orderpy-bridge/orderpy-bridge.env and adjust.
# Required:
ORDERPY_CLOUD_URL=https://api.orderpy.com
ORDERPY_KEY_PATH=/var/lib/orderpy-bridge/bridge_key.pem
ORDERPY_ALLOWED_ORIGINS=https://admin.orderpy.com
# Optional (defaults shown):
# ORDERPY_PRINTER_HEALTH_INTERVAL=10

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

@@ -0,0 +1,191 @@
#!/bin/bash
# Install OrderPy Bridge daemon (systemd). Run with sudo.
set -e
INSTALL_PREFIX="${INSTALL_PREFIX:-/opt/orderpy-bridge}"
CONFIG_DIR="/etc/orderpy-bridge"
DATA_DIR="/var/lib/orderpy-bridge"
SERVICE_USER="orderpy-bridge"
SERVICE_GROUP="orderpy-bridge"
# Optional colors and formatting (disable if not a tty or TERM is dumb)
if [[ -t 1 ]] && [[ "${TERM:-dumb}" != "dumb" ]]; then
RED="$(tput setaf 1 2>/dev/null || true)"
GREEN="$(tput setaf 2 2>/dev/null || true)"
YELLOW="$(tput setaf 3 2>/dev/null || true)"
BOLD="$(tput bold 2>/dev/null || true)"
SGR0="$(tput sgr0 2>/dev/null || true)"
else
RED="" GREEN="" YELLOW="" BOLD="" SGR0=""
fi
# Preflight: print check label (no newline), then call ok_line or fail_line with result (overwrites line with \r)
preflight_check() { printf " %-28s " "$1"; }
ok_line() { printf "\r %-28s ${GREEN}[ OK ]${SGR0} %s\n" "$PREFLIGHT_LABEL" "$1"; }
fail_line() { printf "\r %-28s ${RED}[ FAIL ]${SGR0} %s\n" "$PREFLIGHT_LABEL" "$1" >&2; return 1; }
# Resolve orderpy-bridge repo root (directory containing bridge_core and daemon/)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRIDGE_ROOT="$SCRIPT_DIR"
while [[ -n "$BRIDGE_ROOT" ]]; do
if [[ -d "$BRIDGE_ROOT/bridge_core" ]] && [[ -f "$BRIDGE_ROOT/daemon/main.py" ]]; then
break
fi
BRIDGE_ROOT="${BRIDGE_ROOT%/*}"
[[ "$BRIDGE_ROOT" == "${BRIDGE_ROOT%/*}" ]] && BRIDGE_ROOT=""
done
if [[ -z "$BRIDGE_ROOT" ]] || [[ ! -d "$BRIDGE_ROOT/bridge_core" ]]; then
echo "${RED}Error:${SGR0} Run from orderpy-bridge repo (bridge_core and daemon/ must exist)." >&2
exit 1
fi
if [[ "$(id -u)" -ne 0 ]]; then
echo "${RED}Error:${SGR0} This script must be run with sudo." >&2
exit 1
fi
# --- Preflight checks ---
echo ""
echo "${BOLD}Preflight checks${SGR0}"
PREFLIGHT_FAIL=0
# Python 3.11+
PREFLIGHT_LABEL="Python 3.11+"
preflight_check "$PREFLIGHT_LABEL"
PYVER=""
if command -v python3 >/dev/null 2>&1; then
PYVER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)" || true
fi
if [[ -z "$PYVER" ]]; then
fail_line "python3 not found. Install Python 3.11+ (e.g. apt install python3 python3-venv)." || PREFLIGHT_FAIL=1
else
MAJOR="${PYVER%%.*}"; MINOR="${PYVER#*.}"; MINOR="${MINOR%%.*}"
if [[ "$MAJOR" -lt 3 ]] || { [[ "$MAJOR" -eq 3 ]] && [[ "$MINOR" -lt 11 ]]; }; then
fail_line "Python $PYVER found; 3.11+ required. Install a newer python3." || PREFLIGHT_FAIL=1
else
ok_line "Python $PYVER"
fi
fi
# python3-venv
PREFLIGHT_LABEL="python3-venv"
preflight_check "$PREFLIGHT_LABEL"
if python3 -m venv --help >/dev/null 2>&1; then
ok_line "available"
else
fail_line "not available. Install it (e.g. apt install python3-venv)." || PREFLIGHT_FAIL=1
fi
# systemd
PREFLIGHT_LABEL="systemd"
preflight_check "$PREFLIGHT_LABEL"
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
ok_line "available"
else
fail_line "not found or not running. This installer is for systemd-based systems." || PREFLIGHT_FAIL=1
fi
# Write access to target dirs
PREFLIGHT_LABEL="Write access (prefix)"
preflight_check "$PREFLIGHT_LABEL"
PARENT="$(dirname "$INSTALL_PREFIX")"
if [[ -w "$PARENT" ]] || [[ ! -e "$PARENT" ]]; then
ok_line "Can install to $INSTALL_PREFIX"
else
fail_line "No write access to $(dirname "$INSTALL_PREFIX"). Set INSTALL_PREFIX or fix permissions." || PREFLIGHT_FAIL=1
fi
PREFLIGHT_LABEL="Config directory"
preflight_check "$PREFLIGHT_LABEL"
if [[ -w /etc ]] || [[ -d "$CONFIG_DIR" && -w "$CONFIG_DIR" ]]; then
ok_line "/etc/orderpy-bridge can be created"
else
fail_line "Cannot create $CONFIG_DIR. Run with sudo." || PREFLIGHT_FAIL=1
fi
echo ""
if [[ "$PREFLIGHT_FAIL" -ne 0 ]]; then
echo "${RED}${BOLD}Preflight failed.${SGR0} Fix the issues above and run the script again." >&2
exit 1
fi
echo "${GREEN}All preflight checks passed.${SGR0}"
echo ""
# Installation runs in background; we show a progress bar until it finishes.
ENV_FILE="$CONFIG_DIR/orderpy-bridge.env"
INSTALL_LOG="$(mktemp)"
INSTALL_EXIT_FILE="$(mktemp)"
trap 'rm -f "$INSTALL_LOG" "$INSTALL_EXIT_FILE"' EXIT
do_install() {
set +e
# User/group
if ! getent group "$SERVICE_GROUP" >/dev/null; then
groupadd --system "$SERVICE_GROUP"
fi
if ! getent passwd "$SERVICE_USER" >/dev/null; then
useradd --system --no-create-home --gid "$SERVICE_GROUP" "$SERVICE_USER"
fi
# Directories
install -d -m 755 "$CONFIG_DIR"
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR"
install -d -m 755 "$(dirname "$INSTALL_PREFIX")"
install -d -m 755 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$INSTALL_PREFIX"
# Config: env file only if missing
if [[ ! -f "$ENV_FILE" ]]; then
install -m 640 -o root -g "$SERVICE_GROUP" "$BRIDGE_ROOT/daemon/orderpy-bridge.env.example" "$ENV_FILE"
fi
# Application files
cp -r "$BRIDGE_ROOT/bridge_core" "$INSTALL_PREFIX/"
install -m 644 "$BRIDGE_ROOT/daemon/main.py" "$INSTALL_PREFIX/"
install -m 644 "$BRIDGE_ROOT/daemon/requirements.txt" "$INSTALL_PREFIX/"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_PREFIX"
# Python venv (pip quiet to keep log small)
if [[ ! -x "$INSTALL_PREFIX/venv/bin/uvicorn" ]]; then
python3 -m venv "$INSTALL_PREFIX/venv"
"$INSTALL_PREFIX/venv/bin/pip" install --disable-pip-version-check -q -r "$INSTALL_PREFIX/requirements.txt"
chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_PREFIX/venv"
fi
# systemd unit
install -m 644 "$BRIDGE_ROOT/daemon/systemd/orderpy-bridge.service" /etc/systemd/system/
systemctl daemon-reload
exit 0
}
( do_install; echo $? > "$INSTALL_EXIT_FILE" ) >> "$INSTALL_LOG" 2>&1 &
INSTALL_PID=$!
# Progress bar (indeterminate: moving cursor)
BAR_W=24
bar_pos=0
printf "\n Installing to %s\n " "$INSTALL_PREFIX"
while kill -0 "$INSTALL_PID" 2>/dev/null; do
bar=""
for (( i=0; i<BAR_W; i++ )); do
[[ $i -eq $bar_pos ]] && bar="${bar}>" || bar="${bar} "
done
printf "\r [%s] Installing... " "$bar"
bar_pos=$(( (bar_pos + 1) % BAR_W ))
sleep 0.08
done
wait "$INSTALL_PID" 2>/dev/null || true
EXIT_CODE=0
[[ -f "$INSTALL_EXIT_FILE" ]] && read -r EXIT_CODE < "$INSTALL_EXIT_FILE" || true
if [[ "$EXIT_CODE" -ne 0 ]]; then
printf "\r [%*s] ${RED}Installation failed.${SGR0}\n" "$BAR_W" " "
echo ""
echo "Last lines of install log:"
tail -n 20 "$INSTALL_LOG" | sed 's/^/ /'
cp "$INSTALL_LOG" /tmp/orderpy-bridge-install.log 2>/dev/null || true
echo ""
echo "Full log: /tmp/orderpy-bridge-install.log"
exit "$EXIT_CODE"
fi
printf "\r [%s] ${GREEN}Done!${SGR0} \n\n" "$(printf '%*s' "$BAR_W" '' | tr ' ' '=')"
echo "If this is a fresh install, edit $ENV_FILE and set ORDERPY_CLOUD_URL, ORDERPY_ALLOWED_ORIGINS."
echo ""
echo "Next steps:"
echo " sudo systemctl enable --now orderpy-bridge"
echo " sudo systemctl status orderpy-bridge"

5
daemon/requirements.txt Normal file
View File

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

View File

@@ -0,0 +1,21 @@
[Unit]
Description=OrderPy Bridge daemon (WebSocket + local setup API)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=orderpy-bridge
Group=orderpy-bridge
EnvironmentFile=/etc/orderpy-bridge/orderpy-bridge.env
WorkingDirectory=/opt/orderpy-bridge
ExecStart=/opt/orderpy-bridge/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5
ProtectSystem=strict
ReadWritePaths=/var/lib/orderpy-bridge
PrivateTmp=yes
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target