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
+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."""