diff --git a/local_backend/routers/business_day.py b/local_backend/routers/business_day.py index dfb1255..c5b6943 100644 --- a/local_backend/routers/business_day.py +++ b/local_backend/routers/business_day.py @@ -168,8 +168,10 @@ def close_business_day( if license_state.get("lock_pending"): license_state["lock_pending"] = False license_state["locked"] = True - from services.cloud_sync import _persist_state - _persist_state() + # Re-evaluate the license now that no workday is open (an expiry whose grace + # ended mid-service takes effect here) — also persists the state + from services.cloud_sync import apply_license + apply_license() return day diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index daf7e78..b3d8a2e 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -60,9 +60,23 @@ def identity(db: Session = Depends(get_db)): } +def _lock_reason() -> str | None: + """Why the site is (or will be) blocked, for the manager's banner: + "admin" (locked/lock pending) · "clock" (system clock set back) · + "unverified" (no genuine license yet) · "expired" (expiry grace over / + site deactivated) · None.""" + if license_state.get("locked") or license_state.get("lock_pending"): + return "admin" + problem = license_state.get("license_problem") + if problem in ("clock", "unverified"): + return problem + if problem in ("expired", "inactive") or not license_state.get("licensed", True): + return "expired" + return None + + @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: @@ -78,26 +92,10 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren 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" + lock_reason = _lock_reason() - # 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 + # Computed by services/license.py (only set during the expiry grace period) + grace_days_remaining = license_state.get("grace_days_remaining") return { "uptime_seconds": int(time.time() - _start_time), @@ -113,6 +111,12 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren "grace_days_remaining": grace_days_remaining, "sync_failed": license_state.get("sync_failed", False), "last_sync": license_state.get("last_sync"), + # Offline licensing (KI-006): days without cloud contact; the signed license + # keeps the site running until expiry regardless + "offline_days": license_state.get("offline_days"), + "license_verified": license_state.get("license_verified", False), + "license_problem": license_state.get("license_problem"), + "grace_over": license_state.get("grace_over", False), "waiter_domain": license_state.get("waiter_domain"), "site_id": settings.SITE_ID or None, "lan_ip": lan.get("effective"), @@ -155,11 +159,7 @@ async def sync_license_now(user: User = Depends(require_manager)): "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 - ), + "lock_reason": _lock_reason(), "expires_at": license_state.get("expires_at"), "days_until_expiry": license_state.get("days_until_expiry"), "sync_failed": license_state.get("sync_failed", False), diff --git a/local_backend/services/cloud_sync.py b/local_backend/services/cloud_sync.py index c44d6e2..1d3b2fc 100644 --- a/local_backend/services/cloud_sync.py +++ b/local_backend/services/cloud_sync.py @@ -1,28 +1,28 @@ """ Periodic cloud check-in. Runs every 5 minutes as an asyncio background task. -Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure. -Lock behaviour: - - cloud sets locked=true → set lock_pending=true in state - - lock_pending is enforced at workday-close time (see business_day router) - - while a workday is open, the site keeps running; lock applies once it closes +Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a +license, not to RUN one. Each heartbeat returns a signed license token; the +site stores it and enforces it offline — until expiry (+5 days grace), however +long it has no internet. apply_license() re-evaluates the stored token without +network: at startup, after every heartbeat attempt and when a workday closes. -License expiry behaviour: - - 5 days before expiry → warning only (days_until_expiry in state) - - on expiry → 5-day grace period begins (grace_expires_at in state) - - after grace + no open workday → licensed=False enforced by business_day router +Lock behaviour (unchanged): + - cloud sets locked=true → lock_pending while a workday is open, locked once it closes + - expiry past grace / site deactivated → likewise never mid-service """ import asyncio import json import logging import os -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path import httpx from config import settings from middleware.license_check import license_state +from services.license import evaluate, parse_dt, verify_token ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS @@ -30,20 +30,30 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes -GRACE_HOURS = 72 # 3 days offline grace -EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking -EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning -STATE_FILE = Path(__file__).parent.parent / "license_state.json" + + +def _data_dir() -> Path: + """Directory of the SQLite database — the persistent data volume in Docker.""" + from services.tls_identity import tls_dir + return tls_dir().parent + + +# In the data volume: it must survive container re-creation (image updates, +# compose changes), or an offline site would lose its license. Earlier versions +# kept it inside the container at local_backend/license_state.json. +STATE_FILE = _data_dir() / "license_state.json" +LEGACY_STATE_FILE = Path(__file__).parent.parent / "license_state.json" def _load_persisted_state(): - if STATE_FILE.exists(): - try: - data = json.loads(STATE_FILE.read_text()) - license_state.update(data) - logger.info("Loaded persisted license state: %s", data) - except Exception as e: - logger.warning("Could not load license state file: %s", e) + for path in (STATE_FILE, LEGACY_STATE_FILE): + if path.exists(): + try: + license_state.update(json.loads(path.read_text())) + logger.info("Loaded persisted license state from %s", path) + return + except Exception as e: + logger.warning("Could not load license state file %s: %s", path, e) def _persist_state(): @@ -53,29 +63,54 @@ def _persist_state(): logger.warning("Could not persist license state: %s", e) -def _compute_expiry_fields(expires_at_str: str | None) -> dict: - """Return days_until_expiry and grace_expires_at derived from expires_at.""" - if not expires_at_str: - return {"days_until_expiry": None, "grace_expires_at": None} +def _latest_activity(db) -> datetime | None: + """Newest order timestamp in the database — the clock can't be set before it.""" + from sqlalchemy import func + from models.order import Order, OrderItem + stamps = [db.query(func.max(Order.opened_at)).scalar(), db.query(func.max(OrderItem.added_at)).scalar()] + stamps = [parse_dt(x) for x in stamps if x] + return max(stamps, default=None) + +def apply_license(now: datetime | None = None) -> None: + """Re-evaluate the license from the stored signed token (no network).""" + if not settings.SITE_ID: + return # dev / unregistered install: licensing off, as before + from database import SessionLocal + from models.business_day import BusinessDay + + now = now or datetime.now(timezone.utc) + payload = verify_token(license_state.get("license_token"), settings.SITE_ID) + db = SessionLocal() try: - expires_at = datetime.fromisoformat(expires_at_str) - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - except ValueError: - return {"days_until_expiry": None, "grace_expires_at": None} + workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None + latest_activity = _latest_activity(db) + finally: + db.close() - now = datetime.now(timezone.utc) - days_until = (expires_at - now).days # negative once expired + floors = [parse_dt(license_state.get("time_high_water")), latest_activity, + parse_dt(payload["issued_at"]) if payload else None] + floor = max((f for f in floors if f), default=None) + result = evaluate(payload, now, floor, workday_open, parse_dt(license_state.get("legacy_ok_at"))) + if result["license_problem"] != "clock": + license_state["time_high_water"] = max(floor or now, now).isoformat() - grace_expires_at = None - if days_until < 0: - grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat() + if payload: # lock requested by the cloud — deferred while a workday is open + if payload.get("locked"): + if workday_open and not license_state.get("locked"): + license_state["lock_pending"] = True + else: + license_state["lock_pending"] = False + license_state["locked"] = True + else: + license_state["lock_pending"] = False + license_state["locked"] = False - return { - "days_until_expiry": days_until, - "grace_expires_at": grace_expires_at, - } + last_sync = parse_dt(license_state.get("last_sync")) + license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None}) + _persist_state() + if result["license_problem"]: + logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"]) def _get_local_ip() -> str | None: @@ -112,69 +147,39 @@ async def _sync_once(): resp.raise_for_status() data = resp.json() - licensed = data.get("licensed", True) - cloud_locked = data.get("locked", False) - expires_at = data.get("expires_at") - expiry_fields = _compute_expiry_fields(expires_at) - - # If cloud says locked, check whether a workday is currently open. - # No open workday → lock immediately. - # Open workday → defer to workday close (business_day router enforces it). - if cloud_locked: - from database import SessionLocal - from models.business_day import BusinessDay - db = SessionLocal() - try: - open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first() - finally: - db.close() - - if open_day: - if not license_state.get("lock_pending"): - license_state["lock_pending"] = True - logger.info("Cloud requested lock — workday open, deferring to workday close") + token = data.get("license_token") + if token: + if verify_token(token, settings.SITE_ID): + license_state["license_token"] = token + else: + logger.error("Cloud sent a license token that does not verify - keeping the previous one") + else: + # Cloud without license signing (transition): trust this answer for 72h, + # keep the old immediate lock handling + if data.get("licensed", True): + license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat() + license_state["expires_at"] = data.get("expires_at") + if data.get("locked"): + license_state["lock_pending"] = True else: license_state["lock_pending"] = False - license_state["locked"] = True - logger.info("Cloud requested lock — no open workday, locking immediately") - - # If cloud lifts the lock, clear pending too - if not cloud_locked: - license_state["lock_pending"] = False - license_state["locked"] = False + license_state["locked"] = False license_state.update({ - "licensed": licensed, - "expires_at": expires_at, "latest_version": data.get("latest_version"), "waiter_domain": data.get("waiter_domain"), "site_numeric_id": data.get("site_numeric_id"), "last_sync": datetime.now(timezone.utc).isoformat(), "sync_failed": False, - **expiry_fields, }) - _persist_state() - logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at) + logger.info("Cloud sync OK (signed license: %s)", bool(token)) except Exception as e: logger.warning("Cloud sync failed: %s", e) license_state["sync_failed"] = True - last_sync_str = license_state.get("last_sync") - if last_sync_str: - try: - last_sync = datetime.fromisoformat(last_sync_str) - grace_expires = last_sync + timedelta(hours=GRACE_HOURS) - if datetime.now(timezone.utc) > grace_expires: - logger.error("72-hour offline grace period expired — marking unlicensed") - license_state["licensed"] = False - except ValueError: - pass - - # Recompute expiry fields from cached expires_at even when offline - expiry_fields = _compute_expiry_fields(license_state.get("expires_at")) - license_state.update(expiry_fields) - + # Online or not: the stored signed license decides (no more 72h offline rule) + apply_license() IMAGE_DIR = Path("/app/data/product_images") @@ -397,6 +402,7 @@ async def _pull_pending_orders(): async def _sync_loop(): _load_persisted_state() + apply_license() # decide from the stored license before the first network attempt while True: await _sync_once() await asyncio.sleep(SYNC_INTERVAL_SECONDS) diff --git a/local_backend/services/license.py b/local_backend/services/license.py new file mode 100644 index 0000000..1e62b53 --- /dev/null +++ b/local_backend/services/license.py @@ -0,0 +1,106 @@ +""" +Offline-capable licensing (KI-006). + +The cloud is needed to RENEW a license, not to RUN one. Every heartbeat +brings a license token signed by the cloud (Ed25519). The site stores it and +enforces it by itself, so a venue that paid for a year keeps working for that +year even if it never goes online again. The old rule ("unlicensed after 72h +without a heartbeat") is gone. + +Tamper resistance: + - Editing the stored token breaks the signature → treated as no license. + - Turning the clock back: "now" may not be earlier than the latest time this + system has provably seen — the token's signed cloud time, the newest order + in the database, and a stored high-water mark (1 day tolerance). + Limits (documented): someone with root on the server could still patch the + code itself, and a remote lock only reaches a site when it next goes online. + +Rules (evaluate()): + valid token, clock OK: + active and not expired → licensed + expired: 5-day grace → licensed, warnings in the manager + grace over / site deactivated → unlicensed, but never mid-service: + deferred while a workday is open + no valid token: + old cloud without tokens, heartbeat said licensed < 72h ago → licensed (transition) + otherwise → unlicensed ("unverified") + Locks from the cloud (token.locked) keep the existing workday-deferred behaviour. +""" +import base64 +import json +import math +from datetime import datetime, timedelta, timezone + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +# Matches LICENSE_SIGNING_KEY in the cloud's .env (generated 2026-09-28). +# Built into the code on purpose: a key taken from configuration could simply +# be replaced together with a self-made token. +LICENSE_PUBLIC_KEY = "2oeFHV6hgAlJsx/ZBvG6fqmWYn5tjSW5hURrrPhLoOw=" + +EXPIRY_GRACE = timedelta(days=5) +CLOCK_TOLERANCE = timedelta(days=1) +LEGACY_UNSIGNED_OK = timedelta(hours=72) # only while the cloud sends no tokens + + +def _b64url_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def parse_dt(value) -> datetime | None: + if not value: + return None + try: + dt = datetime.fromisoformat(value) if isinstance(value, str) else value + except ValueError: + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def verify_token(token: str | None, site_id: str, public_key_b64: str = LICENSE_PUBLIC_KEY) -> dict | None: + """Payload of a genuine token for this site, else None.""" + if not token or "." not in token: + return None + body, _, sig = token.partition(".") + try: + Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64)).verify(_b64url_decode(sig), body.encode()) + payload = json.loads(_b64url_decode(body)) + except (InvalidSignature, ValueError, TypeError): + return None + if payload.get("v") != 1 or payload.get("site_id") != site_id: + return None + if not parse_dt(payload.get("expires_at")) or not parse_dt(payload.get("issued_at")): + return None + return payload + + +def evaluate(payload: dict | None, now: datetime, floor: datetime | None, workday_open: bool, + legacy_ok_at: datetime | None = None) -> dict: + """Pure license decision. `floor` = latest time this system has provably seen.""" + base = {"license_verified": payload is not None, "license_problem": None, "grace_over": False, + "days_until_expiry": None, "grace_expires_at": None, "grace_days_remaining": None} + + if payload is None: + legacy = legacy_ok_at is not None and now - legacy_ok_at <= LEGACY_UNSIGNED_OK + return {**base, "licensed": legacy, "license_problem": None if legacy else "unverified"} + + if floor is not None and now < floor - CLOCK_TOLERANCE: + return {**base, "licensed": False, "license_problem": "clock"} + + expires = parse_dt(payload["expires_at"]) + grace_end = expires + EXPIRY_GRACE + days_until = (expires - now).days # negative once expired + grace_over = now > grace_end + fields = { + **base, + "expires_at": expires.isoformat(), + "days_until_expiry": days_until, + "grace_expires_at": grace_end.isoformat() if days_until < 0 else None, + # Rounded up: 2 days 23 hours left reads as "3 days", as people count it + "grace_days_remaining": math.ceil((grace_end - now) / timedelta(days=1)) if days_until < 0 and not grace_over else None, + "grace_over": grace_over, + } + problem = "inactive" if not payload.get("active", True) else "expired" if grace_over else None + # Never cut a restaurant off mid-service: an open workday finishes first + return {**fields, "licensed": problem is None or workday_open, "license_problem": problem}