diff --git a/src/hub/Server.py b/src/hub/Server.py index a4f7ac1..5f6bc40 100644 --- a/src/hub/Server.py +++ b/src/hub/Server.py @@ -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) diff --git a/src/hub/server_enrollment.py b/src/hub/server_enrollment.py index 202af53..0328b84 100644 --- a/src/hub/server_enrollment.py +++ b/src/hub/server_enrollment.py @@ -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") } diff --git a/src/hub/server_models.py b/src/hub/server_models.py index 0956f22..6dd25cf 100644 --- a/src/hub/server_models.py +++ b/src/hub/server_models.py @@ -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): diff --git a/src/hub/web_ui.py b/src/hub/web_ui.py index 9dcc3d1..0d38d72 100644 --- a/src/hub/web_ui.py +++ b/src/hub/web_ui.py @@ -455,6 +455,22 @@ class WebUIRenderer: """ + auto_update_lock_banner = "" if is_auto_update_unlocked else f""" +
+
+
🔒
+
+
Fleet Auto-Update is Inactive & Locked (Unlicensed)
+
Automated background forwarder binary upgrades and package deployment require an active license with the auto_update capability flag.
+
+
+ +
+ """ + + 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"""
@@ -632,6 +648,7 @@ class WebUIRenderer:
+ {auto_update_lock_banner}
@@ -711,25 +728,32 @@ class WebUIRenderer:
-
-
📤 Upload Client Binary Package
+
+
+
📤 Upload Client Binary Package
+ {('🔒 Auto-Update Locked' if not is_auto_update_unlocked else '')} +
+
+ ⚠️ + Warning: Uploaded binary packages are immediately distributed to connected edge forwarders for automatic hot-upgrades. Ensure binaries are thoroughly tested and verified before uploading. +
-
- +
- +
- +
@@ -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); diff --git a/tests/test_license_authority.py b/tests/test_license_authority.py index 1f27501..2cf2936 100644 --- a/tests/test_license_authority.py +++ b/tests/test_license_authority.py @@ -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 & 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__": diff --git a/tests/test_updates.py b/tests/test_updates.py index 81c379d..b44df3c 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -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",