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