diff --git a/src/clients/client_transport.py b/src/clients/client_transport.py index ba28eed..421c5de 100644 --- a/src/clients/client_transport.py +++ b/src/clients/client_transport.py @@ -118,6 +118,7 @@ def enroll_client_if_needed( "is_server": inventory.get("is_server", False), "server_roles": inventory.get("server_roles", []), "client_info": inventory, + "metrics": collect_system_metrics(), "enrollment_secret": enrollment_secret, "version": LOGAR_VERSION } diff --git a/src/clients/log_collector.py b/src/clients/log_collector.py index 47f3e98..ea03198 100644 --- a/src/clients/log_collector.py +++ b/src/clients/log_collector.py @@ -11,6 +11,7 @@ import socket import shutil import ctypes import subprocess +import time import warnings from abc import ABC, abstractmethod from datetime import datetime, timezone, timedelta @@ -106,7 +107,42 @@ def collect_system_metrics() -> Dict[str, Any]: # 3. CPU metrics try: - if hasattr(os, "getloadavg"): + if sys.platform == "win32": + class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)] + + idle, kernel, user = FILETIME(), FILETIME(), FILETIME() + if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)): + i1 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime + k1 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime + u1 = (user.dwHighDateTime << 32) + user.dwLowDateTime + time.sleep(0.06) + if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)): + i2 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime + k2 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime + u2 = (user.dwHighDateTime << 32) + user.dwLowDateTime + idle_delta = i2 - i1 + total_delta = (k2 - k1) + (u2 - u1) + if total_delta > 0: + pct = 100.0 * (1.0 - (idle_delta / total_delta)) + metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1) + elif os.path.exists("/proc/stat"): + def _read_proc_stat(): + with open("/proc/stat", "r") as f: + fields = [float(col) for col in f.readline().strip().split()[1:]] + idle = fields[3] + (fields[4] if len(fields) > 4 else 0.0) + total = sum(fields) + return idle, total + + i1, t1 = _read_proc_stat() + time.sleep(0.06) + i2, t2 = _read_proc_stat() + total_delta = t2 - t1 + idle_delta = i2 - i1 + if total_delta > 0: + pct = 100.0 * (1.0 - (idle_delta / total_delta)) + metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1) + elif hasattr(os, "getloadavg"): load1, _, _ = os.getloadavg() cpu_count = os.cpu_count() or 1 metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1) diff --git a/src/common/system_info.py b/src/common/system_info.py index f4c7a67..d31fece 100644 --- a/src/common/system_info.py +++ b/src/common/system_info.py @@ -436,6 +436,7 @@ def get_full_system_inventory() -> Dict[str, Any]: # Memory mem_total_mb = 0 + mem_percent = 0.0 try: if sys.platform == "win32": import ctypes @@ -455,12 +456,19 @@ def get_full_system_inventory() -> Dict[str, Any]: stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX) if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): mem_total_mb = int(stat.ullTotalPhys / (1024 * 1024)) + mem_percent = float(stat.dwMemoryLoad) elif os.path.exists("/proc/meminfo"): + tot = 0 + avail = 0 with open("/proc/meminfo", "r", encoding="utf-8") as f: for line in f: if line.startswith("MemTotal:"): - mem_total_mb = int(line.split(":")[1].strip().split()[0]) // 1024 - break + tot = int(line.split(":")[1].strip().split()[0]) + elif line.startswith(("MemAvailable:", "MemFree:")) and avail == 0: + avail = int(line.split(":")[1].strip().split()[0]) + if tot > 0: + mem_total_mb = tot // 1024 + mem_percent = round(((tot - avail) / tot) * 100, 1) except Exception: pass @@ -478,6 +486,7 @@ def get_full_system_inventory() -> Dict[str, Any]: "architecture": os_info.get("architecture"), "cpu_count": os.cpu_count() or 1, "memory_total_mb": mem_total_mb, + "memory_percent": mem_percent, "disk_total_gb": disk_total_gb, "disk_free_gb": disk_free_gb, "disk_percent_free": disk_pct_free, diff --git a/src/hub/Server.py b/src/hub/Server.py index a14e2a4..b981001 100644 --- a/src/hub/Server.py +++ b/src/hub/Server.py @@ -62,7 +62,8 @@ try: WindowsLogCollector, LinuxLogCollector, get_log_collector, - is_routine_service_noise + is_routine_service_noise, + collect_system_metrics ) except ImportError: from log_collector import ( @@ -70,7 +71,8 @@ except ImportError: WindowsLogCollector, LinuxLogCollector, get_log_collector, - is_routine_service_noise + is_routine_service_noise, + collect_system_metrics ) # Import specialized modular components @@ -1042,6 +1044,16 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run except Exception: inv = {} + try: + metrics = collect_system_metrics() if "collect_system_metrics" in globals() else {} + except Exception: + metrics = {} + + 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", inv.get("disk_percent_free", 0.0))) + metrics_json = json.dumps(metrics) if metrics else "{}" + machine_id = get_machine_identifier() os_type = inv.get("os_type", "windows" if sys.platform == "win32" else "linux") os_name = inv.get("os_name", "") @@ -1059,9 +1071,11 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run c.execute(""" UPDATE clients SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?, - os_name = ?, is_server = ?, server_roles = ?, client_info_json = ? + os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?, + cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, metrics_json = ? WHERE client_id = ? - """, (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json, machine_id)) + """, (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json, + cpu_p, mem_p, disk_p, metrics_json, machine_id)) except sqlite3.OperationalError: c.execute(""" UPDATE clients @@ -1071,9 +1085,10 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run else: try: c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json) - VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?) - """, (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json)) + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json) + VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json, + cpu_p, mem_p, disk_p, metrics_json)) except sqlite3.OperationalError: c.execute(""" INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) @@ -1461,15 +1476,25 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): req_is_server = 1 if req.is_server else (1 if req.os == "windows_server" else 0) req_server_roles = json.dumps(req.server_roles) if req.server_roles else "[]" req_client_info = json.dumps(req.client_info) if req.client_info else "{}" + req_metrics = getattr(req, "metrics", None) or {} + cpu_p = float(req_metrics.get("cpu_percent", 0.0)) + mem_p = float(req_metrics.get("memory_percent", 0.0)) + disk_p = float(req_metrics.get("disk_percent_free", 0.0)) + metrics_json = json.dumps(req_metrics) if req_metrics else "{}" if existing: try: c.execute(""" UPDATE clients SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?, - os_name = ?, is_server = ?, server_roles = ?, client_info_json = ? + os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?, + cpu_percent = CASE WHEN ? > 0.0 THEN ? ELSE cpu_percent END, + memory_percent = CASE WHEN ? > 0.0 THEN ? ELSE memory_percent END, + disk_percent_free = CASE WHEN ? > 0.0 THEN ? ELSE disk_percent_free END, + metrics_json = CASE WHEN ? != '{}' THEN ? ELSE metrics_json END WHERE client_id = ? - """, (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, req.client_id)) + """, (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, + cpu_p, cpu_p, mem_p, mem_p, disk_p, disk_p, metrics_json, metrics_json, req.client_id)) except sqlite3.OperationalError: try: c.execute(""" @@ -1486,9 +1511,9 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): else: try: c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json) - VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) - """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info)) + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, cpu_p, mem_p, disk_p, metrics_json)) except sqlite3.OperationalError: try: c.execute(""" diff --git a/src/hub/server_models.py b/src/hub/server_models.py index 9fa99fa..5f0a090 100644 --- a/src/hub/server_models.py +++ b/src/hub/server_models.py @@ -17,6 +17,7 @@ class ClientEnrollRequest(BaseModel): is_server: Optional[bool] = None server_roles: Optional[List[str]] = None client_info: Optional[Dict[str, Any]] = None + metrics: Optional[Dict[str, Any]] = None class ClientUpdateRequest(BaseModel): diff --git a/src/hub/web_ui.py b/src/hub/web_ui.py index 523ce97..27451c8 100644 --- a/src/hub/web_ui.py +++ b/src/hub/web_ui.py @@ -358,13 +358,29 @@ class WebUIRenderer: bp_rules_html = "".join(rule_rows) bp_banner_html = f""" +
+
+ 🛡️ +
+
Server Role Baseline & Compliance Status
+
{pass_cnt} passing • {warn_cnt} warnings • {fail_cnt} failures ({total_rules} Rules)
+
+
+
+ {score}% COMPLIANT + +
+
+ """ + + baseline_admin_html = f"""
-
+
-
🛡️
+
🛡️
-

