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:
+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."""
|
||||
|
||||
Reference in New Issue
Block a user