Files
xenia-pos-local/local_backend/routers/system.py
T
bonaminandClaude Opus 5.5 8924a16747 feat(backend): self-managed TLS identity for the native app's encrypted LAN link
services/tls_identity.py creates an EC P-256 key + self-signed cert (10y,
SAN localhost/127.0.0.1/HOST_IP) under <data dir>/tls at startup, and
re-issues the cert with the SAME key when < 2 years remain. A cert that
doesn't belong to the key is replaced. Nothing to renew by hand; a backup of
the data directory keeps the identity. Runs in lifespan before the app is
healthy, so the proxy (which waits for healthy) always finds the files.

/api/system/identity and /api/system/status now include
tls: {port: TLS_PORT (default 8443), spki_sha256} - the base64 SHA-256 of the
public key that phones pin. Adds cryptography==46.0.4.

Tests: create / restart (no change) / renewal 8 years later keeps the key
and pin / foreign cert replaced; pin equals openssl's SPKI sha256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:41:24 +03:00

355 lines
13 KiB
Python

import asyncio
import ipaddress
import json
import socket
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.printer import Printer
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin
from models.user import User
from models.settings import PosSettings
from models.product import Category, Product
from models.table import Table, TableGroup
from services import printer_service
from services.cloud_sync import _sync_once, _push_menu_snapshot
from services.lan_ip import OVERRIDE_KEY, resolve_lan_ip, validate_lan_ip
from services.tls_identity import tls_info
from middleware.license_check import license_state
from config import settings
router = APIRouter()
_start_time = time.time()
@router.get("/health")
def health():
return {"status": "ok", "version": settings.VERSION}
# Bump when the waiter app <-> backend contract changes incompatibly, so native
# apps (which ship their own UI) can tell they're talking to a server they don't support.
API_VERSION = 1
@router.get("/identity")
def identity(db: Session = Depends(get_db)):
"""Public, unauthenticated. Lets a phone confirm which venue a server is
(pairing, and rediscovery after the server's IP changes). No secrets here:
site_id is an identifier — the secret is SITE_KEY, which never leaves the server."""
venue = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
return {
"app": "xenia-pos",
"site_id": settings.SITE_ID or None,
"venue_name": (venue.value if venue and venue.value else None),
"version": settings.VERSION,
"api_version": API_VERSION,
# Encrypted LAN endpoint for the native app: https://<ip>:<port>, pin spki_sha256
"tls": tls_info(),
}
@router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from datetime import datetime, timezone
printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_statuses = []
for p in printers:
reachable = printer_service.check_printer(p.ip_address, p.port)
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
lan = resolve_lan_ip(db)
licensed = license_state.get("licensed", True)
locked = license_state.get("locked", False)
lock_pending = license_state.get("lock_pending", False)
expires_at = license_state.get("expires_at")
days_until_expiry = license_state.get("days_until_expiry")
grace_expires_at = license_state.get("grace_expires_at")
# Determine lock_reason for the frontend banner logic
# "admin" — locked by sysadmin (immediately or deferred)
# "expired" — license grace period over, site is blocked
# None — all good
lock_reason = None
if locked or lock_pending:
lock_reason = "admin"
elif not licensed:
lock_reason = "expired"
# Grace days remaining (only meaningful while in expiry grace period)
grace_days_remaining = None
if grace_expires_at:
try:
grace_dt = datetime.fromisoformat(grace_expires_at)
if grace_dt.tzinfo is None:
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
except ValueError:
pass
return {
"uptime_seconds": int(time.time() - _start_time),
"version": settings.VERSION,
"latest_version": license_state.get("latest_version"),
"licensed": licensed,
"locked": locked,
"lock_pending": lock_pending,
"lock_reason": lock_reason,
"expires_at": expires_at,
"days_until_expiry": days_until_expiry,
"grace_expires_at": grace_expires_at,
"grace_days_remaining": grace_days_remaining,
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
"waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None,
"lan_ip": lan.get("effective"),
"lan_ip_info": lan,
"tls": tls_info(),
"printers": printer_statuses,
}
class LanIpOverride(BaseModel):
ip: str | None = None # null / empty → clear the override (back to automatic)
@router.put("/lan-ip-override")
def set_lan_ip_override(body: LanIpOverride, db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Pin the server's LAN IP (what phones and the pairing QR use). Clearing it
returns to automatic detection / HOST_IP."""
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
if body.ip and body.ip.strip():
try:
value = validate_lan_ip(body.ip)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if row:
row.value = value
row.updated_by_id = user.id or None
else:
db.add(PosSettings(key=OVERRIDE_KEY, value=value, updated_by_id=user.id or None))
elif row:
db.delete(row)
db.commit()
return resolve_lan_ip(db)
@router.post("/sync-license")
async def sync_license_now(user: User = Depends(require_manager)):
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
await _sync_once()
return {
"licensed": license_state.get("licensed", True),
"locked": license_state.get("locked", False),
"lock_pending": license_state.get("lock_pending", False),
"lock_reason": (
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
else "expired" if not license_state.get("licensed", True)
else None
),
"expires_at": license_state.get("expires_at"),
"days_until_expiry": license_state.get("days_until_expiry"),
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
}
@router.post("/sync-menu")
async def sync_menu_now(user: User = Depends(require_manager)):
"""Immediately push the digital menu snapshot (and any changed product images) to the cloud."""
await _push_menu_snapshot()
return {"ok": True}
@router.get("/printers", response_model=List[PrinterOut])
def list_printers(db: Session = Depends(get_db), user: User = Depends(require_manager)):
return db.query(Printer).all()
@router.post("/printers", response_model=PrinterOut)
def create_printer(body: PrinterCreate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = Printer(**body.model_dump())
db.add(printer)
db.commit()
db.refresh(printer)
return printer
@router.post("/printers/test")
def test_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_print(printer.ip_address, printer.port, printer.name, printer.codepage_n)
return {"success": success, "error": error}
@router.post("/printers/test-order")
def test_order_print(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_order_print(printer.ip_address, printer.port, db, printer.line_width, printer.codepage_n)
return {"success": success, "error": error}
@router.post("/printers/test-beep")
def test_beep(printer_id: int, n1: int = 2, n2: int = 2, n3: int = 1, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
success, error = printer_service.send_test_beep(printer.ip_address, printer.port, n1, n2, n3, printer.codepage_n)
return {"success": success, "error": error}
@router.put("/printers/{printer_id}", response_model=PrinterOut)
def update_printer(printer_id: int, body: PrinterUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(printer, field, value)
db.commit()
db.refresh(printer)
return printer
@router.delete("/printers/{printer_id}")
def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = Depends(require_manager)):
printer = db.query(Printer).filter(Printer.id == printer_id).first()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
db.delete(printer)
db.commit()
return {"ok": True}
def _detect_local_subnets() -> list[str]:
"""Return plausible /24 subnets based on local interface IPs, excluding loopback/docker."""
subnets = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, None):
ip_str = info[4][0]
try:
addr = ipaddress.IPv4Address(ip_str)
if addr.is_loopback or addr.is_link_local:
continue
# Exclude docker bridge ranges (172.16-31.x.x)
if addr.packed[0] == 172 and 16 <= addr.packed[1] <= 31:
continue
net = str(ipaddress.IPv4Network(f"{ip_str}/24", strict=False))
if net not in subnets:
subnets.append(net)
except (ValueError, OSError):
continue
except OSError:
pass
return subnets
async def _check_port(ip: str, port: int, timeout: float) -> bool:
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port), timeout=timeout
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
return True
except Exception:
return False
async def _scan_subnet_sse(subnet: str, port: int, token: str):
"""Async generator: yields SSE lines as printers are found."""
try:
network = ipaddress.IPv4Network(subnet, strict=False)
except ValueError as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
hosts = list(network.hosts())
semaphore = asyncio.Semaphore(50) # max 50 concurrent connects
async def check(ip_str: str):
async with semaphore:
return ip_str, await _check_port(ip_str, port, timeout=0.4)
tasks = [asyncio.create_task(check(str(h))) for h in hosts]
yield f"data: {json.dumps({'type': 'start', 'total': len(tasks), 'subnet': subnet, 'port': port})}\n\n"
done_count = 0
for coro in asyncio.as_completed(tasks):
ip_str, reachable = await coro
done_count += 1
if reachable:
yield f"data: {json.dumps({'type': 'found', 'ip': ip_str, 'port': port})}\n\n"
if done_count % 20 == 0 or done_count == len(tasks):
yield f"data: {json.dumps({'type': 'progress', 'done': done_count, 'total': len(tasks)})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
@router.get("/printers/scan")
async def scan_printers(
subnet: str = Query(default=""),
port: int = Query(default=9100),
user: User = Depends(require_manager),
):
"""SSE endpoint: scan a subnet for devices responding on the given port."""
# Fall back to auto-detected subnet if none provided
if not subnet:
detected = _detect_local_subnets()
subnet = detected[0] if detected else "192.168.1.0/24"
return StreamingResponse(
_scan_subnet_sse(subnet, port, ""),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/printers/scan-hints")
def scan_hints(user: User = Depends(require_manager)):
"""Return auto-detected local subnets to suggest in the scan UI."""
return {"subnets": _detect_local_subnets()}
@router.get("/stats")
def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
return {
"categories": db.query(Category).count(),
"products": db.query(Product).filter(Product.lifecycle_status == "active").count(),
"tables": db.query(Table).filter(Table.is_active == True).count(),
"table_groups": db.query(TableGroup).count(),
"managers": db.query(User).filter(User.perm_access_dashboard == True, User.is_active == True).count(),
"waiters": db.query(User).filter(User.perm_access_waiter_app == True, User.is_active == True).count(),
}
@router.post("/lock")
def lock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = True
return {"status": "locked"}
@router.post("/unlock")
def unlock_system(token: str, user: User = Depends(require_sysadmin)):
license_state["locked"] = False
return {"status": "unlocked"}