fix(licensing): support bootstrap and activated JSON .lic imports, lock auto-update when unlicensed, and add upload confirmation warning
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 4m25s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m13s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 12m45s

This commit is contained in:
2026-10-04 01:06:19 +02:00
parent 548b2fd8ad
commit 8f8a605c98
6 changed files with 240 additions and 55 deletions
+64 -28
View File
@@ -1503,6 +1503,14 @@ def check_client_update(
except Exception:
pass
if not is_feature_licensed("auto_update", db_path):
return {
"update_available": False,
"current_version": current_version or "0.0.0",
"target_version": current_version or "0.0.0",
"reason": "Fleet auto-update is not authorized by the server license."
}
return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch)
@@ -1585,6 +1593,13 @@ async def upload_client_package(
Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." }
or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'.
"""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Client package fleet distribution requires an active license with the 'auto_update' capability."
)
import base64
import os as _os
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
@@ -1957,12 +1972,13 @@ def get_ca_csr(auth_user: str = Depends(require_admin_auth)):
@app.post("/api/license/import")
def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(require_admin_auth)):
def import_signed_license(req: LicenseImportRequest, request: Request = None, auth_user: str = Depends(require_admin_auth)):
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
ca_file = os.path.join(cert_dir, "ca.crt")
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
config = SERVER_STATE.get("config", {})
provided_upstream = req.upstream_ca or req.master_root_ca
upstream_pem = provided_upstream.strip() if provided_upstream else None
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
if not upstream_pem and os.path.exists(upstream_file):
try:
with open(upstream_file, "r", encoding="utf-8") as f:
@@ -1970,43 +1986,61 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
except Exception:
upstream_pem = None
if upstream_pem:
if req.ca_cert and upstream_pem:
if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem):
raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.")
try:
info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}")
raw_content = req.lic or req.ca_cert or req.data
if not raw_content and request is not None:
try:
raw_content = req.json()
except Exception:
pass
with open(ca_file, "w", encoding="utf-8") as f:
f.write(req.ca_cert.strip() + "\n")
if not raw_content:
raise HTTPException(status_code=400, detail="No license data provided. Please provide a .lic package, JSON payload, or Sub-CA certificate.")
if isinstance(raw_content, dict):
raw_content = json.dumps(raw_content)
try:
res = enrollment.import_license_package(
str(raw_content),
cert_dir=cert_dir,
server_fingerprint=config.get("server_fingerprint", ""),
hostname=config.get("server_name", "")
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"License validation or enrollment failed: {str(e)}")
if provided_upstream:
with open(upstream_file, "w", encoding="utf-8") as f:
f.write(provided_upstream.strip() + "\n")
new_seats = info.get("seats")
license_id = info.get("license_id")
expires_at = info.get("not_valid_after")
issued_at = info.get("not_valid_before")
features = info.get("features", [])
lic_info = res.get("license_info") or {}
new_seats = res.get("seats") or lic_info.get("seats")
license_id = res.get("license_id") or lic_info.get("license_id")
expires_at = res.get("expires_at") or lic_info.get("not_valid_after")
issued_at = res.get("issued_at") or lic_info.get("not_valid_before")
features = res.get("features") or lic_info.get("features", [])
config = SERVER_STATE.get("config", {})
if new_seats:
config["max_seats"] = new_seats
if license_id:
config["license_id"] = license_id
db_path = config.get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path)
conn.execute("""
UPDATE license_config
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
WHERE id = 1
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features)))
conn.commit()
conn.close()
try:
conn = sqlite3.connect(db_path)
conn.execute("""
UPDATE license_config
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
WHERE id = 1
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features)))
conn.commit()
conn.close()
except Exception as e:
print(f"[!] Warning: Failed to update license_config in DB: {e}")
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
SERVER_STATE["ca_cert"] = ca_cert
@@ -2031,7 +2065,7 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
return {
"status": "success",
"message": "Signed Subordinate CA certificate successfully activated!",
"message": f"License '{license_id}' successfully activated with {new_seats} seats!",
"license_id": license_id,
"seats": new_seats,
"expires_at": expires_at,
@@ -2238,9 +2272,11 @@ async def update_checker_loop(interval_hours: int = 1):
await asyncio.sleep(initial_delay)
while True:
try:
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True))
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if is_feature_licensed("auto_update", db_path):
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True))
except Exception as e:
print(f"[!] Warning in update checker background loop: {e}")
await asyncio.sleep(interval_hours * 3600)