feat(licensing): report active seat quota and usage to upstream license authority
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 21s

This commit is contained in:
2026-09-25 22:53:26 +02:00
parent c2fed0379c
commit 4396052ed3
3 changed files with 84 additions and 6 deletions
+72 -3
View File
@@ -11,6 +11,7 @@ import secrets
import hashlib import hashlib
import hmac import hmac
import warnings import warnings
import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
@@ -811,8 +812,46 @@ def check_and_rotate_server_certs(
return False 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): 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", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] 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: try:
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days)
report_seat_quota_to_authority_sync()
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
@@ -1132,6 +1172,7 @@ def enroll_client(req: ClientEnrollRequest):
conn.commit() conn.commit()
conn.close() conn.close()
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]") print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
trigger_async_seat_report()
return { return {
"ca_cert": ca_cert_pem, "ca_cert": ca_cert_pem,
@@ -1231,6 +1272,7 @@ def approve_client(action: ClientActionRequest):
if affected == 0: if affected == 0:
raise HTTPException(status_code=404, detail="Client ID not found") raise HTTPException(status_code=404, detail="Client ID not found")
print(f"[+] Approved/Reactivated client seat: {action.client_id}") print(f"[+] Approved/Reactivated client seat: {action.client_id}")
trigger_async_seat_report()
return {"status": "active", "client_id": action.client_id} return {"status": "active", "client_id": action.client_id}
@@ -1252,6 +1294,7 @@ def revoke_client(action: ClientActionRequest):
if affected == 0: if affected == 0:
raise HTTPException(status_code=404, detail="Client ID not found") raise HTTPException(status_code=404, detail="Client ID not found")
print(f"[!] Revoked client license seat: {action.client_id}") print(f"[!] Revoked client license seat: {action.client_id}")
trigger_async_seat_report()
return {"status": "revoked", "client_id": action.client_id} return {"status": "revoked", "client_id": action.client_id}
@@ -1269,6 +1312,7 @@ def delete_client(action: ClientActionRequest):
if affected == 0: if affected == 0:
raise HTTPException(status_code=404, detail="Client ID not found") raise HTTPException(status_code=404, detail="Client ID not found")
print(f"[+] Removed client forwarder record: {action.client_id}") print(f"[+] Removed client forwarder record: {action.client_id}")
trigger_async_seat_report()
return {"status": "deleted", "client_id": action.client_id} return {"status": "deleted", "client_id": action.client_id}
@@ -1569,11 +1613,29 @@ async def trigger_license_renewal(request: Request):
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
lic_server = config.get("license_server_url", "https://logar.eibl.tech") 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( success, msg, new_pem = enrollment.renew_ca_cert_mtls(
cert_dir=cert_dir, cert_dir=cert_dir,
license_server_url=lic_server, license_server_url=lic_server,
requested_seats=requested_seats, 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: if not success or not new_pem:
raise HTTPException(status_code=400, detail=msg) raise HTTPException(status_code=400, detail=msg)
@@ -2509,7 +2571,14 @@ def main():
# Fallback to mTLS HTTP sync # Fallback to mTLS HTTP sync
print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...") print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...")
http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "") 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: if not success or not new_pem:
print(f"[-] License sync failed: {msg}") print(f"[-] License sync failed: {msg}")
sys.exit(1) sys.exit(1)
+11 -2
View File
@@ -900,13 +900,17 @@ def renew_ca_cert_mtls(
upstream_ca_pem: Optional[str] = None, upstream_ca_pem: Optional[str] = None,
requested_seats: Optional[int] = None, requested_seats: Optional[int] = None,
seat_increment: int = 0, 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, timeout: int = 15,
verify_tls: bool = True verify_tls: bool = True
) -> Tuple[bool, str, Optional[str]]: ) -> Tuple[bool, str, Optional[str]]:
""" """
Connects to the master License Authority using mutual TLS (mTLS), authenticating 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. 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. If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
Returns (success, message, new_ca_cert_pem). Returns (success, message, new_ca_cert_pem).
""" """
@@ -924,10 +928,15 @@ def renew_ca_cert_mtls(
current_pem = f.read() current_pem = f.read()
info = extract_license_info_from_cert(current_pem) info = extract_license_info_from_cert(current_pem)
effective_seats_taken = seats_taken if seats_taken is not None else active_seats
payload_dict = { payload_dict = {
"license_id": info.get("license_id"), "license_id": info.get("license_id"),
"fingerprint": info.get("fingerprint"), "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: if requested_seats is not None:
payload_dict["requested_seats"] = requested_seats payload_dict["requested_seats"] = requested_seats