Organize codebase into hub, clients, and common packages with backward compatibility and comprehensive unit test suites
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m18s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m18s
This commit is contained in:
+1
-248
@@ -1,248 +1 @@
|
||||
"""
|
||||
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
|
||||
import urllib.parse
|
||||
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.
|
||||
Uses local binary hash checking and ETag conditional headers to avoid redundant downloads.
|
||||
"""
|
||||
# 1. If running binary already matches target SHA-256, skip downloading
|
||||
target_exec = sys.executable
|
||||
if expected_sha256 and os.path.exists(target_exec):
|
||||
try:
|
||||
local_sha = self.calculate_sha256(target_exec)
|
||||
if local_sha and local_sha == expected_sha256.lower():
|
||||
print(f"[*] Local binary already matches target checksum ({local_sha[:12]}...). Skipping redundant download.")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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}...")
|
||||
headers = {"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"}
|
||||
if expected_sha256:
|
||||
headers["If-None-Match"] = f'"{expected_sha256}"'
|
||||
|
||||
req = urllib.request.Request(download_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status == 304:
|
||||
print("[+] Server returned 304 Not Modified. Local binary package is current.")
|
||||
return True
|
||||
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)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 304:
|
||||
print("[+] Server returned 304 Not Modified. Local binary package is current.")
|
||||
return True
|
||||
print(f"[!] HTTP error during update download: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[!] Error downloading update package: {e}")
|
||||
return False
|
||||
|
||||
# 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}
|
||||
from src.clients.client_updater import *
|
||||
Reference in New Issue
Block a user