diff --git a/src/Linux_Client.py b/src/Linux_Client.py index c6528d6..76da2bd 100644 --- a/src/Linux_Client.py +++ b/src/Linux_Client.py @@ -42,7 +42,8 @@ from client_transport import ( get_tls_socket, load_config, ensure_enrolled as transport_ensure_enrolled, - send_encrypted_logs_over_socket as transport_send_encrypted_logs + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update ) CONFIG_FILE_NAME = "client_config.json" @@ -103,6 +104,12 @@ def main(): # Continuously verify/renew PKI enrollment if server is licensed & online ensure_enrolled(config, machine_id) + # Periodically check server for client auto-updates + try: + check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="linux") + except Exception as update_err: + pass + print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") candidate_logs = collector.collect_logs(hours=args.hours) print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") diff --git a/src/Server.py b/src/Server.py index 503d81e..5d7ce39 100644 --- a/src/Server.py +++ b/src/Server.py @@ -41,7 +41,7 @@ warnings.filterwarnings("ignore") import pgpy import ssl from fastapi import FastAPI, HTTPException, Request, Depends -from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse, FileResponse import uvicorn try: @@ -49,6 +49,13 @@ try: except ImportError: import server_enrollment as enrollment +try: + from src.version import LOGAR_VERSION, is_newer_version, compare_versions + from src.update_manager import ServerUpdateManager +except ImportError: + from version import LOGAR_VERSION, is_newer_version, compare_versions + from update_manager import ServerUpdateManager + try: from src.log_collector import ( get_machine_identifier, @@ -77,7 +84,9 @@ from src.server_models import ( ChangePasswordRequest, DbConnectionTestRequest, DbSwitchBackendRequest, - DbFullConfigRequest + DbFullConfigRequest, + ClientUpdateRequest, + ServerUpdateCheckRequest ) from src.auth_manager import ( SessionManager, @@ -102,10 +111,13 @@ SESSION_COOKIE_NAME = SessionManager.SESSION_COOKIE_NAME DEFAULT_ADMIN_USER = StorageEngine.DEFAULT_ADMIN_USER DEFAULT_ADMIN_PASS = StorageEngine.DEFAULT_ADMIN_PASS -app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="3.0.0") +app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version=LOGAR_VERSION) # Global context holding server state -SERVER_STATE: Dict[str, Any] = {} +UPDATE_MANAGER = ServerUpdateManager() +SERVER_STATE: Dict[str, Any] = { + "update_manager": UPDATE_MANAGER +} # Global rate limiter & ingestion queue RATE_LIMITER = RateLimiter() @@ -899,18 +911,33 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i processed_count += 1 - # Update client node metrics if attached + # Update client node metrics & version if attached metrics = payload.get("metrics") + client_ver = payload.get("version") if metrics and isinstance(metrics, dict) and client_server: cpu_p = float(metrics.get("cpu_percent", 0.0)) mem_p = float(metrics.get("memory_percent", 0.0)) disk_p = float(metrics.get("disk_percent_free", 0.0)) + try: + if client_ver: + cursor.execute(""" + UPDATE clients + SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, client_version = ? + WHERE client_id = ? OR hostname = ? + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_ver, client_server, client_server)) + else: + cursor.execute(""" + UPDATE clients + SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? + WHERE client_id = ? OR hostname = ? + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + except Exception: + pass + elif client_ver and client_server: try: cursor.execute(""" - UPDATE clients - SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? - WHERE client_id = ? OR hostname = ? - """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + UPDATE clients SET last_seen = ?, client_version = ? WHERE client_id = ? OR hostname = ? + """, (now_iso, client_ver, client_server, client_server)) except Exception: pass @@ -1336,17 +1363,31 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): fp = enrollment.calculate_cert_fingerprint(cert_pem) now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + client_ver = req.version or "" if existing: - c.execute(""" - UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? - WHERE client_id = ? - """, (req.hostname, req.os, fp, now_sql, req.client_id)) + try: + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? + WHERE client_id = ? + """, (req.hostname, req.os, fp, now_sql, client_ver, req.client_id)) + except sqlite3.OperationalError: + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? + WHERE client_id = ? + """, (req.hostname, req.os, fp, now_sql, req.client_id)) else: - c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) - VALUES (?, ?, ?, ?, 'active', ?, ?) - """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) + try: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?) + """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) + except sqlite3.OperationalError: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) + VALUES (?, ?, ?, ?, 'active', ?, ?) + """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) conn.commit() conn.close() @@ -1400,6 +1441,150 @@ def reset_server_enrollment_secret(auth_user: str = Depends(require_admin_auth)) } +# ===================================================================== +# Update & Distribution Endpoints +# ===================================================================== + +@app.get("/api/update/status") +@app.get("/api/server/update/status") +def get_update_status(user: str = Depends(get_current_user_optional)): + """Returns current server update and client package distribution repository status.""" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + return mgr.get_status() + + +@app.post("/api/update/check") +@app.post("/api/server/update/check") +def check_server_updates(user: str = Depends(get_current_user_optional)): + """Triggers an immediate upstream release check.""" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + return mgr.check_upstream_updates(force=True) + + +@app.get("/api/client/update/check") +def check_client_update( + client_id: str, + os: Optional[str] = "windows", + os_name: Optional[str] = None, + current_version: Optional[str] = None, + arch: str = "x86_64" +): + """ + Invoked by edge forwarders to check whether a newer client binary package is available. + """ + import os as _os + target_os = os_name or os or "windows" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + + # Record check timestamp in database + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if target_os: + now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + try: + conn = sqlite3.connect(db_path) + conn.execute(""" + UPDATE clients SET last_update_check = ?, client_version = COALESCE(NULLIF(?, ''), client_version) + WHERE client_id = ? + """, (now_sql, current_version or "", client_id)) + conn.commit() + conn.close() + except Exception: + pass + + return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) + + +@app.get("/api/client/update/download") +def download_client_update_package(os: Optional[str] = "windows", os_name: Optional[str] = None): + """ + Streams the latest client binary or installer package for the specified OS. + """ + import os as _os + target_os = os_name or os or "windows" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + pkg_tuple = mgr.get_client_package_path(os_type=target_os) + if not pkg_tuple: + raise HTTPException(status_code=404, detail=f"No client update package available for OS: {target_os}") + + file_path, filename, sha256 = pkg_tuple + if not _os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Client package file missing on server disk.") + + return FileResponse( + path=file_path, + filename=filename, + media_type="application/octet-stream", + headers={ + "X-Checksum-SHA256": sha256, + "Content-Disposition": f'attachment; filename="{filename}"' + } + ) + + +@app.post("/api/server/client-package/upload") +async def upload_client_package( + request: Request, + user: str = Depends(get_current_user_optional) +): + """ + Uploads a new client executable/installer into the server's update distribution repository. + Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." } + or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'. + """ + import base64 + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + content_type = request.headers.get("content-type", "") + + os_type = "windows" + version = None + filename = "client_update.bin" + file_bytes = b"" + + if "application/json" in content_type: + body = await request.json() + os_type = body.get("os", "windows") + version = body.get("version") + filename = body.get("filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") + b64_data = body.get("data_base64", "") + file_bytes = base64.b64decode(b64_data) + else: + os_type = request.headers.get("X-Package-OS", "windows") + version = request.headers.get("X-Package-Version") + filename = request.headers.get("X-Package-Filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") + file_bytes = await request.body() + + if not file_bytes: + raise HTTPException(status_code=400, detail="Empty package payload.") + + temp_dir = os.path.join(mgr.update_dir, "temp") + os.makedirs(temp_dir, exist_ok=True) + temp_path = os.path.join(temp_dir, filename) + + with open(temp_path, "wb") as f: + f.write(file_bytes) + + try: + manifest = mgr.register_client_package( + os_type=os_type, + file_path=temp_path, + version=version, + filename=filename + ) + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + return {"status": "success", "manifest": manifest, "sha256": manifest.get("sha256")} + except Exception as e: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + raise HTTPException(status_code=400, detail=f"Package upload registration failed: {e}") + + @app.get("/api/clients") def list_clients(auth_user: str = Depends(require_admin_auth)): """Returns all registered clients, seat usage, and activity timestamps.""" diff --git a/src/Win_Client.py b/src/Win_Client.py index 4821275..dfd1008 100644 --- a/src/Win_Client.py +++ b/src/Win_Client.py @@ -47,7 +47,8 @@ from client_transport import ( get_tls_socket, load_config, ensure_enrolled as transport_ensure_enrolled, - send_encrypted_logs_over_socket as transport_send_encrypted_logs + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update ) CONFIG_FILE_NAME = "client_config.json" @@ -108,6 +109,12 @@ def main(): # Continuously verify/renew PKI enrollment if server is licensed & online ensure_enrolled(config, machine_id) + # Periodically check server for client auto-updates + try: + check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="windows") + except Exception as update_err: + pass + print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...") candidate_logs = collector.collect_logs(hours=args.hours) print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") diff --git a/src/client_transport.py b/src/client_transport.py index eea0408..a50e98d 100644 --- a/src/client_transport.py +++ b/src/client_transport.py @@ -26,6 +26,12 @@ from log_collector import ( resolve_writable_path, collect_system_metrics ) +try: + from src.version import LOGAR_VERSION + from src.client_updater import ClientUpdater +except ImportError: + from version import LOGAR_VERSION + from client_updater import ClientUpdater CONFIG_FILE_NAME = "client_config.json" @@ -75,7 +81,8 @@ def enroll_client_if_needed( "client_id": client_id, "hostname": hostname, "os": os_type, - "enrollment_secret": enrollment_secret + "enrollment_secret": enrollment_secret, + "version": LOGAR_VERSION } req = urllib.request.Request( enroll_endpoint, @@ -249,7 +256,8 @@ def send_encrypted_logs_over_socket( "server": actual_machine_id, "timestamp": datetime.now(timezone.utc).isoformat(), "logs": logs, - "metrics": collect_system_metrics() + "metrics": collect_system_metrics(), + "version": LOGAR_VERSION } payload_bytes = json.dumps(payload).encode("utf-8") frame = struct.pack(">I", len(payload_bytes)) + payload_bytes @@ -286,7 +294,8 @@ def send_encrypted_logs_over_socket( "server": actual_machine_id, "timestamp": datetime.now(timezone.utc).isoformat(), "logs": logs, - "metrics": collect_system_metrics() + "metrics": collect_system_metrics(), + "version": LOGAR_VERSION } payload_json = json.dumps(payload) pgp_msg = pgpy.PGPMessage.new(payload_json) @@ -323,3 +332,9 @@ def send_encrypted_logs_over_socket( response = json.loads(resp_bytes.decode("utf-8")) print(f"[+] Server response: {response}") return response + + +def check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]: + """Helper to check and apply auto updates for edge forwarders.""" + updater = ClientUpdater(config=config, os_type=os_type) + return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply) diff --git a/src/client_updater.py b/src/client_updater.py new file mode 100644 index 0000000..2cbed4f --- /dev/null +++ b/src/client_updater.py @@ -0,0 +1,221 @@ +""" +LOGAR - Client Auto-Update Agent Module +Handles checking the LOGAR Server Hub for client updates, downloading verified binaries (SHA-256), +and applying atomic in-place updates across Windows and Linux forwarders. +""" + +import os +import sys +import json +import time +import shutil +import hashlib +import tempfile +import subprocess +import urllib.request +import urllib.error +from typing import Dict, Any, Optional, Tuple + +try: + from src.version import LOGAR_VERSION, is_newer_version +except ImportError: + from version import LOGAR_VERSION, is_newer_version + + +class ClientUpdater: + def __init__(self, config: dict, os_type: Optional[str] = None): + self.config = config + self.os_type = (os_type or ("windows" if sys.platform == "win32" else "linux")).lower() + self.current_version = LOGAR_VERSION + + @staticmethod + def calculate_sha256(filepath: str) -> str: + """Computes SHA-256 digest of a local file.""" + if not os.path.exists(filepath): + return "" + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest().lower() + + def get_server_base_url(self) -> str: + """Resolves the HTTP base URL for server management endpoints.""" + server_host = self.config.get("server_host", "127.0.0.1") + hermes_host = self.config.get("hermes_host", server_host) + if hermes_host in ("0.0.0.0", "::", None, ""): + hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1" + hermes_port = int(self.config.get("hermes_port", 8443)) + return f"http://{hermes_host}:{hermes_port}" + + def check_for_update(self, client_id: str, timeout: int = 6) -> Dict[str, Any]: + """ + Queries the server Hub to check if a newer client binary is available. + """ + base_url = self.get_server_base_url() + url = ( + f"{base_url}/api/client/update/check" + f"?client_id={urllib.parse.quote(client_id)}" + f"&os={urllib.parse.quote(self.os_type)}" + f"¤t_version={urllib.parse.quote(self.current_version)}" + ) + try: + req = urllib.request.Request( + url, + headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 200: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + # Silent fallback during standard forwarder operation + pass + return {"update_available": False, "latest_version": self.current_version} + + def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool: + """ + Downloads the client binary package from the server and validates its SHA-256 checksum. + """ + base_url = self.get_server_base_url() + download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}" + + print(f"[*] Downloading client update package from {download_url}...") + req = urllib.request.Request( + download_url, + headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status != 200: + print(f"[!] Server returned HTTP {resp.status} during update download.") + return False + with open(target_temp, "wb") as f: + while chunk := resp.read(65536): + f.write(chunk) + + # Verify SHA-256 + actual_sha256 = self.calculate_sha256(target_temp) + if expected_sha256 and actual_sha256 != expected_sha256.lower(): + print(f"[!] Update verification failed: Checksum mismatch! Expected: {expected_sha256}, Got: {actual_sha256}") + if os.path.exists(target_temp): + os.remove(target_temp) + return False + + print(f"[+] Download verified successfully (SHA-256: {actual_sha256[:16]}...).") + return True + + def apply_update_linux(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: + """ + Applies update on Linux by setting permissions and atomically moving the binary. + """ + target = target_binary_path or sys.executable + try: + os.chmod(new_binary_path, 0o755) + # Atomically replace target binary + os.replace(new_binary_path, target) + print(f"[+] Successfully replaced client binary: {target}") + + # If running as a systemd service, restart service + if shutil.which("systemctl"): + try: + subprocess.run(["systemctl", "restart", "logar-client"], check=False) + print("[+] Signaled systemd to restart logar-client service.") + except Exception: + pass + return True + except Exception as e: + print(f"[!] Failed to apply Linux update: {e}") + return False + + def apply_update_windows(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: + """ + Applies update on Windows. Since the currently running .exe cannot be directly overwritten, + spawns a detached background helper script that waits for current PID to exit, swaps the binary, + and restarts the service or executable. + """ + target = target_binary_path or sys.executable + pid = os.getpid() + target_dir = os.path.dirname(os.path.abspath(target)) + updater_bat = os.path.join(target_dir, f"_update_{pid}.bat") + + bat_script = f"""@echo off +setlocal +echo [*] LOGAR Client Auto-Updater waiting for process {pid} to terminate... +:WAIT_LOOP +tasklist /FI "PID eq {pid}" | findstr "{pid}" >nul +if %ERRORLEVEL% == 0 ( + timeout /t 1 /nobreak >nul + goto WAIT_LOOP +) + +echo [*] Applying update to {target}... +move /Y "{new_binary_path}" "{target}" >nul +if %ERRORLEVEL% NEQ 0 ( + copy /Y "{new_binary_path}" "{target}" >nul +) + +if exist "{os.path.join(target_dir, 'nssm.exe')}" ( + "{os.path.join(target_dir, 'nssm.exe')}" restart LOGAR-Client >nul 2>&1 +) else ( + start "" "{target}" +) + +del "{new_binary_path}" 2>nul +(goto) 2>nul & del "%~f0" +""" + try: + with open(updater_bat, "w", encoding="utf-8") as f: + f.write(bat_script) + + # Spawn detached batch process + DETACHED_PROCESS = 0x00000008 + CREATE_NEW_PROCESS_GROUP = 0x00000200 + subprocess.Popen( + ["cmd.exe", "/c", updater_bat], + creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, + close_fds=True + ) + print(f"[+] Detached updater script launched ({updater_bat}). Terminating for restart...") + return True + except Exception as e: + print(f"[!] Failed to launch Windows updater script: {e}") + return False + + def check_and_apply_auto_update(self, client_id: str, auto_apply: bool = True) -> Dict[str, Any]: + """ + Main entry point for client periodic auto-update check. + Checks for update, downloads, verifies, and triggers update if available. + """ + update_info = self.check_for_update(client_id) + if not update_info.get("update_available"): + return {"status": "up_to_date", "current_version": self.current_version} + + target_ver = update_info.get("latest_version") + download_url = update_info.get("download_url") + expected_sha256 = update_info.get("sha256", "") + + print(f"[!] 🔔 New LOGAR Client update available: v{self.current_version} -> v{target_ver}") + + if not auto_apply or not download_url: + return {"status": "available", "latest_version": target_ver} + + # Prepare temporary file for download + temp_dir = os.path.dirname(os.path.abspath(sys.executable)) + if not os.access(temp_dir, os.W_OK): + temp_dir = tempfile.gettempdir() + + suffix = ".exe" if self.os_type == "windows" else ".bin" + temp_download = os.path.join(temp_dir, f"_update_pkg_{int(time.time())}{suffix}") + + ok = self.download_package(download_url, expected_sha256, temp_download) + if not ok: + return {"status": "download_failed"} + + print(f"[*] Applying client update to v{target_ver}...") + if self.os_type == "windows": + applied = self.apply_update_windows(temp_download) + if applied and getattr(sys, "frozen", False): + sys.exit(0) + else: + applied = self.apply_update_linux(temp_download) + + return {"status": "updated" if applied else "apply_failed", "latest_version": target_ver} diff --git a/src/server_models.py b/src/server_models.py index feb2247..164347b 100644 --- a/src/server_models.py +++ b/src/server_models.py @@ -12,6 +12,18 @@ class ClientEnrollRequest(BaseModel): hostname: str os: str enrollment_secret: str + version: Optional[str] = None + + +class ClientUpdateRequest(BaseModel): + client_id: str + os: str + current_version: Optional[str] = None + arch: Optional[str] = "x86_64" + + +class ServerUpdateCheckRequest(BaseModel): + force: bool = False class ClientActionRequest(BaseModel): diff --git a/src/storage_engine.py b/src/storage_engine.py index 5889e0f..3812d5c 100644 --- a/src/storage_engine.py +++ b/src/storage_engine.py @@ -225,7 +225,10 @@ class StorageEngine: ("metrics_json", "TEXT DEFAULT '{}'"), ("cpu_percent", "REAL DEFAULT 0.0"), ("memory_percent", "REAL DEFAULT 0.0"), - ("disk_percent_free", "REAL DEFAULT 0.0") + ("disk_percent_free", "REAL DEFAULT 0.0"), + ("client_version", "TEXT DEFAULT ''"), + ("update_status", "TEXT DEFAULT 'up_to_date'"), + ("last_update_check", "TEXT") ]: try: conn.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_def}") @@ -426,7 +429,10 @@ class StorageEngine: ("metrics_json", "TEXT DEFAULT '{}'"), ("cpu_percent", "DOUBLE PRECISION DEFAULT 0.0"), ("memory_percent", "DOUBLE PRECISION DEFAULT 0.0"), - ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0") + ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"), + ("client_version", "VARCHAR(50) DEFAULT ''"), + ("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"), + ("last_update_check", "VARCHAR(50)") ]: try: cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") diff --git a/src/update_manager.py b/src/update_manager.py new file mode 100644 index 0000000..febc114 --- /dev/null +++ b/src/update_manager.py @@ -0,0 +1,302 @@ +""" +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 + ): + 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 + + # 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._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) + + @staticmethod + def calculate_sha256(filepath: str) -> str: + """Computes the SHA-256 digest of a local file.""" + if not os.path.exists(filepath): + return "" + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest().lower() + + 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") + + 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) + + target_name = filename or os.path.basename(file_path) + 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) + + return manifest + + def get_hosted_packages(self) -> Dict[str, Dict[str, Any]]: + """ + Returns info on currently hosted client packages for Windows and Linux. + Scans update_dir and falls back to project dist/ directory if present. + """ + result: Dict[str, Dict[str, Any]] = {} + for os_name in ["windows", "linux"]: + manifest_path = os.path.join(self.update_dir, os_name, "manifest.json") + 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 + 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): + for f in os.listdir(os_dir): + if f.endswith((".exe", ".bin", ".zip", ".tar.gz", ".sh")) and not f.startswith("."): + 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.exe", "Win_Client.exe" + ] if os_name == "windows" else [ + "Linux_Client.bin", "LOGAR_Client_Linux", "install_linux_client.sh" + ] + 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) + result[os_name] = { + "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") + } + else: + result[os_name] = { + "os": os_name, + "filename": None, + "version": self.hosted_client_version or LOGAR_VERSION, + "sha256": "", + "size_bytes": 0, + "file_path": None, + "exists": False + } + + 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 the hosted version is strictly newer than the client version + 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 check_upstream_updates(self, force: bool = False, timeout: int = 8) -> Dict[str, Any]: + """ + Queries the upstream repository release API (Gitea / GitHub / JSON manifest) + to check if a newer server or client release is available. + """ + 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}") + 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, + "hosted_packages": self.get_hosted_packages() + } diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..aa2f276 --- /dev/null +++ b/src/version.py @@ -0,0 +1,67 @@ +""" +LOGAR - Unified Versioning and Semantic Comparison Module +Provides canonical application version constants and semantic version comparison utilities. +""" + +import os +import re +from typing import Tuple, Optional + + +LOGAR_VERSION = "0.3.5.2" +__version__ = LOGAR_VERSION + + +def parse_version(version_str: Optional[str]) -> Tuple[int, ...]: + """ + Parses a version string into a comparable tuple of integers. + Strips leading 'v' or 'V' and non-numeric suffixes. + e.g. 'v0.3.5.2' -> (0, 3, 5, 2), '0.3.5-beta' -> (0, 3, 5, 0) + """ + if not version_str: + return (0, 0, 0, 0) + + clean = str(version_str).strip() + if clean.lower().startswith("v"): + clean = clean[1:].strip() + + # Extract digit sequences separated by dots + match = re.match(r"^(\d+(?:\.\d+)*)", clean) + if not match: + return (0, 0, 0, 0) + + parts = [int(p) for p in match.group(1).split(".")] + # Pad to at least 4 components for consistent 4-part semantic version comparison + while len(parts) < 4: + parts.append(0) + return tuple(parts) + + +def compare_versions(v1: Optional[str], v2: Optional[str]) -> int: + """ + Compares two version strings. + Returns: + 1 if v1 > v2 (v1 is newer than v2) + 0 if v1 == v2 + -1 if v1 < v2 (v1 is older than v2) + """ + t1 = parse_version(v1) + t2 = parse_version(v2) + if t1 > t2: + return 1 + elif t1 < t2: + return -1 + return 0 + + +def is_newer_version(candidate_version: Optional[str], current_version: Optional[str] = None) -> bool: + """ + Returns True if candidate_version is strictly newer than current_version (or LOGAR_VERSION). + """ + base = current_version or LOGAR_VERSION + return compare_versions(candidate_version, base) > 0 + + +def get_current_version() -> str: + """Returns the canonical current version string.""" + return LOGAR_VERSION diff --git a/src/web_ui.py b/src/web_ui.py index 07713e6..d8c6848 100644 --- a/src/web_ui.py +++ b/src/web_ui.py @@ -14,6 +14,10 @@ from typing import Optional, Dict, Any, List from src.auth_manager import SessionManager from src.license_manager import LicenseManager from src.storage_engine import StorageEngine, StorageCryptoManager +try: + from src.version import LOGAR_VERSION, is_newer_version +except ImportError: + from version import LOGAR_VERSION, is_newer_version def format_relative_time(dt_str: Optional[str]) -> str: @@ -183,11 +187,20 @@ class WebUIRenderer: ) pg_config = config.get("postgres", {}) - # Fetch clients + update_mgr = state.get("update_manager") + 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", {}) + + # Fetch clients safely conn = sqlite3.connect(db_path) c = conn.cursor() - c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - clients = c.fetchall() + try: + c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date') FROM clients ORDER BY last_seen DESC") + clients = c.fetchall() + except sqlite3.OperationalError: + c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + clients = [r + ("", "up_to_date") for r in c.fetchall()] # Fetch verified anomalies c.execute("SELECT fingerprint, server, severity, message, run_count, last_seen FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC LIMIT 50") @@ -203,10 +216,10 @@ class WebUIRenderer: hermes_tokens = c.fetchall() conn.close() - # Build client rows with exact + relative activity time and seat management actions + # Build client rows with exact + relative activity time, version badges, and seat management actions client_rows = [] for cl in clients: - cid, host, ost, fp, st, first, last = cl + cid, host, ost, fp, st, first, last, cver, up_st = cl[:9] esc_cid = html.escape(str(cid)) esc_host = html.escape(str(host)) esc_ost = html.escape(str(ost)) @@ -216,6 +229,14 @@ class WebUIRenderer: rel_time = format_relative_time(last) last_display = f"
{esc_cid}| Target OS | +Package File | +Version | +Size | +SHA-256 Checksum | +Status | +Actions | +
|---|---|---|---|---|---|---|
| WINDOWS | +{html.escape(hosted_packages.get('windows', {}).get('filename') or 'LOGAR_Client_Setup.exe')} | +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 + | +
| LINUX | +{html.escape(hosted_packages.get('linux', {}).get('filename') or 'Linux_Client.bin')} | +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 + | +