all
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user