""" LOGAR - Central Server Update & Fleet Distribution Manager Handles upstream release checking, update alerting, local client package hosting, and client auto-update check/download endpoints. """ import os import sys import json import hashlib import urllib.request import urllib.error from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Tuple try: from src.version import LOGAR_VERSION, is_newer_version, compare_versions except ImportError: from version import LOGAR_VERSION, is_newer_version, compare_versions class ServerUpdateManager: DEFAULT_GITEA_RELEASE_URL = "https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest" def __init__( self, update_dir: Optional[str] = None, upstream_url: Optional[str] = None, 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") self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL self.check_enabled = check_enabled self.auto_update_enabled = auto_update_enabled raw_mode = (update_mode or "official").lower() if raw_mode in ("official", "custom", "manual"): self.update_mode = raw_mode elif raw_mode == "auto": self.update_mode = "official" if (not upstream_url or upstream_url == self.DEFAULT_GITEA_RELEASE_URL) else "custom" else: self.update_mode = "official" # Cached upstream release state self.last_checked_utc: Optional[str] = None self.latest_upstream_version: Optional[str] = None self.server_update_available: bool = False self.release_title: Optional[str] = None self.release_notes: Optional[str] = None self.release_url: Optional[str] = None self.release_assets: List[Dict[str, Any]] = [] self.last_error: Optional[str] = None # Hosted client packages cache self.hosted_client_version: str = LOGAR_VERSION self._sha256_cache: Dict[str, Tuple[float, int, str]] = {} self._package_cache: Dict[str, Dict[str, Any]] = {} self._package_cache_mtimes: Dict[str, float] = {} self._ensure_package_structure() def _ensure_package_structure(self): """Initializes default subdirectories for OS-specific client packages.""" for os_name in ["windows", "linux"]: os.makedirs(os.path.join(self.update_dir, os_name), exist_ok=True) @classmethod def calculate_sha256(cls, filepath: str) -> str: """Computes the SHA-256 digest of a local file with stat-based memoization.""" if not os.path.exists(filepath): return "" try: stat = os.stat(filepath) mtime = stat.st_mtime size = stat.st_size if hasattr(cls, "_global_sha256_cache"): cached = cls._global_sha256_cache.get(filepath) if cached and cached[0] == mtime and cached[1] == size: return cached[2] except Exception: mtime = 0.0 size = 0 h = hashlib.sha256() with open(filepath, "rb") as f: while chunk := f.read(65536): h.update(chunk) digest = h.hexdigest().lower() if not hasattr(cls, "_global_sha256_cache"): cls._global_sha256_cache = {} cls._global_sha256_cache[filepath] = (mtime, size, digest) return digest def set_hosted_client_version(self, version_str: str): """Sets the version tag for currently hosted client packages.""" if version_str: self.hosted_client_version = version_str.strip().lstrip("v") self._package_cache.clear() def invalidate_package_cache(self, os_type: Optional[str] = None): """Clears in-memory package cache.""" if os_type: self._package_cache.pop(os_type.lower(), None) self._package_cache_mtimes.pop(os_type.lower(), None) else: self._package_cache.clear() self._package_cache_mtimes.clear() def register_client_package( self, os_type: str, file_path: str, version: Optional[str] = None, filename: Optional[str] = None ) -> Dict[str, Any]: """ Saves/copies a client binary package into the local update repository. Computes SHA-256 checksum and updates metadata. """ norm_os = os_type.lower() if norm_os not in ["windows", "linux"]: raise ValueError(f"Unsupported OS type: {os_type}") if not os.path.exists(file_path): raise FileNotFoundError(f"Package file not found: {file_path}") target_dir = os.path.join(self.update_dir, norm_os) os.makedirs(target_dir, exist_ok=True) raw_name = filename or os.path.basename(file_path) target_name = os.path.basename(raw_name) dest_path = os.path.join(target_dir, target_name) if os.path.abspath(file_path) != os.path.abspath(dest_path): with open(file_path, "rb") as src_f, open(dest_path, "wb") as dst_f: while chunk := src_f.read(65536): dst_f.write(chunk) sha256 = self.calculate_sha256(dest_path) pkg_version = version or self.hosted_client_version or LOGAR_VERSION self.set_hosted_client_version(pkg_version) # Write manifest file next to package manifest = { "os": norm_os, "filename": target_name, "version": pkg_version, "sha256": sha256, "size_bytes": os.path.getsize(dest_path), "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") } manifest_path = os.path.join(target_dir, "manifest.json") with open(manifest_path, "w", encoding="utf-8") as mf: json.dump(manifest, mf, indent=2) # Update cache directly cached_entry = dict(manifest) cached_entry["file_path"] = dest_path cached_entry["exists"] = True self._package_cache[norm_os] = cached_entry self._package_cache_mtimes[norm_os] = os.path.getmtime(dest_path) return manifest def get_hosted_packages(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: """ Returns info on currently hosted client packages for Windows and Linux. Uses in-memory cache validated against file modification times to minimize disk I/O. """ result: Dict[str, Dict[str, Any]] = {} for os_name in ["windows", "linux"]: manifest_path = os.path.join(self.update_dir, os_name, "manifest.json") target_file = None if not force_refresh and os_name in self._package_cache: cached = self._package_cache[os_name] tf = cached.get("file_path") if tf and os.path.exists(tf): try: cur_mtime = os.path.getmtime(tf) if cur_mtime == self._package_cache_mtimes.get(os_name): result[os_name] = cached continue except Exception: pass if os.path.exists(manifest_path): try: with open(manifest_path, "r", encoding="utf-8") as mf: info = json.load(mf) target_file = os.path.join(self.update_dir, os_name, info.get("filename", "")) if os.path.exists(target_file): info["exists"] = True info["file_path"] = target_file result[os_name] = info self._package_cache[os_name] = info self._package_cache_mtimes[os_name] = os.path.getmtime(target_file) continue except Exception: pass # Fallback scan in update_dir/{os_name} os_dir = os.path.join(self.update_dir, os_name) found_pkg = None if os.path.exists(os_dir): # Search by priority: .bin/.exe first, then archives, never .sh valid_exts = (".exe", ".msi", ".zip") if os_name == "windows" else (".bin", ".tar.gz", ".tgz") for f in sorted(os.listdir(os_dir), key=lambda x: (not x.endswith((".exe", ".bin")), x)): if f.endswith(valid_exts) and not f.startswith(".") and not f.endswith(".sh"): found_pkg = os.path.join(os_dir, f) break # Secondary fallback: search project dist/ if not found_pkg: dist_dir = os.path.join(os.path.dirname(self.update_dir), "dist") if os.path.exists(dist_dir): patterns = [ "LOGAR_Client_Setup.exe", "LOGAR-Client-Setup.exe", "Win_Client.exe", "LOGAR_Client.exe", "LOGAR-Windows-Client.zip" ] if os_name == "windows" else [ "Linux_Client.bin", "Linux_Client", "LOGAR_Client_Linux", "LOGAR-Linux-Client.tar.gz" ] for pat in patterns: candidate = os.path.join(dist_dir, pat) if os.path.exists(candidate): found_pkg = candidate break if found_pkg and os.path.exists(found_pkg): sha256 = self.calculate_sha256(found_pkg) pkg_data = { "os": os_name, "filename": os.path.basename(found_pkg), "version": self.hosted_client_version or LOGAR_VERSION, "sha256": sha256, "size_bytes": os.path.getsize(found_pkg), "file_path": found_pkg, "exists": True, "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") } result[os_name] = pkg_data self._package_cache[os_name] = pkg_data self._package_cache_mtimes[os_name] = os.path.getmtime(found_pkg) else: pkg_data = { "os": os_name, "filename": None, "version": self.hosted_client_version or LOGAR_VERSION, "sha256": "", "size_bytes": 0, "file_path": None, "exists": False } result[os_name] = pkg_data self._package_cache[os_name] = pkg_data self._package_cache_mtimes[os_name] = 0.0 return result def get_client_package_path(self, os_type: str) -> Optional[Tuple[str, str, str]]: """ Returns (file_path, filename, sha256) for downloading a client update package. """ packages = self.get_hosted_packages() pkg_info = packages.get(os_type.lower()) if pkg_info and pkg_info.get("exists") and pkg_info.get("file_path"): return pkg_info["file_path"], pkg_info["filename"], pkg_info["sha256"] return None def check_client_update( self, client_id: str, os_type: str, current_version: Optional[str], arch: str = "x86_64" ) -> Dict[str, Any]: """ Evaluates whether a client requires an update based on its reported version and the server's hosted client package version. """ norm_os = (os_type or "windows").lower() packages = self.get_hosted_packages() pkg = packages.get(norm_os, {}) target_version = pkg.get("version") or self.hosted_client_version or LOGAR_VERSION has_pkg = bool(pkg.get("exists")) client_ver = (current_version or "0.0.0.0").strip().lstrip("v") # 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 { "update_available": update_available, "client_id": client_id, "os": norm_os, "current_version": client_ver, "latest_version": target_version, "sha256": pkg.get("sha256", "") if update_available else "", "download_url": f"/api/client/update/download?os={norm_os}" if update_available else "", "size_bytes": pkg.get("size_bytes", 0) if update_available else 0, "filename": pkg.get("filename", "") if update_available else "", "release_notes": self.release_notes or f"LOGAR {target_version} update for {norm_os.capitalize()}.", "mandatory": False } def download_and_cache_client_packages( self, assets: Optional[List[Dict[str, Any]]] = None, target_version: Optional[str] = None, timeout: int = 60 ) -> Dict[str, Any]: """ Downloads OS client binary packages from upstream release assets and caches them locally. Ensures the Hub acts as a caching distribution point so edge clients don't contact external servers. """ release_assets = assets if assets is not None else self.release_assets version = target_version or self.latest_upstream_version or self.hosted_client_version or LOGAR_VERSION results = {"windows": False, "linux": False, "downloaded": []} if not release_assets: return results temp_dir = os.path.join(self.update_dir, "temp") os.makedirs(temp_dir, exist_ok=True) for asset in release_assets: name = (asset.get("name") or "").lower() download_url = asset.get("download_url") if not download_url or name.endswith(".sh") or "server" in name or "checksum" in name or "sha256" in name: continue target_os = None if ("win" in name or "setup" in name or name.endswith((".exe", ".msi", ".zip"))) and "client" in name and not results["windows"]: target_os = "windows" elif ("win" in name or name.endswith(".exe")) and not results["windows"]: target_os = "windows" elif ("linux" in name or name.endswith((".bin", ".tar.gz", ".tgz"))) and ("client" in name or "agent" in name) and not results["linux"]: target_os = "linux" elif name in ("linux_client.bin", "linux_client", "logar_client_linux") and not results["linux"]: target_os = "linux" if not target_os: continue # Check if matching version is already cached locally existing_pkg = self.get_hosted_packages().get(target_os) if existing_pkg and existing_pkg.get("exists") and existing_pkg.get("version") == version: results[target_os] = True continue filename = os.path.basename(asset.get("name") or f"client_{target_os}") temp_file = os.path.join(temp_dir, filename) try: print(f"[*] Fleet Hub caching upstream client package for {target_os}: {filename} ({download_url})...") req = urllib.request.Request( download_url, headers={"User-Agent": f"LOGAR-Server-Hub/{LOGAR_VERSION}"} ) with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status == 200: with open(temp_file, "wb") as f: while chunk := resp.read(65536): f.write(chunk) # Register package in Hub repository manifest = self.register_client_package( os_type=target_os, file_path=temp_file, version=version, filename=filename ) results[target_os] = True results["downloaded"].append({ "os": target_os, "filename": filename, "version": version, "sha256": manifest.get("sha256") }) print(f"[+] Successfully cached {target_os} client package v{version} (SHA-256: {manifest.get('sha256')[:12]}...).") except Exception as e: print(f"[!] Warning: Failed to download and cache upstream client asset '{filename}': {e}") finally: if os.path.exists(temp_file): try: os.remove(temp_file) except Exception: pass if results["downloaded"]: self.hosted_client_version = version 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 when in automatic acquisition mode. """ if not self.check_enabled and not force: return self.get_status() now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") self.last_checked_utc = now_utc self.last_error = None try: req = urllib.request.Request( self.upstream_url, headers={ "User-Agent": f"LOGAR-Server/{LOGAR_VERSION}", "Accept": "application/json" } ) with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status == 200: data = json.loads(resp.read().decode("utf-8")) raw_tag = data.get("tag_name") or data.get("name") or data.get("version") or "" upstream_ver = raw_tag.strip().lstrip("v") self.latest_upstream_version = upstream_ver self.release_title = data.get("name") or raw_tag self.release_notes = data.get("body") or data.get("notes") or "" self.release_url = data.get("html_url") or data.get("url") or "" # Process assets list assets = [] for a in data.get("assets", []): assets.append({ "name": a.get("name"), "size": a.get("size"), "download_url": a.get("browser_download_url") or a.get("download_url") }) self.release_assets = assets # Compare version against current LOGAR_VERSION self.server_update_available = is_newer_version(upstream_ver, LOGAR_VERSION) 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 in official or custom repo mode should_auto_download = auto_download_clients and (self.update_mode in ("official", "custom", "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")) if needs_win or needs_linux or force: self.download_and_cache_client_packages(assets=assets, target_version=upstream_ver) else: self.last_error = f"Upstream returned HTTP status {resp.status}" except Exception as e: self.last_error = f"Failed to check upstream updates: {e}" return self.get_status() def get_status(self) -> Dict[str, Any]: """Returns structured update status for WebUI and REST APIs.""" return { "current_server_version": LOGAR_VERSION, "latest_upstream_version": self.latest_upstream_version or LOGAR_VERSION, "server_update_available": self.server_update_available, "hosted_client_version": self.hosted_client_version or LOGAR_VERSION, "release_title": self.release_title or "", "release_notes": self.release_notes or "", "release_url": self.release_url or "", "release_assets": self.release_assets, "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() }