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
+8 -1
View File
@@ -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).")
+203 -18
View File
@@ -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."""
+8 -1
View File
@@ -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).")
+18 -3
View File
@@ -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)
+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}
+12
View File
@@ -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):
+8 -2
View File
@@ -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}")
+302
View File
@@ -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()
}
+67
View File
@@ -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
+209 -6
View File
@@ -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"<div><strong>{html.escape(str(last[:19]))}</strong></div><div style='font-size:11px; color:#94a3b8;'>{html.escape(rel_time)}</div>"
# Client Version Display
if not cver:
ver_display = f'<span class="badge" style="background:#1e293b; color:#94a3b8; font-size:11px;">v{LOGAR_VERSION}</span>'
elif is_newer_version(hosted_client_ver, cver):
ver_display = f'<span class="badge badge-warning" title="Update available to v{hosted_client_ver}" style="font-size:11px;">v{html.escape(cver)} (Outdated)</span>'
else:
ver_display = f'<span class="badge badge-active" style="font-size:11px;">v{html.escape(cver)}</span>'
if fp == "LOCAL-HOST-BUILTIN":
esc_st = "LOCAL HUB HOST"
badge_cls = "badge-active"
@@ -237,6 +258,7 @@ class WebUIRenderer:
<td><strong>{esc_host}</strong></td>
<td><code>{esc_cid}</code></td>
<td><span class="badge badge-os">{esc_ost.upper()}</span></td>
<td>{ver_display}</td>
<td>{fp_display}</td>
<td>{last_display}</td>
<td>
@@ -247,7 +269,7 @@ class WebUIRenderer:
</td>
</tr>
""")
clients_html = "".join(client_rows) if client_rows else '<tr><td colspan="7" class="empty-state">No edge clients registered yet.</td></tr>'
clients_html = "".join(client_rows) if client_rows else '<tr><td colspan="8" class="empty-state">No edge clients registered yet.</td></tr>'
# Build anomaly rows
anomaly_rows = []
@@ -322,6 +344,26 @@ class WebUIRenderer:
</div>
"""
server_update_banner_html = ""
update_tab_badge = ""
if update_status.get("server_update_available"):
up_ver = html.escape(str(update_status.get("latest_upstream_version", "")))
update_tab_badge = ' <span class="badge badge-warning" style="font-size:10px; margin-left:4px; padding:2px 6px;">NEW</span>'
server_update_banner_html = f"""
<div style="background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.35); border-radius: 10px; padding: 14px 20px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 26px;">🔔</div>
<div>
<div style="font-weight: 700; color: #60a5fa; font-size: 14px; margin-bottom: 2px;">LOGAR Server Update Available: v{up_ver} (Current: v{LOGAR_VERSION})</div>
<div style="font-size: 13px; color: #cbd5e1;">A newer release of LOGAR is available. Deployed server packages automatically upgrade edge clients during their next forwarder check.</div>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="switchTab('updates')" class="btn btn-primary" style="font-size: 12px; padding: 7px 14px;">📦 View Updates</button>
</div>
</div>
"""
detected_host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or config.get("tcp_host", "127.0.0.1")
if detected_host in ("0.0.0.0", "localhost", ""):
detected_host = socket.gethostname()
@@ -571,6 +613,108 @@ class WebUIRenderer:
</div>
</div>
</div>
<!-- Tab 6: Updates & Fleet Distribution -->
<div class="tab-pane" id="tabPane-updates">
<div class="card" style="margin-bottom: 24px;">
<div class="card-header">
<div>
<div class="card-title" style="font-size: 15px;">🌐 Server Version & Upstream Release Status</div>
<div class="subtitle" style="margin-top: 4px;">Automatic upstream release checking via Gitea production channels</div>
</div>
{('<span class="badge badge-warning">UPDATE AVAILABLE</span>' if update_status.get("server_update_available") else '<span class="badge badge-active">UP TO DATE</span>')}
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px;">Current Server Version</div>
<div style="font-size: 18px; font-weight: 700; color: #38bdf8;">v{LOGAR_VERSION}</div>
</div>
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px;">Latest Upstream Release</div>
<div style="font-size: 18px; font-weight: 700; color: #34d399;">v{update_status.get('latest_upstream_version') or LOGAR_VERSION}</div>
</div>
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px;">Last Checked</div>
<div style="font-size: 14px; font-weight: 600; color: #e2e8f0; margin-top: 3px;">{update_status.get('last_checked_utc') or 'Never'}</div>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button type="button" onclick="checkForUpdatesNow()" class="btn btn-primary">🔍 Check for Upstream Updates</button>
{(f'<a href="{html.escape(update_status.get("release_url", ""))}" target="_blank" class="btn btn-secondary">🌐 View Release on Gitea</a>' if update_status.get("release_url") else '')}
</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title" style="font-size: 15px;">📦 Client Package Distribution Repository</div>
<div class="subtitle" style="margin-top: 4px;">Hosted binaries for edge forwarders to automatically download and hot-upgrade</div>
</div>
<span class="badge badge-active">Target: v{hosted_client_ver}</span>
</div>
<div style="overflow-x: auto; margin-bottom: 24px;">
<table>
<thead>
<tr>
<th>Target OS</th>
<th>Package File</th>
<th>Version</th>
<th>Size</th>
<th>SHA-256 Checksum</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge badge-os">WINDOWS</span></td>
<td><strong>{html.escape(hosted_packages.get('windows', {}).get('filename') or 'LOGAR_Client_Setup.exe')}</strong></td>
<td><code>v{html.escape(str(hosted_packages.get('windows', {}).get('version') or hosted_client_ver))}</code></td>
<td>{((hosted_packages.get('windows', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('windows', {}).get('exists') else 0)} KB</td>
<td><code title="{html.escape(hosted_packages.get('windows', {}).get('sha256', ''))}">{(hosted_packages.get('windows', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('windows', {}).get('sha256') else 'N/A'}</code></td>
<td>{('<span class="badge badge-active">Available</span>' if hosted_packages.get('windows', {}).get('exists') else '<span class="badge badge-warning">Not Uploaded</span>')}</td>
<td>
<a href="/api/client/update/download?os=windows" class="btn btn-outline" style="padding: 4px 10px; font-size: 12px;">📥 Download</a>
</td>
</tr>
<tr>
<td><span class="badge badge-os">LINUX</span></td>
<td><strong>{html.escape(hosted_packages.get('linux', {}).get('filename') or 'Linux_Client.bin')}</strong></td>
<td><code>v{html.escape(str(hosted_packages.get('linux', {}).get('version') or hosted_client_ver))}</code></td>
<td>{((hosted_packages.get('linux', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('linux', {}).get('exists') else 0)} KB</td>
<td><code title="{html.escape(hosted_packages.get('linux', {}).get('sha256', ''))}">{(hosted_packages.get('linux', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('linux', {}).get('sha256') else 'N/A'}</code></td>
<td>{('<span class="badge badge-active">Available</span>' if hosted_packages.get('linux', {}).get('exists') else '<span class="badge badge-warning">Not Uploaded</span>')}</td>
<td>
<a href="/api/client/update/download?os=linux" class="btn btn-outline" style="padding: 4px 10px; font-size: 12px;">📥 Download</a>
</td>
</tr>
</tbody>
</table>
</div>
<div style="background: #0f172a; padding: 18px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 13px; font-weight: 700; color: #60a5fa; margin-bottom: 12px;">📤 Upload Client Binary Package</div>
<form onsubmit="submitClientPackageUpload(event)" style="display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<label class="form-label">OS Platform</label>
<select id="uploadPkgOs" class="form-control" required>
<option value="windows">Windows (.exe)</option>
<option value="linux">Linux (.bin / .sh)</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0; min-width: 140px;">
<label class="form-label">Version Tag</label>
<input type="text" id="uploadPkgVer" class="form-control" placeholder="0.3.5.2" value="{hosted_client_ver}">
</div>
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 220px;">
<label class="form-label">Select Binary File</label>
<input type="file" id="uploadPkgFile" class="form-control" required>
</div>
<button type="submit" class="btn btn-accent" style="padding: 10px 18px; font-size: 13px;">Upload & Deploy to Fleet</button>
</form>
</div>
</div>
</div>
"""
html_content = f"""<!DOCTYPE html>
@@ -797,6 +941,7 @@ class WebUIRenderer:
<div id="statusAlert" class="alert"></div>
{unlicensed_banner_html}
{server_update_banner_html}
<!-- Navigation Tabs -->
<div class="tabs-nav">
@@ -805,6 +950,7 @@ class WebUIRenderer:
<button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry</button>
<button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button>
<button class="tab-btn" id="tabBtn-db" onclick="switchTab('db')">💾 DB Engine</button>
<button class="tab-btn" id="tabBtn-updates" onclick="switchTab('updates')">📦 Updates & Distribution{update_tab_badge}</button>
</div>
<!-- KPI Summary Cards -->
@@ -867,6 +1013,7 @@ class WebUIRenderer:
<th>Host Node</th>
<th>Client Identifier</th>
<th>OS</th>
<th>Version</th>
<th>Cert Fingerprint</th>
<th>Last Activity Time</th>
<th>Seat Actions</th>
@@ -1673,6 +1820,62 @@ class WebUIRenderer:
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(text);
el.appendChild(img);
}}
async function checkForUpdatesNow() {{
showAlert('Checking upstream repository for new releases...');
try {{
const res = await fetch('/api/server/update/check', {{ method: 'POST' }});
const data = await res.json();
if (data.server_update_available) {{
showAlert('🔔 Update Available: v' + data.latest_upstream_version + ' is ready!');
}} else {{
showAlert('✅ LOGAR is up to date (Current: v' + data.current_server_version + ').');
}}
setTimeout(() => location.reload(), 1200);
}} catch (err) {{
alert('Failed to check for updates: ' + err.message);
}}
}}
async function submitClientPackageUpload(e) {{
e.preventDefault();
const osType = document.getElementById('uploadPkgOs').value;
const ver = document.getElementById('uploadPkgVer').value.trim();
const fileInput = document.getElementById('uploadPkgFile');
if (!fileInput.files.length) {{
alert('Please choose a client binary package file.');
return;
}}
const file = fileInput.files[0];
const reader = new FileReader();
showAlert('Reading package file (' + Math.round(file.size / 1024) + ' KB)...');
reader.onload = async function() {{
const b64 = reader.result.split(',')[1] || reader.result;
showAlert('Uploading client package for ' + osType + '...');
try {{
const res = await fetch('/api/server/client-package/upload', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
os: osType,
version: ver || undefined,
filename: file.name,
data_base64: b64
}})
}});
const data = await res.json();
if (res.ok && data.status === 'success') {{
showAlert('Client package uploaded successfully! (SHA-256: ' + (data.sha256 ? data.sha256.substring(0, 16) : '') + '...)');
setTimeout(() => location.reload(), 1500);
}} else {{
alert('Upload failed: ' + (data.detail || data.message || 'Error'));
}}
}} catch (err) {{
alert('Upload request failed: ' + err.message);
}}
}};
reader.readAsDataURL(file);
}}
</script>
</body>
</html>"""
+160
View File
@@ -0,0 +1,160 @@
"""
LOGAR - Unit and Integration Tests for Auto-Update & Update Alerting
Tests version comparison, ServerUpdateManager, ClientUpdater, REST endpoints, and WebUI rendering.
"""
import os
import sys
import json
import uuid
import tempfile
import unittest
import warnings
from typing import Optional
warnings.filterwarnings("ignore")
# Ensure parent directory and src directory are in path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
import Server
from version import LOGAR_VERSION, parse_version, compare_versions, is_newer_version
from update_manager import ServerUpdateManager
from client_updater import ClientUpdater
class TestUpdateSystem(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.test_db = os.path.join(self.test_dir, f"test_db_{uuid.uuid4().hex[:8]}.db")
self.test_updates_dir = os.path.join(self.test_dir, "updates")
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}
def tearDown(self):
import shutil
if os.path.exists(self.test_dir):
try:
shutil.rmtree(self.test_dir)
except Exception:
pass
def test_semantic_version_parsing_and_comparison(self):
"""Tests robust 4-part semantic version comparison."""
self.assertEqual(parse_version("0.3.5.2"), (0, 3, 5, 2))
self.assertEqual(parse_version("v0.3.5.2"), (0, 3, 5, 2))
self.assertEqual(parse_version("0.3.5"), (0, 3, 5, 0))
self.assertEqual(parse_version("1.0.0.0"), (1, 0, 0, 0))
self.assertEqual(parse_version(""), (0, 0, 0, 0))
# Comparisons
self.assertEqual(compare_versions("0.3.5.3", "0.3.5.2"), 1)
self.assertEqual(compare_versions("0.3.5.2", "0.3.5.2"), 0)
self.assertEqual(compare_versions("0.3.5.1", "0.3.5.2"), -1)
self.assertEqual(compare_versions("0.4.0.0", "0.3.9.9"), 1)
self.assertEqual(compare_versions("1.0.0.0", "0.9.9.9"), 1)
self.assertTrue(is_newer_version("0.3.6.0", "0.3.5.2"))
self.assertFalse(is_newer_version("0.3.5.2", "0.3.5.2"))
self.assertFalse(is_newer_version("0.3.5.1", "0.3.5.2"))
def test_server_update_manager_package_registration(self):
"""Tests registering client update packages and calculating SHA256 hashes."""
dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Setup.exe")
with open(dummy_pkg, "wb") as f:
f.write(b"LOGAR_WINDOWS_CLIENT_BINARY_PAYLOAD_V0360")
manifest = self.update_mgr.register_client_package(
os_type="windows",
file_path=dummy_pkg,
version="0.3.6.0",
filename="LOGAR_Client_Setup.exe"
)
self.assertEqual(manifest["os"], "windows")
self.assertEqual(manifest["version"], "0.3.6.0")
self.assertTrue(len(manifest["sha256"]) == 64)
# Check hosted packages dictionary
packages = self.update_mgr.get_hosted_packages()
self.assertTrue(packages["windows"]["exists"])
self.assertEqual(packages["windows"]["version"], "0.3.6.0")
self.assertEqual(packages["windows"]["sha256"], manifest["sha256"])
# Check client update evaluation for an older client
eval_res = self.update_mgr.check_client_update(
client_id="node-test-win",
os_type="windows",
current_version="0.3.5.2"
)
self.assertTrue(eval_res["update_available"])
self.assertEqual(eval_res["latest_version"], "0.3.6.0")
self.assertEqual(eval_res["sha256"], manifest["sha256"])
self.assertIn("/api/client/update/download", eval_res["download_url"])
# Client already on current version -> no update needed
eval_current = self.update_mgr.check_client_update(
client_id="node-test-win",
os_type="windows",
current_version="0.3.6.0"
)
self.assertFalse(eval_current["update_available"])
def test_client_updater_checksum_validation(self):
"""Tests ClientUpdater SHA-256 verification and file integrity validation."""
updater = ClientUpdater(config={"server_host": "127.0.0.1", "hermes_port": 8443}, os_type="windows")
test_file = os.path.join(self.test_dir, "test_bin.exe")
content = b"TEST_PAYLOAD_FOR_AUTO_UPDATER"
with open(test_file, "wb") as f:
f.write(content)
import hashlib
expected_sha = hashlib.sha256(content).hexdigest()
self.assertEqual(updater.calculate_sha256(test_file), expected_sha.lower())
def test_server_update_endpoints_and_webui(self):
"""Tests FastAPI update status, client check, and WebUI rendering with update components."""
# 1. Status API
status = Server.get_update_status()
self.assertIn("current_server_version", status)
self.assertIn("hosted_packages", status)
# 2. Register Windows package
dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Setup.exe")
with open(dummy_pkg, "wb") as f:
f.write(b"WIN_CLIENT_V0360")
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
# 3. Client update check endpoint
chk_resp = Server.check_client_update(
client_id="win-client-01",
os="windows",
current_version="0.3.5.1"
)
self.assertTrue(chk_resp["update_available"])
self.assertEqual(chk_resp["latest_version"], "0.3.6.0")
# 4. Download package endpoint
dl_resp = Server.download_client_update_package(os="windows")
self.assertEqual(dl_resp.status_code, 200)
self.assertIn("X-Checksum-SHA256", dl_resp.headers)
# 5. WebUI rendering verification
# Simulate server update available
self.update_mgr.server_update_available = True
self.update_mgr.latest_upstream_version = "0.3.6.0"
html = Server.WebUIRenderer.render_dashboard_html(
server_state=Server.SERVER_STATE,
ingest_queue=Server.INGEST_QUEUE
)
self.assertIn("LOGAR Server Update Available: v0.3.6.0", html)
self.assertIn("tabBtn-updates", html)
self.assertIn("tabPane-updates", html)
self.assertIn("Client Package Distribution Repository", html)
if __name__ == "__main__":
unittest.main()