Best Practice & Server Role Baseline Analyzer

+

Best Practice & Security Baseline Engine

AUTHORIZED • ACTIVE
@@ -374,7 +390,7 @@ class WebUIRenderer:
-
{score}%
+
{score}%
Fleet Compliance Score
@@ -384,15 +400,31 @@ class WebUIRenderer:
+
-
- - ▶ View {total_rules} Active Baseline Audit Rules & Remediation Standards - -
- {bp_rules_html} + +
+
+
+
Configured Baseline Audit Rules & Checks ({total_rules} Rules)
+
+ Modular compliance checks evaluated across all enrolled server nodes. Defined in src/hub/best_practice_rules.py. +
-
+
+ +
+
+ +
+
+ 💡 Rule Customization: Baseline rules are cleanly decoupled from the evaluation engine and maintained in src/hub/best_practice_rules.py. You can define new role checks, alter warning thresholds, or add compliance baselines without modifying core server logic. +
+
+ +
+ {bp_rules_html} +
""" bp_kpi_value = f"{score}%" @@ -400,13 +432,29 @@ class WebUIRenderer: bp_kpi_badge = f'{score}% COMPLIANT' else: bp_banner_html = """ +
+
+ 🛡️ +
+
Best Practice Analyzer & Role Baseline Engine
+
Requires 'best_practices' enterprise license capability
+
+
+
+ LOCKED + +
+
+ """ + + baseline_admin_html = """
-
🛡️
+
🛡️
-

Best Practice Analyzer & Role Baseline Engine

+

Best Practice Analyzer & Role Baseline Engine

ENTERPRISE CAPABILITY • INACTIVE

@@ -418,7 +466,30 @@ class WebUIRenderer:

- + +
+
+
+ +
+
+
+
Baseline Rules Definition File
+
+ Pre-configured baseline rules ready for evaluation once activated. Defined in src/hub/best_practice_rules.py. +
+
+
+
+
Rules File: src/hub/best_practice_rules.py
+
+ Contains modular rule definitions including: +
    +
  • BP-SYS-001: Primary Volume Storage Headroom (>= 20% free buffer)
  • +
  • BP-SYS-002: Active Memory Saturation (< 85% RAM load)
  • +
  • BP-AD-001: Active Directory Domain Controller Isolation (No IIS, RDS, Print)
  • +
  • BP-UPD-001: Client Agent Version Baseline Synchronization
  • +
@@ -634,6 +705,13 @@ class WebUIRenderer:
{os_badge_full}
{os_display_name}
{specs_sub}
+
+ ⚡ CPU {cpu_p}% + • + 🧠 RAM {mem_p}% + • + 💾 Free {disk_p}% +
@@ -1565,7 +1643,7 @@ class WebUIRenderer: