From 4396052ed3dde4b78629a301401d7bcce703cb30 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 25 Sep 2026 22:53:26 +0200 Subject: [PATCH] feat(licensing): report active seat quota and usage to upstream license authority --- LOGAR-LICENSING | 2 +- src/Server.py | 75 ++++++++++++++++++++++++++++++++++++++-- src/server_enrollment.py | 13 +++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/LOGAR-LICENSING b/LOGAR-LICENSING index 30dfc9f..6484480 160000 --- a/LOGAR-LICENSING +++ b/LOGAR-LICENSING @@ -1 +1 @@ -Subproject commit 30dfc9f77f8c4ba9bc232482184dcfe05e864065 +Subproject commit 64844808ba034fa21487a7eddd4e162fe9238d45 diff --git a/src/Server.py b/src/Server.py index 0bef5e5..875cc89 100644 --- a/src/Server.py +++ b/src/Server.py @@ -11,6 +11,7 @@ import secrets import hashlib import hmac import warnings +import threading from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional, Tuple @@ -811,8 +812,46 @@ def check_and_rotate_server_certs( return False +def report_seat_quota_to_authority_sync(): + """Background helper to push active seat count to upstream authority without blocking.""" + try: + config = SERVER_STATE.get("config", {}) + cert_dir = config.get("cert_dir", "certs") + db_path = config.get("db_path", DEFAULT_DB_FILE) + lic_server = config.get("license_server_url", "https://logar.eibl.tech") + if not os.path.exists(os.path.join(cert_dir, "ca.crt")) or not os.path.exists(os.path.join(cert_dir, "ca.key")): + return + + active_seats = 0 + if os.path.exists(db_path): + try: + conn = sqlite3.connect(db_path) + row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'").fetchone() + if row: + active_seats = row[0] + conn.close() + except Exception: + pass + + enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=lic_server, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") + ) + except Exception: + pass + + +def trigger_async_seat_report(): + """Fires a non-blocking background thread to sync seat usage with upstream authority.""" + threading.Thread(target=report_seat_quota_to_authority_sync, daemon=True).start() + + async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30): - """Periodically checks the validity of Hub Root CA and Server TLS certificates (default every 12 hours).""" + """Periodically checks the validity of Hub Root CA and Server TLS certificates and reports seat quotas.""" config = SERVER_STATE.get("config", {}) cert_dir = config.get("cert_dir", "certs") hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] @@ -820,6 +859,7 @@ async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: try: await asyncio.sleep(interval_seconds) check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) + report_seat_quota_to_authority_sync() except asyncio.CancelledError: break except Exception as e: @@ -1132,6 +1172,7 @@ def enroll_client(req: ClientEnrollRequest): conn.commit() conn.close() print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]") + trigger_async_seat_report() return { "ca_cert": ca_cert_pem, @@ -1231,6 +1272,7 @@ def approve_client(action: ClientActionRequest): if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[+] Approved/Reactivated client seat: {action.client_id}") + trigger_async_seat_report() return {"status": "active", "client_id": action.client_id} @@ -1252,6 +1294,7 @@ def revoke_client(action: ClientActionRequest): if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[!] Revoked client license seat: {action.client_id}") + trigger_async_seat_report() return {"status": "revoked", "client_id": action.client_id} @@ -1269,6 +1312,7 @@ def delete_client(action: ClientActionRequest): if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[+] Removed client forwarder record: {action.client_id}") + trigger_async_seat_report() return {"status": "deleted", "client_id": action.client_id} @@ -1569,11 +1613,29 @@ async def trigger_license_renewal(request: Request): config = SERVER_STATE.get("config", {}) cert_dir = config.get("cert_dir", "certs") lic_server = config.get("license_server_url", "https://logar.eibl.tech") + db_path = config.get("db_path", DEFAULT_DB_FILE) + + active_seats = 0 + try: + conn_cnt = sqlite3.connect(db_path) + c_cnt = conn_cnt.cursor() + c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'") + row = c_cnt.fetchone() + if row: + active_seats = row[0] + conn_cnt.close() + except Exception: + pass + success, msg, new_pem = enrollment.renew_ca_cert_mtls( cert_dir=cert_dir, license_server_url=lic_server, requested_seats=requested_seats, - seat_increment=seat_increment + seat_increment=seat_increment, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") ) if not success or not new_pem: raise HTTPException(status_code=400, detail=msg) @@ -2509,7 +2571,14 @@ def main(): # Fallback to mTLS HTTP sync print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...") http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "") - success, msg, new_pem = enrollment.renew_ca_cert_mtls(cert_dir=cert_dir, license_server_url=http_url) + success, msg, new_pem = enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=http_url, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") + ) if not success or not new_pem: print(f"[-] License sync failed: {msg}") sys.exit(1) diff --git a/src/server_enrollment.py b/src/server_enrollment.py index 2f5b894..f1db507 100644 --- a/src/server_enrollment.py +++ b/src/server_enrollment.py @@ -900,13 +900,17 @@ def renew_ca_cert_mtls( upstream_ca_pem: Optional[str] = None, requested_seats: Optional[int] = None, seat_increment: int = 0, + seats_taken: Optional[int] = None, + active_seats: Optional[int] = None, + hostname: Optional[str] = None, + server_fingerprint: Optional[str] = None, timeout: int = 15, verify_tls: bool = True ) -> Tuple[bool, str, Optional[str]]: """ Connects to the master License Authority using mutual TLS (mTLS), authenticating with the server's current ca.crt and ca.key, and requests an automated renewal. - Supports requesting seat quota increases or increments during renewal. + Supports requesting seat quota increases or increments during renewal and reporting active seat usage. If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir. Returns (success, message, new_ca_cert_pem). """ @@ -924,10 +928,15 @@ def renew_ca_cert_mtls( current_pem = f.read() info = extract_license_info_from_cert(current_pem) + effective_seats_taken = seats_taken if seats_taken is not None else active_seats payload_dict = { "license_id": info.get("license_id"), "fingerprint": info.get("fingerprint"), - "current_cert": current_pem + "current_cert": current_pem, + "seats_taken": effective_seats_taken, + "active_seats": effective_seats_taken, + "hostname": hostname or "", + "server_fingerprint": server_fingerprint or "" } if requested_seats is not None: payload_dict["requested_seats"] = requested_seats