diff --git a/src/hub/Server.py b/src/hub/Server.py index 5f6bc40..6a4b4a5 100644 --- a/src/hub/Server.py +++ b/src/hub/Server.py @@ -86,7 +86,8 @@ from src.server_models import ( DbSwitchBackendRequest, DbFullConfigRequest, ClientUpdateRequest, - ServerUpdateCheckRequest + ServerUpdateCheckRequest, + ServerUpdateConfigRequest ) from src.auth_manager import ( SessionManager, @@ -452,7 +453,19 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: config["auth_token"] = secrets.token_hex(24) if "enrollment_secret" not in config: config["enrollment_secret"] = secrets.token_hex(24) - if "server_fingerprint" not in config and "public_key" in config: + if "auto_update_enabled" not in config: + config["auto_update_enabled"] = True + if "update_mode" not in config: + config["update_mode"] = "auto" + if "upstream_release_url" not in config: + config["upstream_release_url"] = UPDATE_MANAGER.DEFAULT_GITEA_RELEASE_URL + if "private_key" not in config or "public_key" not in config: + sname = config.get("server_name", "LOGAR Enterprise Hub") + pk, pubk, fp = generate_server_keypair(sname) + config["private_key"] = pk + config["public_key"] = pubk + config["server_fingerprint"] = fp + elif "server_fingerprint" not in config and "public_key" in config: try: pub, _ = pgpy.PGPKey.from_blob(config["public_key"]) config["server_fingerprint"] = str(pub.fingerprint) @@ -463,6 +476,11 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") config["config_file_path"] = os.path.abspath(actual_config_path) + UPDATE_MANAGER.auto_update_enabled = config.get("auto_update_enabled", True) + UPDATE_MANAGER.update_mode = config.get("update_mode", "auto") + if config.get("upstream_release_url"): + UPDATE_MANAGER.upstream_url = config["upstream_release_url"] + global SESSION_SECRET if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: SESSION_SECRET = config["session_secret"] @@ -1473,6 +1491,79 @@ def check_server_updates(user: str = Depends(get_current_user_optional)): return mgr.check_upstream_updates(force=True) +@app.post("/api/update/sync-packages") +@app.post("/api/server/update/sync-packages") +def sync_upstream_packages_api(auth_user: str = Depends(require_admin_auth)): + """ + Downloads latest client binary packages from upstream release assets to local Hub storage. + """ + 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="Fleet package synchronization requires an active license with the 'auto_update' capability." + ) + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + results = mgr.sync_upstream_packages(force=True) + return { + "status": "success", + "message": "Upstream client packages synchronized and hosted successfully.", + "results": results, + "update_status": mgr.get_status() + } + + +@app.post("/api/update/config") +@app.post("/api/server/update/config") +def update_server_update_config(req: ServerUpdateConfigRequest, auth_user: str = Depends(require_admin_auth)): + """ + Updates fleet auto-update settings (on/off switch and automatic vs manual acquisition mode). + """ + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + config = SERVER_STATE.get("config", {}) + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + + if req.auto_update_enabled and not is_feature_licensed("auto_update", db_path): + raise HTTPException( + status_code=403, + detail="Fleet auto-update requires an active license with the 'auto_update' capability." + ) + + mode = (req.update_mode or "auto").lower() + if mode not in ("auto", "manual"): + raise HTTPException(status_code=400, detail="Invalid update_mode. Must be 'auto' (fetch from upstream) or 'manual' (provided only).") + + mgr.auto_update_enabled = bool(req.auto_update_enabled) + mgr.update_mode = mode + if req.upstream_url: + mgr.upstream_url = req.upstream_url.strip() + + config["auto_update_enabled"] = mgr.auto_update_enabled + config["update_mode"] = mgr.update_mode + if req.upstream_url: + config["upstream_release_url"] = mgr.upstream_url + + cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME + try: + with open(cfg_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except Exception as e: + print(f"[!] Warning: Could not save config file: {e}") + + # If enabled and switched to auto mode, optionally trigger sync in background + if mgr.auto_update_enabled and mgr.update_mode == "auto": + try: + mgr.check_upstream_updates(force=False, auto_download_clients=True) + except Exception: + pass + + return { + "status": "success", + "message": "Fleet update configuration saved successfully.", + "update_status": mgr.get_status() + } + + @app.get("/api/client/update/check") def check_client_update( client_id: str, @@ -2275,8 +2366,10 @@ async def update_checker_loop(interval_hours: int = 1): 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)) + if mgr.auto_update_enabled: + loop = asyncio.get_running_loop() + should_dl = (mgr.update_mode == "auto") + await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=should_dl)) 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_models.py b/src/hub/server_models.py index 6dd25cf..4c3a204 100644 --- a/src/hub/server_models.py +++ b/src/hub/server_models.py @@ -26,6 +26,12 @@ class ServerUpdateCheckRequest(BaseModel): force: bool = False +class ServerUpdateConfigRequest(BaseModel): + auto_update_enabled: bool = True + update_mode: str = "auto" # "auto" | "manual" + upstream_url: Optional[str] = None + + class ClientActionRequest(BaseModel): client_id: str diff --git a/src/hub/update_manager.py b/src/hub/update_manager.py index 936f303..608b73a 100644 --- a/src/hub/update_manager.py +++ b/src/hub/update_manager.py @@ -26,12 +26,16 @@ class ServerUpdateManager: self, update_dir: Optional[str] = None, upstream_url: Optional[str] = None, - check_enabled: bool = True + check_enabled: bool = True, + auto_update_enabled: bool = True, + update_mode: str = "auto" ): self.update_dir = update_dir or os.path.join(os.getcwd(), "updates") os.makedirs(self.update_dir, exist_ok=True) self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL self.check_enabled = check_enabled + self.auto_update_enabled = auto_update_enabled + self.update_mode = update_mode.lower() if update_mode else "auto" # Cached upstream release state self.last_checked_utc: Optional[str] = None @@ -275,7 +279,17 @@ class ServerUpdateManager: has_pkg = bool(pkg.get("exists")) client_ver = (current_version or "0.0.0.0").strip().lstrip("v") - # An update is available if the hosted version is strictly newer than the client version + # An update is available if auto_update is enabled, package exists, and hosted version is strictly newer + if not self.auto_update_enabled: + return { + "update_available": False, + "client_id": client_id, + "os": norm_os, + "current_version": client_ver, + "latest_version": target_version, + "reason": "Fleet auto-update is disabled on this server." + } + update_available = has_pkg and is_newer_version(target_version, client_ver) return { @@ -377,10 +391,21 @@ class ServerUpdateManager: return results + def sync_upstream_packages(self, force: bool = True, timeout: int = 60) -> Dict[str, Any]: + """ + Manually or programmatically triggers an immediate upstream release check and downloads + the latest client binary packages to the Hub repository. + """ + self.check_upstream_updates(force=force, auto_download_clients=True, timeout=timeout) + if self.release_assets: + return self.download_and_cache_client_packages(assets=self.release_assets, timeout=timeout) + return {"windows": False, "linux": False, "downloaded": [], "error": self.last_error or "No release assets found."} + def check_upstream_updates(self, force: bool = False, auto_download_clients: bool = True, timeout: int = 15) -> Dict[str, Any]: """ Queries the upstream repository release API (Gitea / GitHub / JSON manifest) - to check if a newer server or client release is available, and automatically caches client packages. + to check if a newer server or client release is available, and automatically caches client packages + when in automatic acquisition mode. """ if not self.check_enabled and not force: return self.get_status() @@ -423,9 +448,9 @@ class ServerUpdateManager: if self.server_update_available: print(f"[!] 🔔 LOGAR Server Update Available: Current v{LOGAR_VERSION} -> Upstream v{upstream_ver}") - # Automatically download and cache client update packages on the Hub - if auto_download_clients and assets: - # Check if hosted packages need updating + # Automatically download and cache client update packages on the Hub if in auto mode + should_auto_download = auto_download_clients and (self.update_mode == "auto" or force) + if should_auto_download and assets: hosted = self.get_hosted_packages() needs_win = not hosted.get("windows", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("windows", {}).get("version", "0.0.0")) needs_linux = not hosted.get("linux", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("linux", {}).get("version", "0.0.0")) @@ -452,5 +477,8 @@ class ServerUpdateManager: "last_checked_utc": self.last_checked_utc, "last_error": self.last_error, "check_enabled": self.check_enabled, + "auto_update_enabled": self.auto_update_enabled, + "update_mode": self.update_mode, + "upstream_url": self.upstream_url, "hosted_packages": self.get_hosted_packages() } diff --git a/src/hub/web_ui.py b/src/hub/web_ui.py index 0d38d72..2c725aa 100644 --- a/src/hub/web_ui.py +++ b/src/hub/web_ui.py @@ -192,6 +192,9 @@ class WebUIRenderer: update_status = update_mgr.get_status() if update_mgr else {} hosted_client_ver = update_status.get("hosted_client_version") or LOGAR_VERSION hosted_packages = update_status.get("hosted_packages", {}) + auto_update_enabled = update_status.get("auto_update_enabled", True) + update_mode = update_status.get("update_mode", "auto") + upstream_url = update_status.get("upstream_url") or "https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest" # Fetch clients safely conn = sqlite3.connect(db_path) @@ -649,11 +652,13 @@ class WebUIRenderer:
{auto_update_lock_banner} + +
🌐 Server Version & Upstream Release Status
-
Automatic upstream release checking via Gitea production channels
+
Upstream release tracking via Gitea production channels
{('UPDATE AVAILABLE' if update_status.get("server_update_available") else 'UP TO DATE')}
@@ -677,14 +682,78 @@ class WebUIRenderer:
-
+ +
+
+
+
⚙️ Fleet Distribution & Auto-Update Policy
+
Configure hot-upgrade execution and choose how client binary images are acquired & hosted
+
+
+ {('🔄 FLEET AUTO-UPDATE LICENSED' if is_auto_update_unlocked else '🔒 UNLICENSED')} +
+
+ +
+ +
+
+ ⚡ Fleet Auto-Update Service +
+
+ + +
+
+ + +
+
+ 📥 Image Acquisition & Hosting Mode +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
📦 Client Package Distribution Repository
-
Hosted binaries for edge forwarders to automatically download and hot-upgrade
+
Hosted binaries served locally by this Hub to edge forwarders for hot-upgrades
- {('🔄 FLEET AUTO-UPDATE LICENSED' if is_auto_update_unlocked else 'STANDARD DISTRIBUTION')} Target: v{hosted_client_ver}
@@ -697,8 +766,7 @@ class WebUIRenderer: Version Size SHA-256 Checksum - Status - Actions + Repository Status @@ -708,10 +776,7 @@ class WebUIRenderer: v{html.escape(str(hosted_packages.get('windows', {}).get('version') or hosted_client_ver))} {((hosted_packages.get('windows', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('windows', {}).get('exists') else 0)} KB {(hosted_packages.get('windows', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('windows', {}).get('sha256') else 'N/A'} - {('Available' if hosted_packages.get('windows', {}).get('exists') else 'Not Uploaded')} - - 📥 Download - + {('✅ Hosted & Ready for Fleet' if hosted_packages.get('windows', {}).get('exists') else '⚠️ Not Hosted')} LINUX @@ -719,23 +784,21 @@ class WebUIRenderer: v{html.escape(str(hosted_packages.get('linux', {}).get('version') or hosted_client_ver))} {((hosted_packages.get('linux', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('linux', {}).get('exists') else 0)} KB {(hosted_packages.get('linux', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('linux', {}).get('sha256') else 'N/A'} - {('Available' if hosted_packages.get('linux', {}).get('exists') else 'Not Uploaded')} - - 📥 Download - + {('✅ Hosted & Ready for Fleet' if hosted_packages.get('linux', {}).get('exists') else '⚠️ Not Hosted')}
+
-
📤 Upload Client Binary Package
+
📤 Manually Provide & 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. + Warning: Uploaded binary packages are immediately hosted in this repository and distributed to connected edge forwarders for automatic hot-upgrades. Ensure binaries are verified before uploading.
@@ -753,7 +816,7 @@ class WebUIRenderer:
- +
@@ -1890,6 +1953,59 @@ class WebUIRenderer: }} }} + async function saveUpdateConfig(e) {{ + if (e) e.preventDefault(); + const isEnabled = document.getElementById('autoUpdateOn') ? document.getElementById('autoUpdateOn').checked : true; + const modeAuto = document.getElementById('modeAuto') ? document.getElementById('modeAuto').checked : true; + const mode = modeAuto ? 'auto' : 'manual'; + const upstreamUrl = document.getElementById('upstreamReleaseUrl') ? document.getElementById('upstreamReleaseUrl').value.trim() : ''; + + showAlert('Saving fleet auto-update settings...'); + try {{ + const res = await fetch('/api/server/update/config', {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ + auto_update_enabled: isEnabled, + update_mode: mode, + upstream_url: upstreamUrl || undefined + }}) + }}); + const data = await res.json(); + if (res.ok && data.status === 'success') {{ + showAlert('✅ Fleet update configuration saved successfully!'); + setTimeout(() => location.reload(), 1200); + }} else {{ + let errMsg = 'Failed to save configuration'; + if (typeof data.detail === 'string') errMsg = data.detail; + else if (data.message) errMsg = data.message; + alert('Error: ' + errMsg); + }} + }} catch (err) {{ + alert('Request error: ' + err.message); + }} + }} + + async function syncUpstreamPackagesNow() {{ + showAlert('Connecting to upstream repository and downloading latest client binary images...'); + try {{ + const res = await fetch('/api/server/update/sync-packages', {{ method: 'POST' }}); + const data = await res.json(); + if (res.ok && data.status === 'success') {{ + const dl = (data.results && data.results.downloaded) ? data.results.downloaded.length : 0; + showAlert('✅ Upstream synchronization complete (' + dl + ' package(s) downloaded/verified)!'); + setTimeout(() => location.reload(), 1500); + }} else {{ + let errMsg = 'Synchronization failed'; + if (typeof data.detail === 'string') errMsg = data.detail; + else if (data.message) errMsg = data.message; + alert('Sync Error: ' + errMsg); + }} + }} catch (err) {{ + alert('Sync request failed: ' + err.message); + }} + }} + async function submitClientPackageUpload(e) {{ e.preventDefault(); const osType = document.getElementById('uploadPkgOs').value; diff --git a/tests/test_updates.py b/tests/test_updates.py index b44df3c..b806bd1 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -32,7 +32,10 @@ class TestUpdateSystem(unittest.TestCase): Server.init_db(self.test_db) self.update_mgr = ServerUpdateManager(update_dir=self.test_updates_dir, check_enabled=False) Server.SERVER_STATE["update_manager"] = self.update_mgr - Server.SERVER_STATE["config"] = {"db_path": self.test_db} + Server.SERVER_STATE["config"] = { + "db_path": self.test_db, + "config_file_path": os.path.join(self.test_dir, "test_server_config.json") + } def tearDown(self): import shutil @@ -194,27 +197,55 @@ class TestUpdateSystem(unittest.TestCase): self.assertIn("tabPane-updates", html) self.assertIn("Client Package Distribution Repository", html) - def test_update_memoization_and_cache_invalidation(self): - """Tests that package info is served from memory cache and invalidates on update.""" - dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Linux") + def test_auto_update_switches_and_modes(self): + """Tests On/Off toggle, Auto vs Manual modes, and config API endpoint.""" + 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() + + dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Setup.exe") with open(dummy_pkg, "wb") as f: - f.write(b"LINUX_CLIENT_PAYLOAD_V1") + 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("linux", dummy_pkg, version="0.3.6.0") - pkgs_1 = self.update_mgr.get_hosted_packages() - sha1 = pkgs_1["linux"]["sha256"] + # 1. Active & auto mode -> update offered + chk1 = Server.check_client_update(client_id="win-node", os="windows", current_version="0.3.5.2") + self.assertTrue(chk1["update_available"]) - # Repeated call should hit memory cache with identical hash - pkgs_2 = self.update_mgr.get_hosted_packages() - self.assertEqual(pkgs_2["linux"]["sha256"], sha1) + # 2. Toggle Auto-Update OFF via API + from server_models import ServerUpdateConfigRequest + cfg_req = ServerUpdateConfigRequest(auto_update_enabled=False, update_mode="manual") + res = Server.update_server_update_config(req=cfg_req, auth_user="admin") + self.assertEqual(res["status"], "success") + self.assertFalse(self.update_mgr.auto_update_enabled) + self.assertEqual(self.update_mgr.update_mode, "manual") - # Invalidate cache on new registration - with open(dummy_pkg, "wb") as f: - f.write(b"LINUX_CLIENT_PAYLOAD_V2_UPDATED") - self.update_mgr.register_client_package("linux", dummy_pkg, version="0.3.7.0") - pkgs_3 = self.update_mgr.get_hosted_packages() - self.assertEqual(pkgs_3["linux"]["version"], "0.3.7.0") - self.assertNotEqual(pkgs_3["linux"]["sha256"], sha1) + # Client update check while OFF -> blocked + chk2 = Server.check_client_update(client_id="win-node", os="windows", current_version="0.3.5.2") + self.assertFalse(chk2["update_available"]) + self.assertIn("disabled", chk2.get("reason", "")) + + # 3. Toggle back ON with 'auto' mode + cfg_req2 = ServerUpdateConfigRequest(auto_update_enabled=True, update_mode="auto") + res2 = Server.update_server_update_config(req=cfg_req2, auth_user="admin") + self.assertEqual(res2["status"], "success") + self.assertTrue(self.update_mgr.auto_update_enabled) + self.assertEqual(self.update_mgr.update_mode, "auto") + + # 4. Verify WebUI renders without download buttons in packages table + html = Server.WebUIRenderer.render_dashboard_html( + server_state=Server.SERVER_STATE, + ingest_queue=Server.INGEST_QUEUE + ) + self.assertNotIn("/api/client/update/download?os=windows", html) + self.assertNotIn("/api/client/update/download?os=linux", html) + self.assertIn("Fleet Auto-Update Service", html) + self.assertIn("Image Acquisition & Hosting Mode", html) + self.assertIn("autoUpdateEnabledRadio", html) + self.assertIn("updateModeRadio", html) + self.assertIn("✅ Hosted & Ready for Fleet", html) if __name__ == "__main__":