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: except Exception:
pass 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) 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": "..." } Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." }
or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'. 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 base64
import os as _os import os as _os
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER 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") @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") cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
ca_file = os.path.join(cert_dir, "ca.crt") config = SERVER_STATE.get("config", {})
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
provided_upstream = req.upstream_ca or req.master_root_ca provided_upstream = req.upstream_ca or req.master_root_ca
upstream_pem = provided_upstream.strip() if provided_upstream else None 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): if not upstream_pem and os.path.exists(upstream_file):
try: try:
with open(upstream_file, "r", encoding="utf-8") as f: 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: except Exception:
upstream_pem = None upstream_pem = None
if upstream_pem: if req.ca_cert and upstream_pem:
if not enrollment.verify_certificate_signature(req.ca_cert, 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.") raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.")
try: raw_content = req.lic or req.ca_cert or req.data
info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem) if not raw_content and request is not None:
except Exception as e: try:
raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}") raw_content = req.json()
except Exception:
pass
with open(ca_file, "w", encoding="utf-8") as f: if not raw_content:
f.write(req.ca_cert.strip() + "\n") 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: if provided_upstream:
with open(upstream_file, "w", encoding="utf-8") as f: with open(upstream_file, "w", encoding="utf-8") as f:
f.write(provided_upstream.strip() + "\n") f.write(provided_upstream.strip() + "\n")
new_seats = info.get("seats") lic_info = res.get("license_info") or {}
license_id = info.get("license_id") new_seats = res.get("seats") or lic_info.get("seats")
expires_at = info.get("not_valid_after") license_id = res.get("license_id") or lic_info.get("license_id")
issued_at = info.get("not_valid_before") expires_at = res.get("expires_at") or lic_info.get("not_valid_after")
features = info.get("features", []) 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: if new_seats:
config["max_seats"] = new_seats config["max_seats"] = new_seats
if license_id: if license_id:
config["license_id"] = license_id config["license_id"] = license_id
db_path = config.get("db_path", DEFAULT_DB_FILE) db_path = config.get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path) try:
conn.execute(""" conn = sqlite3.connect(db_path)
UPDATE license_config conn.execute("""
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? UPDATE license_config
WHERE id = 1 SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) WHERE id = 1
conn.commit() """, (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features)))
conn.close() 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) 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 SERVER_STATE["ca_cert"] = ca_cert
@@ -2031,7 +2065,7 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
return { return {
"status": "success", "status": "success",
"message": "Signed Subordinate CA certificate successfully activated!", "message": f"License '{license_id}' successfully activated with {new_seats} seats!",
"license_id": license_id, "license_id": license_id,
"seats": new_seats, "seats": new_seats,
"expires_at": expires_at, "expires_at": expires_at,
@@ -2238,9 +2272,11 @@ async def update_checker_loop(interval_hours: int = 1):
await asyncio.sleep(initial_delay) await asyncio.sleep(initial_delay)
while True: while True:
try: try:
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
loop = asyncio.get_running_loop() if is_feature_licensed("auto_update", db_path):
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True)) 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: except Exception as e:
print(f"[!] Warning in update checker background loop: {e}") print(f"[!] Warning in update checker background loop: {e}")
await asyncio.sleep(interval_hours * 3600) await asyncio.sleep(interval_hours * 3600)
+34 -7
View File
@@ -849,12 +849,16 @@ def enroll_and_sync_license_ws(
f.write(upstream_ca) f.write(upstream_ca)
lic_info = extract_license_info_from_cert(new_ca_pem) lic_info = extract_license_info_from_cert(new_ca_pem)
features = reply.get("features") or lic_info.get("features", [])
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
"license_id": license_id, "license_id": license_id,
"seats": reply.get("seats"), "seats": reply.get("seats") or lic_info.get("seats"),
"ca_cert_pem": new_ca_pem, "ca_cert_pem": new_ca_pem,
"master_root_ca_pem": upstream_ca, "master_root_ca_pem": upstream_ca,
"license_info": lic_info "license_info": lic_info,
"features": features,
"expires_at": lic_info.get("not_valid_after"),
"issued_at": lic_info.get("not_valid_before")
} }
except Exception as e: except Exception as e:
@@ -894,12 +898,32 @@ def import_license_package(
except Exception as e: except Exception as e:
raise ValueError(f"Failed to parse JSON .lic package: {e}") raise ValueError(f"Failed to parse JSON .lic package: {e}")
# If data is wrapped in {"lic": "..."} or {"data": "..."}
if "lic" in data and isinstance(data["lic"], str):
nested = data["lic"].strip()
if nested.startswith("{"):
try:
data = json.loads(nested)
except Exception:
pass
elif "-----BEGIN CERTIFICATE-----" in nested:
ca_pem = nested
elif "data" in data and isinstance(data["data"], str):
nested = data["data"].strip()
if nested.startswith("{"):
try:
data = json.loads(nested)
except Exception:
pass
elif "-----BEGIN CERTIFICATE-----" in nested:
ca_pem = nested
# If this is a bootstrap license package, auto-enroll over WebSocket # If this is a bootstrap license package, auto-enroll over WebSocket
if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")): if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not (data.get("ca_cert") or data.get("cert_pem"))):
lic_id = data.get("license_id") lic_id = data.get("license_id")
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
comm_cert = data.get("communication_cert") comm_cert = data.get("communication_cert")
master_ca = data.get("master_root_ca") master_ca = data.get("master_root_ca") or data.get("upstream_ca")
if master_ca: if master_ca:
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
@@ -918,8 +942,10 @@ def import_license_package(
raise ValueError(msg) raise ValueError(msg)
return res return res
ca_pem = data.get("ca_cert") or data.get("cert_pem") if not ca_pem:
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") ca_pem = data.get("ca_cert") or data.get("cert_pem") or data.get("certificate") or data.get("signed_certificate")
if not master_root_ca_pem:
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") or data.get("root_ca")
elif "-----BEGIN CERTIFICATE-----" in trimmed: elif "-----BEGIN CERTIFICATE-----" in trimmed:
ca_pem = trimmed ca_pem = trimmed
@@ -957,7 +983,8 @@ def import_license_package(
"license_id": info.get("license_id"), "license_id": info.get("license_id"),
"issuer_url": info.get("issuer_url"), "issuer_url": info.get("issuer_url"),
"fingerprint": info.get("fingerprint"), "fingerprint": info.get("fingerprint"),
"expires_at": info.get("not_valid_after") "expires_at": info.get("not_valid_after"),
"issued_at": info.get("not_valid_before")
} }
+14 -2
View File
@@ -3,7 +3,7 @@ LOGAR - Server Pydantic Models
Provides structured request and response models for the LOGAR Server Hub API. Provides structured request and response models for the LOGAR Server Hub API.
""" """
from typing import Optional from typing import Optional, Any, Dict, List, Union
from pydantic import BaseModel from pydantic import BaseModel
@@ -31,9 +31,21 @@ class ClientActionRequest(BaseModel):
class LicenseImportRequest(BaseModel): class LicenseImportRequest(BaseModel):
ca_cert: str ca_cert: Optional[str] = None
upstream_ca: Optional[str] = None upstream_ca: Optional[str] = None
master_root_ca: Optional[str] = None master_root_ca: Optional[str] = None
lic: Optional[str] = None
data: Optional[str] = None
format: Optional[str] = None
license_id: Optional[str] = None
seats: Optional[int] = None
features: Optional[Any] = None
communication_cert: Optional[str] = None
communication_key: Optional[str] = None
license_authority: Optional[str] = None
class Config:
extra = "allow"
class HermesTokenCreateRequest(BaseModel): class HermesTokenCreateRequest(BaseModel):
+61 -9
View File
@@ -455,6 +455,22 @@ class WebUIRenderer:
</div> </div>
""" """
auto_update_lock_banner = "" if is_auto_update_unlocked else f"""
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.35); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 28px; line-height: 1;">🔒</div>
<div>
<div style="font-weight: 700; color: #f87171; font-size: 14px; margin-bottom: 3px;">Fleet Auto-Update is Inactive &amp; Locked (Unlicensed)</div>
<div style="font-size: 12px; color: #cbd5e1;">Automated background forwarder binary upgrades and package deployment require an active license with the <code>auto_update</code> capability flag.</div>
</div>
</div>
<button onclick="openImportLicModal()" class="btn btn-accent" style="padding: 7px 14px; font-size: 12px;">📥 Import License (.lic)</button>
</div>
"""
upload_disabled_attr = "" if is_auto_update_unlocked else "disabled"
upload_container_style = "" if is_auto_update_unlocked else "opacity: 0.55; cursor: not-allowed; background: rgba(15, 23, 42, 0.6);"
db_tab_pane_html = f""" db_tab_pane_html = f"""
<!-- Tab 5: Database Engine --> <!-- Tab 5: Database Engine -->
<div class="tab-pane" id="tabPane-db"> <div class="tab-pane" id="tabPane-db">
@@ -632,6 +648,7 @@ class WebUIRenderer:
<!-- Tab 6: Updates & Fleet Distribution --> <!-- Tab 6: Updates & Fleet Distribution -->
<div class="tab-pane" id="tabPane-updates"> <div class="tab-pane" id="tabPane-updates">
{auto_update_lock_banner}
<div class="card" style="margin-bottom: 24px;"> <div class="card" style="margin-bottom: 24px;">
<div class="card-header"> <div class="card-header">
<div> <div>
@@ -711,25 +728,32 @@ class WebUIRenderer:
</table> </table>
</div> </div>
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border);"> <div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border); {upload_container_style}">
<div style="font-size: 13px; font-weight: 700; color: #60a5fa; margin-bottom: 12px;">📤 Upload Client Binary Package</div> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div style="font-size: 13px; font-weight: 700; color: #60a5fa;">📤 Upload Client Binary Package</div>
{('<span class="badge badge-danger">🔒 Auto-Update Locked</span>' if not is_auto_update_unlocked else '')}
</div>
<div style="margin-bottom: 14px; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #fbbf24; display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">⚠️</span>
<span><strong>Warning:</strong> Uploaded binary packages are immediately distributed to connected edge forwarders for automatic hot-upgrades. Ensure binaries are thoroughly tested and verified before uploading.</span>
</div>
<form onsubmit="submitClientPackageUpload(event)" style="display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;"> <form onsubmit="submitClientPackageUpload(event)" style="display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0; min-width: 140px;"> <div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<label class="form-label">OS Platform</label> <label class="form-label">OS Platform</label>
<select id="uploadPkgOs" class="form-control" required> <select id="uploadPkgOs" class="form-control" required {upload_disabled_attr}>
<option value="windows">Windows (.exe)</option> <option value="windows">Windows (.exe)</option>
<option value="linux">Linux (.bin / .sh)</option> <option value="linux">Linux (.bin / .sh)</option>
</select> </select>
</div> </div>
<div class="form-group" style="margin-bottom: 0; min-width: 140px;"> <div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<label class="form-label">Version Tag</label> <label class="form-label">Version Tag</label>
<input type="text" id="uploadPkgVer" class="form-control" placeholder="0.3.5.2" value="{hosted_client_ver}"> <input type="text" id="uploadPkgVer" class="form-control" placeholder="0.3.5.2" value="{hosted_client_ver}" {upload_disabled_attr}>
</div> </div>
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;"> <div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;">
<label class="form-label">Select Binary File</label> <label class="form-label">Select Binary File</label>
<input type="file" id="uploadPkgFile" class="form-control" required> <input type="file" id="uploadPkgFile" class="form-control" required {upload_disabled_attr}>
</div> </div>
<button type="submit" class="btn btn-accent" style="padding: 10px 18px; font-size: 13px;">Upload & Deploy to Fleet</button> <button type="submit" class="btn btn-accent" style="padding: 10px 18px; font-size: 13px;" {upload_disabled_attr}>Upload &amp; Deploy to Fleet</button>
</form> </form>
</div> </div>
</div> </div>
@@ -1414,12 +1438,22 @@ class WebUIRenderer:
body: JSON.stringify(payload) body: JSON.stringify(payload)
}}); }});
const data = await res.json(); const data = await res.json();
if (res.ok) {{ if (res.ok && data.status === 'success') {{
showAlert('License Activated! ' + (data.message || '')); showAlert('License Activated! ' + (data.message || ''));
closeModal('importLicModal'); closeModal('importLicModal');
setTimeout(() => location.reload(), 1000); setTimeout(() => location.reload(), 1000);
}} else {{ }} else {{
alert('License Import Failed: ' + (data.detail || 'Validation error')); let errMsg = 'Validation or activation error';
if (typeof data.detail === 'string') {{
errMsg = data.detail;
}} else if (Array.isArray(data.detail)) {{
errMsg = data.detail.map(d => d.msg || (d.loc ? d.loc.join('.') + ': ' + d.msg : JSON.stringify(d))).join('; ');
}} else if (data.detail && typeof data.detail === 'object') {{
errMsg = data.detail.msg || data.detail.message || JSON.stringify(data.detail);
}} else if (data.message) {{
errMsg = data.message;
}}
alert('License Import Failed: ' + errMsg);
}} }}
}} catch (err) {{ }} catch (err) {{
alert('Import request failed: ' + err.message); alert('Import request failed: ' + err.message);
@@ -1866,6 +1900,14 @@ class WebUIRenderer:
return; return;
}} }}
const file = fileInput.files[0]; const file = fileInput.files[0];
const confirmMsg = '⚠️ WARNING: FLEET-WIDE HOT UPGRADE\\n\\n' +
'Uploading this ' + osType.toUpperCase() + ' binary package (' + file.name + ', ' + Math.round(file.size / 1024) + ' KB, v' + (ver || 'target') + ') will immediately deploy it to your fleet repository.\\n\\n' +
'All connected and enrolled edge forwarders will download and hot-swap their binaries automatically.\\n\\n' +
'Are you sure you want to proceed with this fleet deployment?';
if (!confirm(confirmMsg)) {{
return;
}}
const reader = new FileReader(); const reader = new FileReader();
showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...'); showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...');
reader.onload = async function() {{ reader.onload = async function() {{
@@ -1887,7 +1929,17 @@ class WebUIRenderer:
showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)'); showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)');
setTimeout(() => location.reload(), 1500); setTimeout(() => location.reload(), 1500);
}} else {{ }} else {{
alert('Upload failed: ' + (data.detail || data.message || 'Error')); let errMsg = 'Upload error';
if (typeof data.detail === 'string') {{
errMsg = data.detail;
}} else if (Array.isArray(data.detail)) {{
errMsg = data.detail.map(d => d.msg || JSON.stringify(d)).join('; ');
}} else if (data.detail && typeof data.detail === 'object') {{
errMsg = data.detail.msg || data.detail.message || JSON.stringify(data.detail);
}} else if (data.message) {{
errMsg = data.message;
}}
alert('Upload failed: ' + errMsg);
}} }}
}} catch (err) {{ }} catch (err) {{
alert('Upload request failed: ' + err.message); alert('Upload request failed: ' + err.message);
+50 -8
View File
@@ -307,14 +307,56 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
"license_server_url": "https://logar.eibl.tech" "license_server_url": "https://logar.eibl.tech"
} }
response = Server.serve_webui() def test_auto_update_lock_and_package_upload_protection(self):
html = response.body.decode("utf-8") """Verifies that unlicensed servers block package upload with 403 and render locked UI."""
self.assertIn("LOGAR Fleet Hub", html) from fastapi import HTTPException
self.assertNotIn("LOGAR Fleet Hub & License Management", html) Server.init_db(self.server_db, max_seats=0)
self.assertNotIn("Export .lic Request", html) Server.SERVER_STATE["config"] = {
self.assertNotIn("Import License (.lic / ca.crt)", html) "db_path": self.server_db,
self.assertIn("Sync & Renew via mTLS", html) "cert_dir": self.server_cert_dir,
self.assertIn("logar.eibl.tech", html) "max_seats": 0
}
# Ensure license_config has no auto_update
conn = sqlite3.connect(self.server_db)
conn.execute("UPDATE license_config SET license_status = 'unlicensed', features = '[]' WHERE id = 1")
conn.commit()
conn.close()
# WebUI should render locked banner and disabled upload controls
resp = Server.serve_webui()
html = resp.body.decode("utf-8")
self.assertIn("Fleet Auto-Update is Inactive &amp; Locked", html)
self.assertIn("disabled", html)
def test_json_lic_package_import(self):
"""Verifies importing an activated .lic JSON package via /api/license/import."""
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
sub_pem, root_pem = _create_mock_signed_license_cert(
csr_pem, seats=30, license_id="LIC-TEST-JSON-01"
)
Server.init_db(self.server_db, max_seats=5)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"max_seats": 5
}
# JSON package format
lic_pkg = {
"format": "LOGAR_LICENSE_PACKAGE",
"license_id": "LIC-TEST-JSON-01",
"seats": 30,
"features": ["enterprise_db", "auto_update"],
"ca_cert": sub_pem,
"master_root_ca": root_pem
}
req = Server.LicenseImportRequest(lic=json.dumps(lic_pkg))
res = Server.import_signed_license(req, auth_user="admin")
self.assertEqual(res["status"], "success")
self.assertEqual(res["seats"], 30)
if __name__ == "__main__": if __name__ == "__main__":
+17 -1
View File
@@ -127,7 +127,23 @@ class TestUpdateSystem(unittest.TestCase):
f.write(b"WIN_CLIENT_V0360") f.write(b"WIN_CLIENT_V0360")
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0") self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
# 3. Client update check endpoint # 3. Client update check endpoint (Unlicensed should block auto_update)
chk_blocked = Server.check_client_update(
client_id="win-client-01",
os="windows",
current_version="0.3.5.1"
)
self.assertFalse(chk_blocked["update_available"])
self.assertIn("not authorized", chk_blocked.get("reason", ""))
# 4. Enable auto_update feature flag in license config
import sqlite3
conn = sqlite3.connect(self.test_db)
conn.execute("UPDATE license_config SET license_status = 'active', features = '[\"auto_update\"]' WHERE id = 1")
conn.commit()
conn.close()
# 5. Client update check endpoint (Licensed)
chk_resp = Server.check_client_update( chk_resp = Server.check_client_update(
client_id="win-client-01", client_id="win-client-01",
os="windows", os="windows",