feat(backend): offline-capable signed licensing; fix expiry grace; state in data dir (KI-006)

- services/license.py: verifies the cloud's Ed25519-signed license token
  (public key built in) and decides purely: valid → licensed until expiry,
  then a 5-day grace, then blocked - never mid-service (deferred while a
  workday is open, applied at close). Works offline for as long as the
  license lasts: the "unlicensed after 72h without heartbeat" rule is gone.
- Tamper resistance: an edited token fails the signature ("unverified");
  a clock earlier than the latest provable time (token issued_at, newest
  order in the DB, stored high-water mark; 1 day tolerance) → "clock".
- apply_license() re-evaluates from the stored token at startup, after
  every heartbeat attempt and when a workday closes. Cloud lock/unlock from
  the token keeps the workday-deferred behaviour. Transition: a cloud
  without tokens is trusted 72h per successful heartbeat.
- FIX: the promised 5-day grace after expiry never happened - the cloud's
  licensed=false was applied immediately (402 on everything).
- FIX: license_state.json lived inside the container and was lost on every
  re-creation; it now lives in the data volume (old path read once).
- /api/system/status: offline_days, license_verified, license_problem,
  grace_over; lock_reason "clock"/"unverified"; grace days from the license
  module (rounded up).

Tests: 18 unit checks (signature, tamper, other site/key, 364 days
offline, grace ±workday, inactive, clock rollback, transition) + 17 E2E
checks with a real cloud + site process (400 days offline, tampered file →
402, clock behind newest order, expiry deferred until workday close,
renewal, remote lock/unlock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:16:32 +03:00
co-authored by Claude Opus 5.5
parent a349043abb
commit 13a451a756
4 changed files with 228 additions and 114 deletions
+93 -87
View File
@@ -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)