Add server update alerting and fleet client auto-update system (synchronized release lockstep)
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s

This commit is contained in:
2026-10-02 22:08:51 +02:00
parent f2a9e43619
commit 2c08f9fc15
11 changed files with 1216 additions and 31 deletions
+221
View File
@@ -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"&current_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}