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
+52 -16
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,35 +1986,51 @@ 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.")
raw_content = req.lic or req.ca_cert or req.data
if not raw_content and request is not None:
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.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)
try:
conn = sqlite3.connect(db_path)
conn.execute("""
UPDATE license_config
@@ -2007,6 +2039,8 @@ def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(re
""", (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,6 +2272,8 @@ async def update_checker_loop(interval_hours: int = 1):
await asyncio.sleep(initial_delay)
while True:
try:
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))
+34 -7
View File
@@ -849,12 +849,16 @@ def enroll_and_sync_license_ws(
f.write(upstream_ca)
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)", {
"license_id": license_id,
"seats": reply.get("seats"),
"seats": reply.get("seats") or lic_info.get("seats"),
"ca_cert_pem": new_ca_pem,
"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:
@@ -894,12 +898,32 @@ def import_license_package(
except Exception as 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 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")
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
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:
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)
return res
ca_pem = data.get("ca_cert") or data.get("cert_pem")
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca")
if not ca_pem:
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:
ca_pem = trimmed
@@ -957,7 +983,8 @@ def import_license_package(
"license_id": info.get("license_id"),
"issuer_url": info.get("issuer_url"),
"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.
"""
from typing import Optional
from typing import Optional, Any, Dict, List, Union
from pydantic import BaseModel
@@ -31,9 +31,21 @@ class ClientActionRequest(BaseModel):
class LicenseImportRequest(BaseModel):
ca_cert: str
ca_cert: Optional[str] = None
upstream_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):
+61 -9
View File
@@ -455,6 +455,22 @@ class WebUIRenderer:
</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"""
<!-- Tab 5: Database Engine -->
<div class="tab-pane" id="tabPane-db">
@@ -632,6 +648,7 @@ class WebUIRenderer:
<!-- Tab 6: Updates & Fleet Distribution -->
<div class="tab-pane" id="tabPane-updates">
{auto_update_lock_banner}
<div class="card" style="margin-bottom: 24px;">
<div class="card-header">
<div>
@@ -711,25 +728,32 @@ class WebUIRenderer:
</table>
</div>
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 13px; font-weight: 700; color: #60a5fa; margin-bottom: 12px;">📤 Upload Client Binary Package</div>
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border); {upload_container_style}">
<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;">
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<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="linux">Linux (.bin / .sh)</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<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 class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;">
<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>
<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>
</div>
</div>
@@ -1414,12 +1438,22 @@ class WebUIRenderer:
body: JSON.stringify(payload)
}});
const data = await res.json();
if (res.ok) {{
if (res.ok && data.status === 'success') {{
showAlert('License Activated! ' + (data.message || ''));
closeModal('importLicModal');
setTimeout(() => location.reload(), 1000);
}} 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) {{
alert('Import request failed: ' + err.message);
@@ -1866,6 +1900,14 @@ class WebUIRenderer:
return;
}}
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();
showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...');
reader.onload = async function() {{
@@ -1887,7 +1929,17 @@ class WebUIRenderer:
showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)');
setTimeout(() => location.reload(), 1500);
}} 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) {{
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"
}
response = Server.serve_webui()
html = response.body.decode("utf-8")
self.assertIn("LOGAR Fleet Hub", html)
self.assertNotIn("LOGAR Fleet Hub & License Management", html)
self.assertNotIn("Export .lic Request", html)
self.assertNotIn("Import License (.lic / ca.crt)", html)
self.assertIn("Sync & Renew via mTLS", html)
self.assertIn("logar.eibl.tech", html)
def test_auto_update_lock_and_package_upload_protection(self):
"""Verifies that unlicensed servers block package upload with 403 and render locked UI."""
from fastapi import HTTPException
Server.init_db(self.server_db, max_seats=0)
Server.SERVER_STATE["config"] = {
"db_path": self.server_db,
"cert_dir": self.server_cert_dir,
"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__":
+17 -1
View File
@@ -127,7 +127,23 @@ class TestUpdateSystem(unittest.TestCase):
f.write(b"WIN_CLIENT_V0360")
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(
client_id="win-client-01",
os="windows",