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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user