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
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s
This commit is contained in:
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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>"""
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user