From cccf928f29e6bf73718aa6c1ad92582d20f721c9 Mon Sep 17 00:00:00 2001 From: max Date: Sun, 11 Oct 2026 09:37:05 +0200 Subject: [PATCH] Add Windows Server vs Client differentiation, installed server roles introspection, dedicated /client-info directory, and best practice baseline analyzer --- LOGAR-LICENSING | 2 +- src/clients/Win_Client.py | 15 +- src/clients/client_transport.py | 28 +- src/common/__init__.py | 7 + src/common/system_info.py | 323 +++++++ src/hub/Server.py | 297 ++++++- src/hub/best_practices.py | 278 ++++++ src/hub/server_models.py | 4 + src/hub/storage_engine.py | 12 +- src/hub/web_ui.py | 796 +++++++++++++++++- tests/test_server_roles_and_best_practices.py | 178 ++++ 11 files changed, 1902 insertions(+), 38 deletions(-) create mode 100644 src/common/system_info.py create mode 100644 src/hub/best_practices.py create mode 100644 tests/test_server_roles_and_best_practices.py diff --git a/LOGAR-LICENSING b/LOGAR-LICENSING index c5ba1d6..8068652 160000 --- a/LOGAR-LICENSING +++ b/LOGAR-LICENSING @@ -1 +1 @@ -Subproject commit c5ba1d65b518fbc369ecd5c5d86ba68f7da9147a +Subproject commit 80686525732934920fb9302a899ad3748921cc2f diff --git a/src/clients/Win_Client.py b/src/clients/Win_Client.py index da04fc1..85206c7 100644 --- a/src/clients/Win_Client.py +++ b/src/clients/Win_Client.py @@ -88,14 +88,25 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li return collector.collect_logs(hours=hours) +try: + from src.common.system_info import get_os_details +except ImportError: + try: + from common.system_info import get_os_details + except ImportError: + get_os_details = lambda: {"os_type": "windows"} + + def send_encrypted_logs_over_socket(config: dict, logs: list): """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" - return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows") + node_os = get_os_details().get("os_type", "windows") + return transport_send_encrypted_logs(config=config, logs=logs, os_type=node_os) def ensure_enrolled(config: dict, machine_id: str) -> bool: """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" - return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows") + node_os = get_os_details().get("os_type", "windows") + return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type=node_os) def main(): diff --git a/src/clients/client_transport.py b/src/clients/client_transport.py index 4f59aee..a62a3be 100644 --- a/src/clients/client_transport.py +++ b/src/clients/client_transport.py @@ -52,6 +52,18 @@ except ImportError: except ImportError: from client_updater import ClientUpdater +try: + from src.common.system_info import get_full_system_inventory, get_os_details +except ImportError: + try: + from common.system_info import get_full_system_inventory, get_os_details + except ImportError: + try: + from system_info import get_full_system_inventory, get_os_details + except ImportError: + get_full_system_inventory = lambda: {} + get_os_details = lambda: {"os_type": "windows" if sys.platform == "win32" else "linux", "os_name": "", "is_server": False} + CONFIG_FILE_NAME = "client_config.json" @@ -96,10 +108,16 @@ def enroll_client_if_needed( action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling" print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...") enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll" + inventory = get_full_system_inventory() + effective_os = inventory.get("os_type") or os_type payload = { "client_id": client_id, "hostname": hostname, - "os": os_type, + "os": effective_os, + "os_name": inventory.get("os_name", ""), + "is_server": inventory.get("is_server", False), + "server_roles": inventory.get("server_roles", []), + "client_info": inventory, "enrollment_secret": enrollment_secret, "version": LOGAR_VERSION } @@ -271,12 +289,18 @@ def send_encrypted_logs_over_socket( raise with sock: + inventory = get_full_system_inventory() payload = { "server": actual_machine_id, "timestamp": datetime.now(timezone.utc).isoformat(), "logs": logs, "metrics": collect_system_metrics(), - "version": LOGAR_VERSION + "version": LOGAR_VERSION, + "os_type": inventory.get("os_type", node_os), + "os_name": inventory.get("os_name", ""), + "is_server": inventory.get("is_server", False), + "server_roles": inventory.get("server_roles", []), + "client_info": inventory } payload_bytes = json.dumps(payload).encode("utf-8") frame = struct.pack(">I", len(payload_bytes)) + payload_bytes diff --git a/src/common/__init__.py b/src/common/__init__.py index 51dc656..991cd98 100644 --- a/src/common/__init__.py +++ b/src/common/__init__.py @@ -1 +1,8 @@ # LOGAR Common Package +from src.common.system_info import ( + get_os_details, + get_installed_server_roles, + get_full_system_inventory, + get_ip_addresses +) +from src.common.version import LOGAR_VERSION diff --git a/src/common/system_info.py b/src/common/system_info.py new file mode 100644 index 0000000..e96b49e --- /dev/null +++ b/src/common/system_info.py @@ -0,0 +1,323 @@ +""" +LOGAR System & Server Role Introspection Module +Provides cross-platform OS classification (Windows Client vs Windows Server vs Linux), +installed Server Role detection, and comprehensive host inventory telemetry. +""" + +import os +import sys +import json +import socket +import shutil +import platform +import subprocess +from typing import Dict, Any, List, Optional + + +def get_ip_addresses() -> List[str]: + """Retrieves all non-loopback IPv4 addresses assigned to the local host.""" + ips = set() + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None, socket.AF_INET): + ip = info[4][0] + if ip and not ip.startswith("127."): + ips.add(ip) + except Exception: + pass + + try: + # Fallback via outbound UDP socket probe (doesn't send traffic) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.1) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + if local_ip and not local_ip.startswith("127."): + ips.add(local_ip) + except Exception: + pass + + return sorted(list(ips)) + + +def get_os_details() -> Dict[str, Any]: + """ + Accurately classifies the operating system, explicitly differentiating between + Windows Client (Workstation) and Windows Server, as well as Linux distributions. + + Returns: + { + "os_type": "windows_server" | "windows" | "linux" | "darwin" | "unknown", + "os_name": str, # e.g. "Windows Server 2022 Datacenter", "Windows 11 Pro", "Ubuntu 22.04 LTS" + "is_server": bool, # True for Windows Server or Linux Server; False for desktop clients + "edition": str, # e.g. "ServerDatacenter", "Professional", "Enterprise" + "build": str, # OS build number + "architecture": str, # "AMD64", "x86_64", "ARM64" + "hostname": str, + "ip_addresses": List[str] + } + """ + is_server = False + os_type = "unknown" + os_name = platform.platform() + edition = "" + build = platform.version() + arch = platform.machine() or ("64-bit" if sys.maxsize > 2**32 else "32-bit") + + if sys.platform == "win32": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") as key: + pname = str(winreg.QueryValueEx(key, "ProductName")[0]) + try: + itype = str(winreg.QueryValueEx(key, "InstallationType")[0]) + except Exception: + itype = "" + try: + edition = str(winreg.QueryValueEx(key, "EditionID")[0]) + except Exception: + pass + try: + build = str(winreg.QueryValueEx(key, "CurrentBuild")[0]) + except Exception: + pass + + os_name = pname + # Check for Windows Server installation type or product name + if itype.strip().lower() == "server" or "server" in pname.lower(): + is_server = True + os_type = "windows_server" + else: + is_server = False + os_type = "windows" + except Exception: + # Fallback for Windows + win_ver = platform.win32_ver() + os_name = f"Windows {win_ver[0]}" + if "server" in os_name.lower(): + is_server = True + os_type = "windows_server" + else: + is_server = False + os_type = "windows" + + elif sys.platform.startswith("linux"): + os_type = "linux" + is_server = True # Linux in enterprise log forwarding contexts is typically server/node + if os.path.exists("/etc/os-release"): + try: + with open("/etc/os-release", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("PRETTY_NAME="): + os_name = line.strip().split("=", 1)[1].strip('"\'') + break + except Exception: + pass + elif sys.platform == "darwin": + os_type = "darwin" + is_server = False + os_name = f"macOS {platform.mac_ver()[0]}" + else: + os_type = sys.platform + is_server = False + + return { + "os_type": os_type, + "os_name": os_name, + "is_server": is_server, + "edition": edition, + "build": build, + "architecture": arch, + "hostname": socket.gethostname(), + "ip_addresses": get_ip_addresses() + } + + +def get_installed_windows_server_roles() -> List[str]: + """ + Detects installed Windows Server roles and major features. + Combines authoritative PowerShell ServerManager cmdlets on Windows Server with + resilient service & registry checks. + """ + if sys.platform != "win32": + return [] + + roles = set() + + # 1. Authoritative PowerShell query on Windows Server + try: + ps_cmd = ( + "try { " + "Get-WindowsFeature | Where-Object { $_.Installed -eq $true -and ($_.FeatureType -eq 'Role' -or ($_.Parent -eq '' -and $_.Installed -eq $true)) } | Select-Object -ExpandProperty DisplayName " + "} catch { }" + ) + proc = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], + capture_output=True, + text=True, + timeout=6 + ) + if proc.returncode == 0 and proc.stdout: + for line in proc.stdout.splitlines(): + role_name = line.strip() + if role_name and len(role_name) > 2: + roles.add(role_name) + except Exception: + pass + + # 2. Service and Registry Inspection (Instant fallback and verification) + ROLE_SERVICE_MAP = { + "NTDS": "Active Directory Domain Services", + "DNS": "DNS Server", + "DHCPServer": "DHCP Server", + "W3SVC": "Web Server (IIS)", + "CertSvc": "Active Directory Certificate Services", + "vmms": "Hyper-V", + "TermService": "Remote Desktop Services", + "LanmanServer": "File and Storage Services", + "WsusService": "Windows Server Update Services (WSUS)", + "ClusSvc": "Failover Clustering", + "RemoteAccess": "Remote Access / DirectAccess", + "ADAM": "Active Directory Lightweight Directory Services", + "adfssrv": "Active Directory Federation Services", + "IAS": "Network Policy Server (NPS)", + "Spooler": "Print and Document Services", + "MSMQ": "Message Queuing (MSMQ)", + } + + try: + import winreg + for svc_name, role_title in ROLE_SERVICE_MAP.items(): + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, rf"SYSTEM\CurrentControlSet\Services\{svc_name}") as k: + # Key exists; check if service is enabled (Start != 4 disabled) + try: + start_type = winreg.QueryValueEx(k, "Start")[0] + if start_type in (2, 3): # 2=Auto, 3=Manual + # For LanmanServer / Spooler / TermService, only count as dedicated role if on Server + roles.add(role_title) + except Exception: + roles.add(role_title) + except (FileNotFoundError, OSError): + pass + except Exception: + pass + + # Clean up and normalize role names + return sorted(list(roles)) + + +def get_installed_linux_server_roles() -> List[str]: + """Detects installed server roles and major service daemons on Linux hosts.""" + if not sys.platform.startswith("linux"): + return [] + + roles = set() + SERVICE_MAP = { + "named": "DNS Server (BIND)", + "bind9": "DNS Server (BIND)", + "dnsmasq": "DNS & DHCP Server (dnsmasq)", + "isc-dhcp-server": "DHCP Server", + "nginx": "Web Server (Nginx)", + "apache2": "Web Server (Apache)", + "httpd": "Web Server (Apache)", + "sshd": "SSH Remote Access", + "smbd": "File Server (Samba/SMB)", + "nfs-server": "File Server (NFS)", + "docker": "Container Host (Docker)", + "containerd": "Container Runtime (containerd)", + "podman": "Container Engine (Podman)", + "postgresql": "Database Server (PostgreSQL)", + "mariadb": "Database Server (MariaDB)", + "mysql": "Database Server (MySQL)", + "postfix": "Mail Transfer Agent (Postfix)", + "dovecot": "Mail Delivery Agent (Dovecot)", + "haproxy": "Load Balancer (HAProxy)", + "keepalived": "High Availability (Keepalived)", + "ufw": "Host Firewall (UFW)", + "firewalld": "Host Firewall (firewalld)", + } + + for svc, title in SERVICE_MAP.items(): + if shutil.which(svc): + roles.add(title) + + return sorted(list(roles)) + + +def get_installed_server_roles() -> List[str]: + """Cross-platform dispatcher for installed server roles and features.""" + if sys.platform == "win32": + return get_installed_windows_server_roles() + elif sys.platform.startswith("linux"): + return get_installed_linux_server_roles() + return [] + + +def get_full_system_inventory() -> Dict[str, Any]: + """ + Gathers comprehensive hardware, OS, network, and server role inventory. + """ + os_info = get_os_details() + roles = get_installed_server_roles() if os_info.get("is_server") else [] + + # Disk capacity + disk_total_gb = 0.0 + disk_free_gb = 0.0 + disk_pct_free = 0.0 + try: + path = "C:\\" if sys.platform == "win32" else "/" + total, _, free = shutil.disk_usage(path) + disk_total_gb = round(total / (1024 ** 3), 2) + disk_free_gb = round(free / (1024 ** 3), 2) + disk_pct_free = round((free / total) * 100, 1) if total > 0 else 0.0 + except Exception: + pass + + # Memory + mem_total_mb = 0 + try: + if sys.platform == "win32": + import ctypes + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("sullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + stat = MEMORYSTATUSEX() + stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX) + if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): + mem_total_mb = int(stat.ullTotalPhys / (1024 * 1024)) + elif os.path.exists("/proc/meminfo"): + 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 + except Exception: + pass + + return { + "hostname": os_info.get("hostname"), + "os_type": os_info.get("os_type"), + "os_name": os_info.get("os_name"), + "is_server": os_info.get("is_server"), + "edition": os_info.get("edition"), + "build": os_info.get("build"), + "architecture": os_info.get("architecture"), + "cpu_count": os.cpu_count() or 1, + "memory_total_mb": mem_total_mb, + "disk_total_gb": disk_total_gb, + "disk_free_gb": disk_free_gb, + "disk_percent_free": disk_pct_free, + "ip_addresses": os_info.get("ip_addresses", []), + "server_roles": roles + } diff --git a/src/hub/Server.py b/src/hub/Server.py index 9a62813..db3accc 100644 --- a/src/hub/Server.py +++ b/src/hub/Server.py @@ -102,6 +102,22 @@ from src.license_manager import LicenseManager from src.pki_manager import PKIManager from src.web_ui import WebUIRenderer, format_relative_time +try: + from src.common.system_info import get_full_system_inventory, get_os_details +except ImportError: + try: + from common.system_info import get_full_system_inventory, get_os_details + except ImportError: + from system_info import get_full_system_inventory, get_os_details + +try: + from src.hub.best_practices import BEST_PRACTICES_ENGINE +except ImportError: + try: + from best_practices import BEST_PRACTICES_ENGINE + except ImportError: + BEST_PRACTICES_ENGINE = None + CONFIG_FILE_NAME = "server_config.json" DEFAULT_DB_FILE = "logar_state.db" @@ -939,9 +955,15 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i processed_count += 1 - # Update client node metrics & version if attached + # Update client node metrics, version & server role introspection if attached metrics = payload.get("metrics") client_ver = payload.get("version") + payload_ost = payload.get("os_type") + payload_os_name = payload.get("os_name") + payload_is_srv = payload.get("is_server") + payload_roles = payload.get("server_roles") + payload_info = payload.get("client_info") + 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)) @@ -969,6 +991,24 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i except Exception: pass + # Update OS details, server roles and client info telemetry if provided + if (payload_os_name or payload_roles is not None or payload_info is not None or payload_is_srv is not None or payload_ost) and client_server: + roles_val = json.dumps(payload_roles) if isinstance(payload_roles, list) else (str(payload_roles) if payload_roles is not None else None) + info_val = json.dumps(payload_info) if isinstance(payload_info, dict) else (str(payload_info) if payload_info is not None else None) + is_srv_val = 1 if payload_is_srv else (0 if payload_is_srv is not None else (1 if payload_ost == "windows_server" else None)) + try: + cursor.execute(""" + UPDATE clients + SET os_type = COALESCE(?, os_type), + os_name = COALESCE(NULLIF(?, ''), os_name), + is_server = COALESCE(?, is_server), + server_roles = COALESCE(?, server_roles), + client_info_json = COALESCE(?, client_info_json) + WHERE client_id = ? OR hostname = ? + """, (payload_ost, payload_os_name, is_srv_val, roles_val, info_val, client_server, client_server)) + except Exception: + pass + conn.commit() conn.close() @@ -997,8 +1037,17 @@ def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int: """Executes one cycle of local host log collection and direct ingestion into SQLite.""" + try: + inv = get_full_system_inventory() if "get_full_system_inventory" in globals() else {} + except Exception: + inv = {} + machine_id = get_machine_identifier() - os_type = "windows" if sys.platform == "win32" else "linux" + os_type = inv.get("os_type", "windows" if sys.platform == "win32" else "linux") + os_name = inv.get("os_name", "") + is_server = 1 if inv.get("is_server") else (1 if os_type == "windows_server" else 0) + server_roles = json.dumps(inv.get("server_roles", [])) + client_info_json = json.dumps(inv) now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") try: @@ -1006,16 +1055,30 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run c = conn.cursor() c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) if c.fetchone(): - c.execute(""" - UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? - WHERE client_id = ? - """, (machine_id, os_type, now_sql, machine_id)) + try: + 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 = ? + WHERE client_id = ? + """, (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json, machine_id)) + except sqlite3.OperationalError: + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? + WHERE client_id = ? + """, (machine_id, os_type, now_sql, machine_id)) else: - c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) - VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) - """, (machine_id, machine_id, os_type, now_sql, now_sql)) + 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)) + except sqlite3.OperationalError: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) + VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) + """, (machine_id, machine_id, os_type, now_sql, now_sql)) conn.commit() conn.close() except Exception as e: @@ -1394,30 +1457,49 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") client_ver = req.version or "" + req_os_name = req.os_name or "" + 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 "{}" + if existing: try: c.execute(""" UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?, + os_name = ?, is_server = ?, server_roles = ?, client_info_json = ? WHERE client_id = ? - """, (req.hostname, req.os, fp, now_sql, client_ver, req.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)) 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)) + 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: 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)) + 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)) 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)) + 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() @@ -2484,6 +2566,171 @@ def serve_webui(request: Request = None): return HTMLResponse(content=html_content) +@app.get("/client-info", response_class=HTMLResponse) +@app.get("/webui/client-info", response_class=HTMLResponse) +def serve_client_info(request: Request = None): + """Renders the dedicated LOGAR Client & Server Role Directory subpage.""" + auth_user = None + if request is not None: + auth_user = get_current_user_optional(request) + if not auth_user: + return RedirectResponse(url="/login", status_code=303) + else: + auth_user = DEFAULT_ADMIN_USER + + html_content = WebUIRenderer.render_client_info_html( + auth_user=auth_user, + request=request, + server_state=SERVER_STATE + ) + return HTMLResponse(content=html_content) + + +@app.get("/api/client-info") +def api_client_info(request: Request = None, auth_user: str = Depends(require_admin_auth)): + """API endpoint providing comprehensive host telemetry, OS classification, and server roles.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute(""" + SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, + COALESCE(client_version, ''), COALESCE(os_name, ''), COALESCE(is_server, 0), + COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'), + COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0) + FROM clients + ORDER BY is_server DESC, last_seen DESC + """) + rows = c.fetchall() + except Exception as e: + conn.close() + raise HTTPException(status_code=500, detail=f"Database query error: {str(e)}") + conn.close() + + clients_list = [] + win_servers = 0 + win_workstations = 0 + linux_nodes = 0 + + for r in rows: + cid, host, ost, fp, st, first, last, cver, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p = r + try: + roles = json.loads(roles_raw) if roles_raw else [] + except Exception: + roles = [] + try: + info = json.loads(info_raw) if info_raw else {} + except Exception: + info = {} + + is_srv_bool = bool(is_srv) or ost == "windows_server" + if "server" in ost or is_srv_bool: + if "windows" in ost: + win_servers += 1 + else: + linux_nodes += 1 + elif "windows" in ost: + win_workstations += 1 + elif "linux" in ost: + linux_nodes += 1 + + clients_list.append({ + "client_id": cid, + "hostname": host, + "os_type": ost, + "os_name": os_name, + "is_server": is_srv_bool, + "server_roles": roles, + "client_info": info, + "cert_fingerprint": fp, + "status": st, + "first_seen": first, + "last_seen": last, + "client_version": cver, + "metrics": { + "cpu_percent": cpu_p, + "memory_percent": mem_p, + "disk_percent_free": disk_p + } + }) + + return { + "status": "success", + "total_nodes": len(clients_list), + "windows_servers": win_servers, + "windows_workstations": win_workstations, + "linux_nodes": linux_nodes, + "clients": clients_list + } + + +@app.get("/api/best-practices/status") +def api_best_practices_status(request: Request = None, auth_user: str = Depends(require_admin_auth)): + """API endpoint providing fleet-wide Best Practice baseline compliance evaluation.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + is_licensed = is_feature_licensed("best_practices", db_path) + + if not is_licensed: + return { + "status": "locked", + "licensed": False, + "message": "The Best Practice Analyzer is an enterprise capability that requires an active license with the 'best_practices' feature enabled.", + "rules_available": len(BEST_PRACTICES_ENGINE.get_rules()) if BEST_PRACTICES_ENGINE else 0 + } + + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute(""" + SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, + COALESCE(client_version, ''), COALESCE(os_name, ''), COALESCE(is_server, 0), + COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'), + COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0), + COALESCE(update_status, 'up_to_date') + FROM clients + WHERE status = 'active' + """) + rows = c.fetchall() + except Exception: + rows = [] + finally: + conn.close() + + node_records = [] + for r in rows: + cid, host, ost, fp, st, first, last, cver, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p, up_st = r + try: + roles = json.loads(roles_raw) if roles_raw else [] + except Exception: + roles = [] + try: + info = json.loads(info_raw) if info_raw else {} + except Exception: + info = {} + + node_records.append({ + "client_id": cid, + "hostname": host, + "os_type": ost, + "os_name": os_name, + "is_server": bool(is_srv) or ost == "windows_server", + "server_roles": roles, + "client_info": info, + "cpu_percent": cpu_p, + "memory_percent": mem_p, + "disk_percent_free": disk_p, + "client_version": cver, + "update_status": up_st + }) + + eval_result = BEST_PRACTICES_ENGINE.evaluate_fleet(node_records) if BEST_PRACTICES_ENGINE else {} + return { + "status": "active", + "licensed": True, + "evaluation": eval_result + } + + @app.get("/health") def health_check(): return { diff --git a/src/hub/best_practices.py b/src/hub/best_practices.py new file mode 100644 index 0000000..ba70f64 --- /dev/null +++ b/src/hub/best_practices.py @@ -0,0 +1,278 @@ +""" +LOGAR Best Practice Analyzer & Security Baseline Engine +Provides a modular, extensible framework for verifying server configuration baselines, +role-specific best practices, and security hardening across enrolled fleet nodes. +Gated by the 'best_practices' enterprise license capability. +""" + +import json +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional, Callable + + +class BestPracticeRule: + """Represents a single modular Best Practice configuration check.""" + + def __init__( + self, + rule_id: str, + category: str, + title: str, + description: str, + recommendation: str, + severity: str = "WARNING", # "CRITICAL" | "WARNING" | "RECOMMENDED" + target_role: Optional[str] = None, + target_os: Optional[str] = None, + eval_fn: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None + ): + self.rule_id = rule_id + self.category = category + self.title = title + self.description = description + self.recommendation = recommendation + self.severity = severity + self.target_role = target_role + self.target_os = target_os + self.eval_fn = eval_fn + + def to_dict(self) -> Dict[str, Any]: + return { + "rule_id": self.rule_id, + "category": self.category, + "title": self.title, + "description": self.description, + "recommendation": self.recommendation, + "severity": self.severity, + "target_role": self.target_role, + "target_os": self.target_os + } + + +class BestPracticeEngine: + """Evaluates rules across enrolled server nodes and collects compliance telemetry.""" + + def __init__(self): + self._rules: Dict[str, BestPracticeRule] = {} + self._load_baseline_rules() + + def register_rule(self, rule: BestPracticeRule): + """Registers a custom Best Practice check. Enables users to define rules later on.""" + self._rules[rule.rule_id] = rule + + def get_rules(self) -> List[Dict[str, Any]]: + return [r.to_dict() for r in self._rules.values()] + + def _load_baseline_rules(self): + # 1. Disk Capacity Health + def eval_disk_space(node: Dict[str, Any]) -> Dict[str, Any]: + pct = float(node.get("disk_percent_free") or 0.0) + if pct <= 0.0: + return {"status": "NOT_APPLICABLE", "message": "Disk capacity metrics pending telemetry collection."} + if pct < 10.0: + return {"status": "FAIL", "message": f"Critical disk headroom: only {pct}% free capacity remaining on primary volume."} + if pct < 20.0: + return {"status": "WARN", "message": f"Low disk headroom: {pct}% free capacity remaining (recommended >= 20%)."} + return {"status": "PASS", "message": f"Healthy disk capacity: {pct}% free capacity available."} + + self.register_rule(BestPracticeRule( + rule_id="BP-SYS-001", + category="System Health", + title="System Volume Storage Headroom (> 20% Free)", + description="Verifies that server and workstation system drives retain at least 20% free space to ensure unhindered OS paging, log buffering, and update installation.", + recommendation="Expand volume storage or clear transient files to maintain healthy write headroom.", + severity="CRITICAL", + eval_fn=eval_disk_space + )) + + # 2. Memory Headroom + def eval_memory_load(node: Dict[str, Any]) -> Dict[str, Any]: + pct = float(node.get("memory_percent") or 0.0) + if pct <= 0.0: + return {"status": "NOT_APPLICABLE", "message": "Memory metrics pending telemetry collection."} + if pct >= 95.0: + return {"status": "FAIL", "message": f"Exhausted memory headroom: {pct}% active memory consumption."} + if pct >= 88.0: + return {"status": "WARN", "message": f"Elevated memory load: {pct}% utilization (recommended < 85%)."} + return {"status": "PASS", "message": f"Memory utilization optimal: {pct}% in use."} + + self.register_rule(BestPracticeRule( + rule_id="BP-SYS-002", + category="System Health", + title="Memory Saturation & Buffer Safety (< 85% Load)", + description="Ensures system RAM utilization remains below critical saturation thresholds to prevent kernel thread swapping and out-of-memory service crashes.", + recommendation="Review high-consumption service processes or allocate additional physical RAM to this server.", + severity="WARNING", + eval_fn=eval_memory_load + )) + + # 3. Server Role Segregation + def eval_dc_co_location(node: Dict[str, Any]) -> Dict[str, Any]: + roles = node.get("server_roles") or [] + if isinstance(roles, str): + try: + roles = json.loads(roles) + except Exception: + roles = [r.strip() for r in roles.split(",") if r.strip()] + + roles_lower = [str(r).lower() for r in roles] + has_ad = any("active directory" in r for r in roles_lower) + if not has_ad: + return {"status": "NOT_APPLICABLE", "message": "Node is not designated as an Active Directory Domain Controller."} + + # Check for high-risk co-located roles on Domain Controllers + conflicts = [] + if any("web server" in r or "iis" in r for r in roles_lower): + conflicts.append("Web Server (IIS)") + if any("remote desktop" in r or "terminal" in r for r in roles_lower): + conflicts.append("Remote Desktop Services") + if any("print" in r for r in roles_lower): + conflicts.append("Print & Document Services") + + if conflicts: + return {"status": "FAIL", "message": f"Active Directory Domain Controller co-locates non-recommended roles: {', '.join(conflicts)}."} + return {"status": "PASS", "message": "Domain Controller adheres to dedicated role isolation standards."} + + self.register_rule(BestPracticeRule( + rule_id="BP-AD-001", + category="Active Directory", + title="Domain Controller Role Isolation Baseline", + description="Domain Controllers should be dedicated solely to identity services. Co-locating IIS, Remote Desktop, or Print Spooler introduces lateral privilege escalation attack vectors.", + recommendation="Migrate auxiliary web and print services to separate member servers.", + severity="CRITICAL", + target_role="Active Directory Domain Services", + target_os="windows_server", + eval_fn=eval_dc_co_location + )) + + # 4. Windows Server Detection and Role Discovery + def eval_server_classification(node: Dict[str, Any]) -> Dict[str, Any]: + is_server = bool(node.get("is_server")) + os_type = str(node.get("os_type", "")).lower() + os_name = str(node.get("os_name", "")) + roles = node.get("server_roles") or [] + if isinstance(roles, str): + try: + roles = json.loads(roles) + except Exception: + roles = [] + + if "server" in os_type or "server" in os_name.lower(): + if len(roles) > 0: + return {"status": "PASS", "message": f"Windows Server edition identified ({os_name}) with {len(roles)} detected enterprise roles."} + else: + return {"status": "WARN", "message": f"Windows Server identified ({os_name}), but no server roles currently indexed."} + elif os_type in ("windows", "workstation"): + return {"status": "PASS", "message": f"Workstation node ({os_name}) correctly classified without server role footprint."} + return {"status": "PASS", "message": f"Host classification verified: {os_name or os_type}."} + + self.register_rule(BestPracticeRule( + rule_id="BP-WIN-002", + category="Role Baseline", + title="Operating System Edition & Server Role Alignment", + description="Verifies that nodes are accurately differentiated between client workstations and enterprise servers, and that active server roles are fully inventoried.", + recommendation="Ensure the LOGAR edge forwarder runs with sufficient permissions to query Windows Server Manager.", + severity="RECOMMENDED", + eval_fn=eval_server_classification + )) + + # 5. Forwarder Agent Freshness + def eval_agent_version(node: Dict[str, Any]) -> Dict[str, Any]: + ver = str(node.get("client_version") or "").strip() + up_status = str(node.get("update_status") or "up_to_date").lower() + if not ver: + return {"status": "WARN", "message": "Client version not yet reported to hub."} + if up_status == "outdated": + return {"status": "WARN", "message": f"Edge client version (v{ver}) is outdated relative to hosted hub binaries."} + return {"status": "PASS", "message": f"Edge forwarder binary (v{ver}) is current."} + + self.register_rule(BestPracticeRule( + rule_id="BP-SEC-003", + category="Security Baseline", + title="Edge Forwarder Binary Version Baseline", + description="Ensures forwarder nodes run the latest patched agent binary with current TLS 1.3 cryptographic cipher suites and security fixes.", + recommendation="Enable Fleet Auto-Update or update the forwarder executable to the latest hosted release.", + severity="RECOMMENDED", + eval_fn=eval_agent_version + )) + + def evaluate_node(self, node: Dict[str, Any]) -> List[Dict[str, Any]]: + """Evaluates all applicable rules against a single server/node record.""" + results = [] + for rule in self._rules.values(): + # Check target OS constraint + if rule.target_os and str(node.get("os_type", "")).lower() != rule.target_os.lower(): + continue + + # Check target role constraint + if rule.target_role: + roles = node.get("server_roles") or [] + if isinstance(roles, str): + try: + roles = json.loads(roles) + except Exception: + roles = [r.strip() for r in roles.split(",") if r.strip()] + roles_str = " ".join(str(r) for r in roles).lower() + if rule.target_role.lower() not in roles_str: + continue + + eval_res = {"status": "PASS", "message": "Check passed baseline standards."} + if rule.eval_fn: + try: + eval_res = rule.eval_fn(node) + except Exception as ex: + eval_res = {"status": "ERROR", "message": f"Evaluation error: {str(ex)}"} + + results.append({ + "rule_id": rule.rule_id, + "category": rule.category, + "title": rule.title, + "severity": rule.severity, + "recommendation": rule.recommendation, + "status": eval_res.get("status", "PASS"), + "message": eval_res.get("message", "") + }) + return results + + def evaluate_fleet(self, nodes: List[Dict[str, Any]]) -> Dict[str, Any]: + """Evaluates all registered rules across all fleet nodes and returns aggregated summary.""" + fleet_results = {} + pass_count = 0 + warn_count = 0 + fail_count = 0 + + for node in nodes: + cid = node.get("client_id") or node.get("hostname") or "unknown" + node_checks = self.evaluate_node(node) + fleet_results[cid] = { + "hostname": node.get("hostname", cid), + "os_type": node.get("os_type", "unknown"), + "os_name": node.get("os_name", ""), + "is_server": bool(node.get("is_server")), + "checks": node_checks + } + for chk in node_checks: + st = chk.get("status") + if st == "PASS": + pass_count += 1 + elif st == "WARN": + warn_count += 1 + elif st == "FAIL": + fail_count += 1 + + total_evaluated = pass_count + warn_count + fail_count + score = int((pass_count / total_evaluated * 100)) if total_evaluated > 0 else 100 + + return { + "score": score, + "pass_count": pass_count, + "warn_count": warn_count, + "fail_count": fail_count, + "total_evaluated": total_evaluated, + "evaluated_nodes_count": len(nodes), + "fleet_results": fleet_results, + "rules_count": len(self._rules) + } + + +# Global singleton instance +BEST_PRACTICES_ENGINE = BestPracticeEngine() diff --git a/src/hub/server_models.py b/src/hub/server_models.py index 7b7574b..9fa99fa 100644 --- a/src/hub/server_models.py +++ b/src/hub/server_models.py @@ -13,6 +13,10 @@ class ClientEnrollRequest(BaseModel): os: str enrollment_secret: str version: Optional[str] = None + os_name: Optional[str] = None + is_server: Optional[bool] = None + server_roles: Optional[List[str]] = None + client_info: Optional[Dict[str, Any]] = None class ClientUpdateRequest(BaseModel): diff --git a/src/hub/storage_engine.py b/src/hub/storage_engine.py index 5b23d86..17efe42 100644 --- a/src/hub/storage_engine.py +++ b/src/hub/storage_engine.py @@ -233,7 +233,11 @@ class StorageEngine: ("disk_percent_free", "REAL DEFAULT 0.0"), ("client_version", "TEXT DEFAULT ''"), ("update_status", "TEXT DEFAULT 'up_to_date'"), - ("last_update_check", "TEXT") + ("last_update_check", "TEXT"), + ("os_name", "TEXT DEFAULT ''"), + ("is_server", "INTEGER DEFAULT 0"), + ("server_roles", "TEXT DEFAULT '[]'"), + ("client_info_json", "TEXT DEFAULT '{}'") ]) # 5. Users Table @@ -429,7 +433,11 @@ class StorageEngine: ("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)") + ("last_update_check", "VARCHAR(50)"), + ("os_name", "VARCHAR(255) DEFAULT ''"), + ("is_server", "INTEGER DEFAULT 0"), + ("server_roles", "TEXT DEFAULT '[]'"), + ("client_info_json", "TEXT DEFAULT '{}'") ]: try: cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") diff --git a/src/hub/web_ui.py b/src/hub/web_ui.py index 5feeb33..e80ca1f 100644 --- a/src/hub/web_ui.py +++ b/src/hub/web_ui.py @@ -19,6 +19,14 @@ try: except ImportError: from version import LOGAR_VERSION, is_newer_version +try: + from src.hub.best_practices import BEST_PRACTICES_ENGINE +except ImportError: + try: + from best_practices import BEST_PRACTICES_ENGINE + except ImportError: + BEST_PRACTICES_ENGINE = None + def format_relative_time(dt_str: Optional[str]) -> str: """Calculates human-readable relative time from ISO or SQL timestamp string.""" @@ -205,11 +213,20 @@ class WebUIRenderer: conn = sqlite3.connect(db_path) c = conn.cursor() 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") + c.execute(""" + SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, + COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date'), + COALESCE(os_name, ''), COALESCE(is_server, 0), COALESCE(server_roles, '[]') + 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()] + 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 = [r + ("", 0, "[]") for r in 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", "", 0, "[]") 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") @@ -225,10 +242,18 @@ class WebUIRenderer: hermes_tokens = c.fetchall() conn.close() - # Build client rows with exact + relative activity time, version badges, and seat management actions + # Build client rows with exact + relative activity time, version badges, OS differentiation, and seat management actions client_rows = [] for cl in clients: cid, host, ost, fp, st, first, last, cver, up_st = cl[:9] + os_name = cl[9] if len(cl) > 9 else "" + is_server = bool(cl[10]) if len(cl) > 10 else (ost == "windows_server") + roles_raw = cl[11] if len(cl) > 11 else "[]" + try: + roles = json.loads(roles_raw) if roles_raw else [] + except Exception: + roles = [] + esc_cid = html.escape(str(cid)) esc_host = html.escape(str(host)) esc_ost = html.escape(str(ost)) @@ -246,6 +271,17 @@ class WebUIRenderer: else: ver_display = f'v{html.escape(cver)}' + # Operating System & Server Classification Badge + if ost == "windows_server" or is_server: + role_cnt_badge = f'{len(roles)} Roles' if roles else '' + os_badge_html = f'🏢 WIN SERVER{role_cnt_badge}' + elif "win" in ost.lower(): + os_badge_html = f'🖥️ WINDOWS' + elif "linux" in ost.lower(): + os_badge_html = f'🐧 LINUX' + else: + os_badge_html = f'{esc_ost.upper()}' + if fp == "LOCAL-HOST-BUILTIN": esc_st = "LOCAL HUB HOST" badge_cls = "badge-active" @@ -266,7 +302,7 @@ class WebUIRenderer: {esc_st.upper()} {esc_host} {esc_cid} - {esc_ost.upper()} + {os_badge_html} {ver_display} {fp_display} {last_display} @@ -424,6 +460,21 @@ class WebUIRenderer: """) + elif feat == "best_practices": + feature_cards.append(""" +
+
🛡️
+
+
+ best_practices + AUTHORIZED +
+
+ Best Practice & Security Baseline Analyzer: Unlocks automated audit checks, server role configuration validation, Domain Controller isolation checks, and hardening baselines across your fleet. +
+
+
+ """) else: esc_feat = html.escape(str(feat)) feat_title = html.escape(str(feat).replace("_", " ").title()) @@ -1071,6 +1122,7 @@ class WebUIRenderer:
+ 🖥️ Server Roles & Client Directory @@ -1125,7 +1177,8 @@ class WebUIRenderer:
Enrolled Forwarders & Seat Management -
+
+ 📋 Server Roles & Directory (/client-info)
@@ -2171,3 +2224,734 @@ class WebUIRenderer: """ return html_content + + @staticmethod + def render_client_info_html( + auth_user: Optional[str] = None, + request: Optional[Any] = None, + server_state: Optional[Dict[str, Any]] = None + ) -> str: + """ + Renders the dedicated LOGAR Client & Server Role Directory subpage (/client-info). + Explicitly differentiates Windows Client vs Windows Server vs Linux, + displays installed server roles, hardware specs, and Best Practice baseline compliance. + """ + state = server_state or {} + config = state.get("config", {}) + db_path = config.get("db_path", "logar_state.db") + auth_display = html.escape(str(auth_user or "admin")) + + # Check Best Practice licensing feature + is_bp_licensed = LicenseManager.is_feature_licensed("best_practices", db_path=db_path, server_state=state) + + # Query clients and host telemetry + conn = sqlite3.connect(db_path) + c = conn.cursor() + 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'), + COALESCE(os_name, ''), COALESCE(is_server, 0), + COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'), + COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0) + FROM clients + ORDER BY is_server DESC, last_seen DESC + """) + raw_clients = c.fetchall() + except sqlite3.OperationalError: + 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 + """) + raw_clients = [r + ("", 0, "[]", "{}", 0.0, 0.0, 0.0) for r in c.fetchall()] + except Exception: + raw_clients = [] + finally: + conn.close() + + clients_data = [] + win_servers = 0 + win_workstations = 0 + linux_nodes = 0 + total_roles_detected = 0 + + for r in raw_clients: + cid, host, ost, fp, st, first, last, cver, up_st, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p = r + try: + roles = json.loads(roles_raw) if roles_raw else [] + if not isinstance(roles, list): + roles = [str(roles)] + except Exception: + roles = [] + try: + info = json.loads(info_raw) if info_raw else {} + if not isinstance(info, dict): + info = {} + except Exception: + info = {} + + is_server_bool = bool(is_srv) or ost == "windows_server" + ost_lower = ost.lower() + + if "server" in ost_lower or is_server_bool: + if "windows" in ost_lower or "win" in ost_lower: + win_servers += 1 + else: + linux_nodes += 1 + elif "windows" in ost_lower or "win" in ost_lower: + win_workstations += 1 + elif "linux" in ost_lower: + linux_nodes += 1 + + total_roles_detected += len(roles) + + clients_data.append({ + "client_id": cid, + "hostname": host, + "os_type": ost, + "os_name": os_name, + "is_server": is_server_bool, + "server_roles": roles, + "client_info": info, + "cert_fingerprint": fp, + "status": st, + "first_seen": first, + "last_seen": last, + "client_version": cver, + "update_status": up_st, + "cpu_percent": float(cpu_p or 0.0), + "memory_percent": float(mem_p or 0.0), + "disk_percent_free": float(disk_p or 0.0) + }) + + total_nodes = len(clients_data) + + # Evaluate fleet Best Practices if licensed + bp_evaluation = None + if is_bp_licensed and BEST_PRACTICES_ENGINE: + try: + bp_evaluation = BEST_PRACTICES_ENGINE.evaluate_fleet(clients_data) + except Exception as ex: + bp_evaluation = {"score": 0, "pass_count": 0, "warn_count": 0, "fail_count": 0, "rules_count": 0, "error": str(ex), "fleet_results": {}} + + # Build Best Practice summary card HTML + if is_bp_licensed and bp_evaluation: + score = bp_evaluation.get("score", 100) + score_color = "#34d399" if score >= 90 else ("#fbbf24" if score >= 70 else "#f87171") + pass_cnt = bp_evaluation.get("pass_count", 0) + warn_cnt = bp_evaluation.get("warn_count", 0) + fail_cnt = bp_evaluation.get("fail_count", 0) + total_rules = bp_evaluation.get("rules_count", 0) + + # Build detailed checks overview + rules_list = BEST_PRACTICES_ENGINE.get_rules() if BEST_PRACTICES_ENGINE else [] + rule_rows = [] + for r in rules_list: + rid = html.escape(str(r.get("rule_id", ""))) + rtitle = html.escape(str(r.get("title", ""))) + rcat = html.escape(str(r.get("category", ""))) + rsev = html.escape(str(r.get("severity", "WARNING"))) + rrec = html.escape(str(r.get("recommendation", ""))) + rdesc = html.escape(str(r.get("description", ""))) + sev_badge = "badge-error" if rsev == "CRITICAL" else ("badge-warning" if rsev == "WARNING" else "badge-os") + + rule_rows.append(f""" +
+
+
+ {rid} + {rtitle} + {rsev} +
+ {rcat} +
+
{rdesc}
+
Recommendation: {rrec}
+
+ """) + + bp_rules_html = "".join(rule_rows) + + bp_banner_html = f""" +
+
+
+
🛡️
+
+
+

Best Practice & Server Role Baseline Analyzer

+ AUTHORIZED • ACTIVE +
+
+ Comprehensive security baseline, Active Directory role segregation, and storage buffer auditing active across {total_nodes} nodes. +
+
+
+
+
+
{score}%
+
Fleet Compliance Score
+
+
+ ✅ {pass_cnt} PASS + ⚠️ {warn_cnt} WARN + ❌ {fail_cnt} FAIL +
+
+
+ +
+ + ▶ View {total_rules} Active Baseline Audit Rules & Remediation Standards + +
+ {bp_rules_html} +
+
+
+ """ + bp_kpi_value = f"{score}%" + bp_kpi_sub = f"{pass_cnt} passing • {warn_cnt} warnings • {fail_cnt} failures" + bp_kpi_badge = f'{score}% COMPLIANT' + else: + bp_banner_html = """ +
+
+
+
🛡️
+
+
+

Best Practice Analyzer & Role Baseline Engine

+ ENTERPRISE CAPABILITY • INACTIVE +
+

+ Automated configuration baseline auditing, Active Directory role isolation checks (e.g. verifying Domain Controllers do not co-locate IIS or Remote Desktop), and fleet hardening standards require an active enterprise license with the best_practices capability enabled. +

+
+ 💡 This capability is inactive by default. You can define rules later on and activate it in the LOGAR Licensing Portal at any time. +
+
+
+ +
+
+ """ + bp_kpi_value = "INACTIVE" + bp_kpi_sub = "Requires 'best_practices' license capability" + bp_kpi_badge = 'LOCKED' + + # Build Host Rows + node_rows = [] + for node in clients_data: + cid = node["client_id"] + host = node["hostname"] + ost = node["os_type"] + os_name = node["os_name"] + is_srv = node["is_server"] + roles = node["server_roles"] + info = node["client_info"] + fp = node["cert_fingerprint"] + st = node["status"] + last = node["last_seen"] + cver = node["client_version"] + cpu_p = node["cpu_percent"] + mem_p = node["memory_percent"] + disk_p = node["disk_percent_free"] + + esc_cid = html.escape(str(cid)) + esc_host = html.escape(str(host)) + badge_st_cls = "badge-active" if st == "active" else "badge-revoked" + rel_time = format_relative_time(last) + + # Node Classification Badge & Filter Category + ost_lower = ost.lower() + if "server" in ost_lower or is_srv: + if "windows" in ost_lower or "win" in ost_lower: + filter_cat = "windows_server" + os_badge = '🏢 WINDOWS SERVER' + os_display_name = html.escape(os_name or "Windows Server") + else: + filter_cat = "linux" + os_badge = '🐧 LINUX SERVER' + os_display_name = html.escape(os_name or "Linux Node") + elif "windows" in ost_lower or "win" in ost_lower: + filter_cat = "windows_client" + os_badge = '🖥️ WINDOWS CLIENT' + os_display_name = html.escape(os_name or "Windows Workstation") + elif "linux" in ost_lower: + filter_cat = "linux" + os_badge = '🐧 LINUX' + os_display_name = html.escape(os_name or "Linux Node") + else: + filter_cat = "other" + os_badge = f'{html.escape(ost.upper())}' + os_display_name = html.escape(os_name or ost) + + edition_str = html.escape(str(info.get("edition") or "")) + build_str = html.escape(str(info.get("build") or "")) + arch_str = html.escape(str(info.get("architecture") or "")) + specs_sub = f"{edition_str} • Build {build_str} • {arch_str}".strip(" •") if (edition_str or build_str or arch_str) else "" + + # Server Roles Badges + role_badges = [] + if roles: + for r in roles: + esc_r = html.escape(str(r)) + r_lower = str(r).lower() + if "active directory" in r_lower or "domain" in r_lower: + role_badges.append(f'🌐 {esc_r}') + elif "dns" in r_lower: + role_badges.append(f'📡 {esc_r}') + elif "dhcp" in r_lower: + role_badges.append(f'📶 {esc_r}') + elif "web server" in r_lower or "iis" in r_lower or "nginx" in r_lower or "apache" in r_lower: + role_badges.append(f'🌍 {esc_r}') + elif "hyper-v" in r_lower or "kvm" in r_lower or "qemu" in r_lower: + role_badges.append(f'⚡ {esc_r}') + elif "remote desktop" in r_lower or "terminal" in r_lower or "ssh" in r_lower: + role_badges.append(f'🖥️ {esc_r}') + elif "file" in r_lower or "storage" in r_lower or "samba" in r_lower or "nfs" in r_lower: + role_badges.append(f'📁 {esc_r}') + elif "print" in r_lower or "cups" in r_lower: + role_badges.append(f'🖨️ {esc_r}') + else: + role_badges.append(f'⚙️ {esc_r}') + roles_cell_html = "".join(role_badges) + else: + roles_cell_html = 'Workstation / Member Node (0 Roles)' + + # Hardware specs & telemetry + ip_list = info.get("ip_addresses") or [] + ip_str = ", ".join(ip_list[:2]) if ip_list else "Local" + mem_tot = info.get("memory_total_mb") + mem_tot_str = f"{round(mem_tot / 1024, 1)} GB" if mem_tot else "" + cpu_cnt = info.get("cpu_count") + cpu_cnt_str = f"{cpu_cnt} Cores • " if cpu_cnt else "" + + cpu_bar_col = "#34d399" if cpu_p < 75 else ("#fbbf24" if cpu_p < 90 else "#f87171") + mem_bar_col = "#34d399" if mem_p < 75 else ("#fbbf24" if mem_p < 90 else "#f87171") + + telemetry_html = f""" +
+
CPU: {cpu_cnt_str}{cpu_p}%
+
RAM: {mem_tot_str} ({mem_p}% in use)
+
Disk Free: {disk_p}%
+
{html.escape(ip_str)}
+
+ """ + + # Builtin indicator + if fp == "LOCAL-HOST-BUILTIN": + host_tag = '⚡ LOCAL HUB' + else: + host_tag = '' + + # Best Practice node compliance result + if is_bp_licensed and bp_evaluation and "fleet_results" in bp_evaluation: + node_eval = bp_evaluation["fleet_results"].get(cid) or bp_evaluation["fleet_results"].get(host) + if node_eval: + n_checks = node_eval.get("checks", []) + n_fails = sum(1 for c in n_checks if c.get("status") == "FAIL") + n_warns = sum(1 for c in n_checks if c.get("status") == "WARN") + n_passes = sum(1 for c in n_checks if c.get("status") == "PASS") + if n_fails > 0: + bp_node_badge = f'❌ {n_fails} FAIL' + elif n_warns > 0: + bp_node_badge = f'⚠️ {n_warns} WARN' + else: + bp_node_badge = f'✅ {n_passes} PASS' + else: + bp_node_badge = '✅ HEALTHY' + else: + bp_node_badge = '🔒 Locked' + + # Client info JSON payload for inspect modal + safe_info_json = html.escape(json.dumps(node, indent=2)) + + node_rows.append(f""" + + +
+ {st.upper()} +
+ + +
{esc_host}{host_tag}
+
{esc_cid}
+ + +
{os_badge}
+
{os_display_name}
+
{specs_sub}
+ + +
+ {roles_cell_html} +
+ + {telemetry_html} + +
v{html.escape(str(cver or LOGAR_VERSION))}
+
{html.escape(rel_time)}
+
{html.escape(str(last[:19]))}
+ + {bp_node_badge} + + + + + """) + + nodes_table_html = "".join(node_rows) if node_rows else 'No monitored nodes registered yet.' + + # Render complete subpage HTML + return f""" + + + + + LOGAR | Server & Client Role Directory + + + + + + +
+ +
+
+
🏢
+
+
LOGAR Server & Client Role Directory
+
OS Classification • Windows & Linux Server Roles • Hardware Telemetry • Best Practice Baseline
+
+
+ +
+ + +
+
+
+ Total Monitored Nodes + {total_nodes} Enrolled +
+
{total_nodes}
+
Total active edge nodes and local hub host
+
+ +
+
+ Windows Server Fleet + {win_servers} Servers +
+
{win_servers}
+
{total_roles_detected} enterprise server roles inventoried
+
+ +
+
+ Windows Workstations + {win_workstations} Clients +
+
{win_workstations}
+
Desktop Windows 10/11 edge forwarders
+
+ +
+
+ Linux Server Nodes + {linux_nodes} Nodes +
+
{linux_nodes}
+
Linux distributions and cloud containers
+
+ +
+
+ Best Practice Baseline + {bp_kpi_badge} +
+
{bp_kpi_value}
+
{bp_kpi_sub}
+
+
+ + + {bp_banner_html} + + +
+
+
+

Node Telemetry & Server Roles Inventory

+
+ Real-time hardware metrics, OS edition introspection, and installed enterprise server roles across your fleet. +
+
+
+ + +
+
+ + + + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + {nodes_table_html} + +
StatusHost NodeOperating SystemInstalled Server RolesHardware TelemetryAgent VersionComplianceSpecs
+
+
+
+ + + + + + +""" + diff --git a/tests/test_server_roles_and_best_practices.py b/tests/test_server_roles_and_best_practices.py new file mode 100644 index 0000000..a537791 --- /dev/null +++ b/tests/test_server_roles_and_best_practices.py @@ -0,0 +1,178 @@ +""" +Unit and integration tests for LOGAR Server Roles detection, +Windows Server vs Windows Client differentiation, /client-info subpage, +and Best Practice baseline engine. +""" + +import os +import sys +import json +import sqlite3 +import unittest +from unittest.mock import patch, MagicMock + +from fastapi.testclient import TestClient + +from src.common.system_info import ( + get_os_details, + get_ip_addresses, + get_full_system_inventory, + get_installed_windows_server_roles +) +from src.hub.best_practices import BestPracticeEngine, BestPracticeRule, BEST_PRACTICES_ENGINE +from src.hub.web_ui import WebUIRenderer +from src.hub.Server import app, SERVER_STATE, DEFAULT_DB_FILE +from src.hub.auth_manager import SessionManager + + +class TestServerRolesAndBestPractices(unittest.TestCase): + + def setUp(self): + self.client = TestClient(app) + self.db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + self.session_token = SessionManager.generate_session_token("admin", db_path=self.db_path) + self.auth_headers = {"Authorization": f"Bearer {self.session_token}"} + + def test_os_details_classification(self): + """Verifies OS classification returns valid structure with is_server flag.""" + details = get_os_details() + self.assertIn("os_type", details) + self.assertIn("os_name", details) + self.assertIn("is_server", details) + self.assertIn("hostname", details) + self.assertIn("ip_addresses", details) + self.assertIsInstance(details["is_server"], bool) + self.assertIn(details["os_type"], ["windows", "windows_server", "linux", "darwin", "unknown"]) + + def test_full_system_inventory(self): + """Verifies full system inventory includes CPU, RAM, disk, and server roles.""" + inv = get_full_system_inventory() + self.assertIn("hostname", inv) + self.assertIn("os_type", inv) + self.assertIn("cpu_count", inv) + self.assertIn("memory_total_mb", inv) + self.assertIn("disk_percent_free", inv) + self.assertIn("server_roles", inv) + self.assertIsInstance(inv["server_roles"], list) + + def test_best_practices_engine_evaluation(self): + """Tests that baseline best practice checks evaluate properly.""" + engine = BestPracticeEngine() + rules = engine.get_rules() + self.assertGreaterEqual(len(rules), 4) + + # Test node with healthy specs + healthy_node = { + "client_id": "test-dc-01", + "hostname": "DC01.corp.local", + "os_type": "windows_server", + "os_name": "Windows Server 2022 Datacenter", + "is_server": True, + "server_roles": ["Active Directory Domain Services", "DNS Server"], + "disk_percent_free": 45.0, + "memory_percent": 55.0, + "client_version": "1.2.0", + "update_status": "up_to_date" + } + res = engine.evaluate_node(healthy_node) + self.assertTrue(all(r["status"] == "PASS" for r in res)) + + # Test DC with bad practice (co-located IIS Web Server) + unhealthy_dc = { + "client_id": "test-dc-bad", + "hostname": "DC-BAD.corp.local", + "os_type": "windows_server", + "os_name": "Windows Server 2022 Datacenter", + "is_server": True, + "server_roles": ["Active Directory Domain Services", "Web Server (IIS)"], + "disk_percent_free": 5.0, # Critical disk space (< 10%) + "memory_percent": 96.0, # Exhausted memory (>= 95%) + "client_version": "1.0.0", + "update_status": "outdated" + } + res_bad = engine.evaluate_node(unhealthy_dc) + statuses = {r["rule_id"]: r["status"] for r in res_bad} + self.assertEqual(statuses.get("BP-AD-001"), "FAIL") # DC co-location conflict + self.assertEqual(statuses.get("BP-SYS-001"), "FAIL") # Critical disk + self.assertEqual(statuses.get("BP-SYS-002"), "FAIL") # Exhausted RAM + self.assertEqual(statuses.get("BP-SEC-003"), "WARN") # Outdated client + + def test_best_practices_custom_rule_extensibility(self): + """Tests user extensibility: custom rules can be defined and registered dynamically.""" + engine = BestPracticeEngine() + custom_rule = BestPracticeRule( + rule_id="BP-CUSTOM-001", + category="Custom Compliance", + title="Custom Domain Suffix Enforcement", + description="All fleet nodes must belong to the internal domain.", + recommendation="Join host to corp.internal.", + severity="WARNING", + eval_fn=lambda node: {"status": "PASS" if "corp.internal" in node.get("hostname", "") else "FAIL", "message": "Domain check."} + ) + engine.register_rule(custom_rule) + rules = engine.get_rules() + self.assertTrue(any(r["rule_id"] == "BP-CUSTOM-001" for r in rules)) + + eval_pass = engine.evaluate_node({"hostname": "server1.corp.internal"}) + self.assertTrue(any(r["rule_id"] == "BP-CUSTOM-001" and r["status"] == "PASS" for r in eval_pass)) + + eval_fail = engine.evaluate_node({"hostname": "server1.public.com"}) + self.assertTrue(any(r["rule_id"] == "BP-CUSTOM-001" and r["status"] == "FAIL" for r in eval_fail)) + + def test_client_info_html_rendering(self): + """Verifies GET /client-info redirects to /login when unauthenticated, and renders HTML when authenticated.""" + # Unauthenticated redirects to /login + res_unauth = self.client.get("/client-info", follow_redirects=False) + self.assertEqual(res_unauth.status_code, 303) + self.assertEqual(res_unauth.headers.get("location"), "/login") + + # Authenticated renders the full directory page + res1 = self.client.get("/client-info", cookies={SessionManager.SESSION_COOKIE_NAME: self.session_token}) + self.assertEqual(res1.status_code, 200) + self.assertIn("LOGAR Server & Client Role Directory", res1.text) + self.assertIn("Node Telemetry & Server Roles Inventory", res1.text) + + res2 = self.client.get("/webui/client-info", cookies={SessionManager.SESSION_COOKIE_NAME: self.session_token}) + self.assertEqual(res2.status_code, 200) + self.assertIn("LOGAR Server & Client Role Directory", res2.text) + + def test_api_client_info_endpoint(self): + """Verifies GET /api/client-info provides structured JSON inventory.""" + res = self.client.get("/api/client-info", headers=self.auth_headers) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data.get("status"), "success") + self.assertIn("total_nodes", data) + self.assertIn("windows_servers", data) + self.assertIn("windows_workstations", data) + self.assertIn("linux_nodes", data) + self.assertIn("clients", data) + + def test_api_best_practices_status_unlicensed(self): + """Verifies /api/best-practices/status returns locked when unlicensed.""" + with patch("src.hub.Server.is_feature_licensed", return_value=False): + res = self.client.get("/api/best-practices/status", headers=self.auth_headers) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data.get("status"), "locked") + self.assertFalse(data.get("licensed")) + self.assertIn("enterprise capability", data.get("message", "").lower()) + + def test_api_best_practices_status_licensed(self): + """Verifies /api/best-practices/status returns active evaluation when feature is licensed.""" + with patch("src.hub.Server.is_feature_licensed", return_value=True): + res = self.client.get("/api/best-practices/status", headers=self.auth_headers) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data.get("status"), "active") + self.assertTrue(data.get("licensed")) + self.assertIn("evaluation", data) + eval_data = data["evaluation"] + self.assertIn("score", eval_data) + self.assertIn("pass_count", eval_data) + self.assertIn("warn_count", eval_data) + self.assertIn("fail_count", eval_data) + + +if __name__ == "__main__": + unittest.main()