Add Windows Server vs Client differentiation, installed server roles introspection, dedicated /client-info directory, and best practice baseline analyzer
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m50s

This commit is contained in:
2026-10-11 09:37:05 +02:00
parent bcaae4a9ee
commit cccf928f29
11 changed files with 1902 additions and 38 deletions
+13 -2
View File
@@ -88,14 +88,25 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li
return collector.collect_logs(hours=hours) 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): def send_encrypted_logs_over_socket(config: dict, logs: list):
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" """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: def ensure_enrolled(config: dict, machine_id: str) -> bool:
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" """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(): def main():
+26 -2
View File
@@ -52,6 +52,18 @@ except ImportError:
except ImportError: except ImportError:
from client_updater import ClientUpdater 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" 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" action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...") print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll" enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
inventory = get_full_system_inventory()
effective_os = inventory.get("os_type") or os_type
payload = { payload = {
"client_id": client_id, "client_id": client_id,
"hostname": hostname, "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, "enrollment_secret": enrollment_secret,
"version": LOGAR_VERSION "version": LOGAR_VERSION
} }
@@ -271,12 +289,18 @@ def send_encrypted_logs_over_socket(
raise raise
with sock: with sock:
inventory = get_full_system_inventory()
payload = { payload = {
"server": actual_machine_id, "server": actual_machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs, "logs": logs,
"metrics": collect_system_metrics(), "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") payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
+7
View File
@@ -1 +1,8 @@
# LOGAR Common Package # 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
+323
View File
@@ -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
}
+272 -25
View File
@@ -102,6 +102,22 @@ from src.license_manager import LicenseManager
from src.pki_manager import PKIManager from src.pki_manager import PKIManager
from src.web_ui import WebUIRenderer, format_relative_time 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" CONFIG_FILE_NAME = "server_config.json"
DEFAULT_DB_FILE = "logar_state.db" 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 processed_count += 1
# Update client node metrics & version if attached # Update client node metrics, version & server role introspection if attached
metrics = payload.get("metrics") metrics = payload.get("metrics")
client_ver = payload.get("version") 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: if metrics and isinstance(metrics, dict) and client_server:
cpu_p = float(metrics.get("cpu_percent", 0.0)) cpu_p = float(metrics.get("cpu_percent", 0.0))
mem_p = float(metrics.get("memory_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: except Exception:
pass 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.commit()
conn.close() 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: 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.""" """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() 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") now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
try: try:
@@ -1006,16 +1055,30 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run
c = conn.cursor() c = conn.cursor()
c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,))
if c.fetchone(): if c.fetchone():
c.execute(""" try:
UPDATE clients c.execute("""
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? UPDATE clients
WHERE client_id = ? SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?,
""", (machine_id, os_type, now_sql, machine_id)) 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: else:
c.execute(""" try:
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) c.execute("""
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json)
""", (machine_id, machine_id, os_type, now_sql, now_sql)) 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.commit()
conn.close() conn.close()
except Exception as e: 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") now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
client_ver = req.version or "" 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: if existing:
try: try:
c.execute(""" c.execute("""
UPDATE clients 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 = ? 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: except sqlite3.OperationalError:
c.execute(""" try:
UPDATE clients c.execute("""
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? UPDATE clients
WHERE client_id = ? SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?
""", (req.hostname, req.os, fp, now_sql, req.client_id)) 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: else:
try: try:
c.execute(""" c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) 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', ?, ?, ?) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) """, (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: except sqlite3.OperationalError:
c.execute(""" try:
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) c.execute("""
VALUES (?, ?, ?, ?, 'active', ?, ?) INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) 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.commit()
conn.close() conn.close()
@@ -2484,6 +2566,171 @@ def serve_webui(request: Request = None):
return HTMLResponse(content=html_content) 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") @app.get("/health")
def health_check(): def health_check():
return { return {
+278
View File
@@ -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()
+4
View File
@@ -13,6 +13,10 @@ class ClientEnrollRequest(BaseModel):
os: str os: str
enrollment_secret: str enrollment_secret: str
version: Optional[str] = None 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): class ClientUpdateRequest(BaseModel):
+10 -2
View File
@@ -233,7 +233,11 @@ class StorageEngine:
("disk_percent_free", "REAL DEFAULT 0.0"), ("disk_percent_free", "REAL DEFAULT 0.0"),
("client_version", "TEXT DEFAULT ''"), ("client_version", "TEXT DEFAULT ''"),
("update_status", "TEXT DEFAULT 'up_to_date'"), ("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 # 5. Users Table
@@ -429,7 +433,11 @@ class StorageEngine:
("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"), ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"),
("client_version", "VARCHAR(50) DEFAULT ''"), ("client_version", "VARCHAR(50) DEFAULT ''"),
("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"), ("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: try:
cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}")
+790 -6
View File
@@ -19,6 +19,14 @@ try:
except ImportError: except ImportError:
from version import LOGAR_VERSION, is_newer_version 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: def format_relative_time(dt_str: Optional[str]) -> str:
"""Calculates human-readable relative time from ISO or SQL timestamp string.""" """Calculates human-readable relative time from ISO or SQL timestamp string."""
@@ -205,11 +213,20 @@ class WebUIRenderer:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
c = conn.cursor() c = conn.cursor()
try: 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() clients = c.fetchall()
except sqlite3.OperationalError: 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") try:
clients = [r + ("", "up_to_date") for r in c.fetchall()] 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 # 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") 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() hermes_tokens = c.fetchall()
conn.close() 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 = [] client_rows = []
for cl in clients: for cl in clients:
cid, host, ost, fp, st, first, last, cver, up_st = cl[:9] 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_cid = html.escape(str(cid))
esc_host = html.escape(str(host)) esc_host = html.escape(str(host))
esc_ost = html.escape(str(ost)) esc_ost = html.escape(str(ost))
@@ -246,6 +271,17 @@ class WebUIRenderer:
else: else:
ver_display = f'<span class="badge badge-active" style="font-size:11px;">v{html.escape(cver)}</span>' ver_display = f'<span class="badge badge-active" style="font-size:11px;">v{html.escape(cver)}</span>'
# Operating System & Server Classification Badge
if ost == "windows_server" or is_server:
role_cnt_badge = f'<span class="badge" style="background:rgba(147, 51, 234, 0.3); color:#e9d5ff; font-size:10px; margin-left:4px;" title="{len(roles)} Server Roles Installed">{len(roles)} Roles</span>' if roles else ''
os_badge_html = f'<span class="badge" style="background:rgba(147, 51, 234, 0.15); color:#c084fc; border:1px solid rgba(147, 51, 234, 0.3);" title="{html.escape(os_name or "Windows Server")}">🏢 WIN SERVER</span>{role_cnt_badge}'
elif "win" in ost.lower():
os_badge_html = f'<span class="badge badge-os" title="{html.escape(os_name or "Windows Client")}">🖥️ WINDOWS</span>'
elif "linux" in ost.lower():
os_badge_html = f'<span class="badge badge-os" title="{html.escape(os_name or "Linux Node")}">🐧 LINUX</span>'
else:
os_badge_html = f'<span class="badge badge-os">{esc_ost.upper()}</span>'
if fp == "LOCAL-HOST-BUILTIN": if fp == "LOCAL-HOST-BUILTIN":
esc_st = "LOCAL HUB HOST" esc_st = "LOCAL HUB HOST"
badge_cls = "badge-active" badge_cls = "badge-active"
@@ -266,7 +302,7 @@ class WebUIRenderer:
<td><span class="badge {badge_cls}">{esc_st.upper()}</span></td> <td><span class="badge {badge_cls}">{esc_st.upper()}</span></td>
<td><strong>{esc_host}</strong></td> <td><strong>{esc_host}</strong></td>
<td><code>{esc_cid}</code></td> <td><code>{esc_cid}</code></td>
<td><span class="badge badge-os">{esc_ost.upper()}</span></td> <td>{os_badge_html}</td>
<td>{ver_display}</td> <td>{ver_display}</td>
<td>{fp_display}</td> <td>{fp_display}</td>
<td>{last_display}</td> <td>{last_display}</td>
@@ -424,6 +460,21 @@ class WebUIRenderer:
</div> </div>
</div> </div>
""") """)
elif feat == "best_practices":
feature_cards.append("""
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.35); display: flex; align-items: flex-start; gap: 14px;">
<div style="font-size: 22px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 3px;">
<strong style="color: #34d399; font-size: 14px;">best_practices</strong>
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.4;">
<strong>Best Practice &amp; Security Baseline Analyzer:</strong> Unlocks automated audit checks, server role configuration validation, Domain Controller isolation checks, and hardening baselines across your fleet.
</div>
</div>
</div>
""")
else: else:
esc_feat = html.escape(str(feat)) esc_feat = html.escape(str(feat))
feat_title = html.escape(str(feat).replace("_", " ").title()) feat_title = html.escape(str(feat).replace("_", " ").title())
@@ -1071,6 +1122,7 @@ class WebUIRenderer:
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="tabs-nav"> <div class="tabs-nav">
<button class="tab-btn active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders</button> <button class="tab-btn active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders</button>
<a href="/client-info" class="tab-btn" style="text-decoration:none; display:inline-flex; align-items:center; gap:8px;" title="Open dedicated client info and server roles directory">🖥️ Server Roles &amp; Client Directory</a>
<button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens</button> <button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens</button>
<button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry</button> <button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry</button>
<button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button> <button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button>
@@ -1125,7 +1177,8 @@ class WebUIRenderer:
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<span class="card-title" style="font-size: 15px;">Enrolled Forwarders & Seat Management</span> <span class="card-title" style="font-size: 15px;">Enrolled Forwarders & Seat Management</span>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="/client-info" class="btn btn-outline" style="padding:6px 12px; font-size:12px; text-decoration:none;" title="Open dedicated client info and server roles directory">📋 Server Roles &amp; Directory (/client-info)</a>
<button onclick="resetEnrollmentSecret()" class="btn btn-outline" style="padding:6px 12px; font-size:12px;" title="Reset shared enrollment secret key">🔄 Reset Shared Key</button> <button onclick="resetEnrollmentSecret()" class="btn btn-outline" style="padding:6px 12px; font-size:12px;" title="Reset shared enrollment secret key">🔄 Reset Shared Key</button>
<button onclick="openExportProfileModal()" class="btn btn-accent" style="padding:6px 12px; font-size:12px;">+ Export New Client Profile</button> <button onclick="openExportProfileModal()" class="btn btn-accent" style="padding:6px 12px; font-size:12px;">+ Export New Client Profile</button>
</div> </div>
@@ -2171,3 +2224,734 @@ class WebUIRenderer:
</html>""" </html>"""
return html_content 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"""
<div style="background: #0b0f19; border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 8px;">
<code style="font-size: 11px; background: rgba(59, 130, 246, 0.15); color: #60a5fa; padding: 2px 6px; border-radius: 4px;">{rid}</code>
<strong style="font-size: 13px; color: #f8fafc;">{rtitle}</strong>
<span class="badge {sev_badge}" style="font-size: 9px;">{rsev}</span>
</div>
<span style="font-size: 11px; color: var(--text-muted); background: #111827; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border);">{rcat}</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px; line-height: 1.4;">{rdesc}</div>
<div style="font-size: 11px; color: #34d399;"><strong style="color: #6ee7b7;">Recommendation:</strong> {rrec}</div>
</div>
""")
bp_rules_html = "".join(rule_rows)
bp_banner_html = f"""
<div class="card" style="border: 1px solid rgba(16, 185, 129, 0.35); background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(15, 23, 42, 0.8));">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 2px;">
<h3 style="margin: 0; font-size: 16px; color: #34d399;">Best Practice &amp; Server Role Baseline Analyzer</h3>
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED • ACTIVE</span>
</div>
<div style="font-size: 12px; color: var(--text-muted);">
Comprehensive security baseline, Active Directory role segregation, and storage buffer auditing active across {total_nodes} nodes.
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="text-align: right;">
<div style="font-size: 26px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: {score_color};">{score}%</div>
<div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Fleet Compliance Score</div>
</div>
<div style="display: flex; gap: 8px;">
<span class="badge badge-active" style="font-size: 11px; padding: 6px 10px;">✅ {pass_cnt} PASS</span>
<span class="badge badge-warning" style="font-size: 11px; padding: 6px 10px;">⚠️ {warn_cnt} WARN</span>
<span class="badge badge-error" style="font-size: 11px; padding: 6px 10px;">❌ {fail_cnt} FAIL</span>
</div>
</div>
</div>
<details style="cursor: pointer;">
<summary style="font-size: 12px; font-weight: 700; color: #60a5fa; outline: none; margin-bottom: 12px;">
▶ View {total_rules} Active Baseline Audit Rules &amp; Remediation Standards
</summary>
<div style="margin-top: 10px;">
{bp_rules_html}
</div>
</details>
</div>
"""
bp_kpi_value = f"{score}%"
bp_kpi_sub = f"{pass_cnt} passing • {warn_cnt} warnings • {fail_cnt} failures"
bp_kpi_badge = f'<span class="badge badge-active" style="color:{score_color};">{score}% COMPLIANT</span>'
else:
bp_banner_html = """
<div class="card" style="border: 1px solid rgba(234, 179, 8, 0.4); background: linear-gradient(135deg, rgba(30, 41, 59, 0.7), rgba(15, 23, 42, 0.9)); margin-bottom: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: flex-start; gap: 16px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
<h3 style="margin: 0; font-size: 16px; color: #facc15;">Best Practice Analyzer &amp; Role Baseline Engine</h3>
<span class="badge" style="background: rgba(234, 179, 8, 0.2); color: #facc15; border: 1px solid rgba(234, 179, 8, 0.4); font-size: 10px;">ENTERPRISE CAPABILITY • INACTIVE</span>
</div>
<p style="margin: 0; font-size: 13px; color: var(--text-muted); max-width: 820px; line-height: 1.5;">
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 <code>best_practices</code> capability enabled.
</p>
<div style="margin-top: 8px; font-size: 12px; color: #94a3b8;">
💡 <em>This capability is inactive by default. You can define rules later on and activate it in the LOGAR Licensing Portal at any time.</em>
</div>
</div>
</div>
<div>
<a href="/webui#pki" class="btn btn-outline" style="border-color: #facc15; color: #facc15; font-size: 12px; text-decoration: none;">📜 View Licensing Portal</a>
</div>
</div>
</div>
"""
bp_kpi_value = "INACTIVE"
bp_kpi_sub = "Requires 'best_practices' license capability"
bp_kpi_badge = '<span class="badge" style="background:rgba(234, 179, 8, 0.15); color:#facc15; border:1px solid rgba(234, 179, 8, 0.3);">LOCKED</span>'
# 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 = '<span class="badge" style="background:rgba(147, 51, 234, 0.2); color:#c084fc; border:1px solid rgba(147, 51, 234, 0.4); font-size:10px;">🏢 WINDOWS SERVER</span>'
os_display_name = html.escape(os_name or "Windows Server")
else:
filter_cat = "linux"
os_badge = '<span class="badge badge-os" style="font-size:10px;">🐧 LINUX SERVER</span>'
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 = '<span class="badge" style="background:rgba(59, 130, 246, 0.2); color:#60a5fa; border:1px solid rgba(59, 130, 246, 0.4); font-size:10px;">🖥️ WINDOWS CLIENT</span>'
os_display_name = html.escape(os_name or "Windows Workstation")
elif "linux" in ost_lower:
filter_cat = "linux"
os_badge = '<span class="badge badge-os" style="font-size:10px;">🐧 LINUX</span>'
os_display_name = html.escape(os_name or "Linux Node")
else:
filter_cat = "other"
os_badge = f'<span class="badge badge-os" style="font-size:10px;">{html.escape(ost.upper())}</span>'
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'<span class="badge" style="background:rgba(59, 130, 246, 0.25); color:#93c5fd; border:1px solid #3b82f6; font-size:10px; margin:2px;" title="{esc_r}">🌐 {esc_r}</span>')
elif "dns" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(168, 85, 247, 0.25); color:#d8b4fe; border:1px solid #a855f7; font-size:10px; margin:2px;" title="{esc_r}">📡 {esc_r}</span>')
elif "dhcp" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(234, 179, 8, 0.25); color:#fde047; border:1px solid #eab308; font-size:10px; margin:2px;" title="{esc_r}">📶 {esc_r}</span>')
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'<span class="badge" style="background:rgba(6, 182, 212, 0.25); color:#67e8f9; border:1px solid #06b6d4; font-size:10px; margin:2px;" title="{esc_r}">🌍 {esc_r}</span>')
elif "hyper-v" in r_lower or "kvm" in r_lower or "qemu" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(16, 185, 129, 0.25); color:#6ee7b7; border:1px solid #10b981; font-size:10px; margin:2px;" title="{esc_r}">⚡ {esc_r}</span>')
elif "remote desktop" in r_lower or "terminal" in r_lower or "ssh" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(244, 63, 94, 0.25); color:#fda4af; border:1px solid #f43f5e; font-size:10px; margin:2px;" title="{esc_r}">🖥️ {esc_r}</span>')
elif "file" in r_lower or "storage" in r_lower or "samba" in r_lower or "nfs" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(99, 102, 241, 0.25); color:#a5b4fc; border:1px solid #6366f1; font-size:10px; margin:2px;" title="{esc_r}">📁 {esc_r}</span>')
elif "print" in r_lower or "cups" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(148, 163, 184, 0.25); color:#cbd5e1; border:1px solid #94a3b8; font-size:10px; margin:2px;" title="{esc_r}">🖨️ {esc_r}</span>')
else:
role_badges.append(f'<span class="badge" style="background:#1e293b; color:#38bdf8; border:1px solid var(--border); font-size:10px; margin:2px;" title="{esc_r}">⚙️ {esc_r}</span>')
roles_cell_html = "".join(role_badges)
else:
roles_cell_html = '<span style="color:#64748b; font-size:12px; font-style:italic;">Workstation / Member Node (0 Roles)</span>'
# 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"""
<div style="font-size:11px; line-height: 1.5;">
<div><span style="color:var(--text-muted);">CPU:</span> {cpu_cnt_str}<strong style="color:{cpu_bar_col};">{cpu_p}%</strong></div>
<div><span style="color:var(--text-muted);">RAM:</span> {mem_tot_str} (<strong style="color:{mem_bar_col};">{mem_p}%</strong> in use)</div>
<div><span style="color:var(--text-muted);">Disk Free:</span> <strong style="color:#34d399;">{disk_p}%</strong></div>
<div style="color:#94a3b8; font-family:'JetBrains Mono',monospace; font-size:10px;">{html.escape(ip_str)}</div>
</div>
"""
# Builtin indicator
if fp == "LOCAL-HOST-BUILTIN":
host_tag = '<span class="badge badge-active" style="font-size:9px; margin-left:6px;">⚡ LOCAL HUB</span>'
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'<span class="badge badge-error" style="font-size:10px;" title="{n_fails} failed checks">❌ {n_fails} FAIL</span>'
elif n_warns > 0:
bp_node_badge = f'<span class="badge badge-warning" style="font-size:10px;" title="{n_warns} warnings">⚠️ {n_warns} WARN</span>'
else:
bp_node_badge = f'<span class="badge badge-active" style="font-size:10px;" title="{n_passes} checks passed">✅ {n_passes} PASS</span>'
else:
bp_node_badge = '<span class="badge badge-active" style="font-size:10px;">✅ HEALTHY</span>'
else:
bp_node_badge = '<span class="badge" style="background:#1e293b; color:#94a3b8; font-size:10px;">🔒 Locked</span>'
# Client info JSON payload for inspect modal
safe_info_json = html.escape(json.dumps(node, indent=2))
node_rows.append(f"""
<tr class="node-row" data-category="{filter_cat}" data-search="{esc_host.lower()} {esc_cid.lower()} {os_display_name.lower()} {' '.join(str(x).lower() for x in roles)} {ip_str.lower()}">
<td>
<div style="display:flex; align-items:center; gap:6px;">
<span class="badge {badge_st_cls}">{st.upper()}</span>
</div>
</td>
<td>
<div style="font-weight:700; color:#fff; font-size:13px;">{esc_host}{host_tag}</div>
<div style="font-size:11px; color:var(--text-muted); font-family:'JetBrains Mono',monospace;">{esc_cid}</div>
</td>
<td>
<div>{os_badge}</div>
<div style="font-size:12px; font-weight:600; color:#e2e8f0; margin-top:3px;">{os_display_name}</div>
<div style="font-size:11px; color:#94a3b8;">{specs_sub}</div>
</td>
<td style="max-width: 320px;">
<div style="display:flex; flex-wrap:wrap; gap:4px;">
{roles_cell_html}
</div>
</td>
<td>{telemetry_html}</td>
<td>
<div><span class="badge badge-active" style="font-size:10px;">v{html.escape(str(cver or LOGAR_VERSION))}</span></div>
<div style="font-size:11px; color:var(--text-muted); margin-top:3px;">{html.escape(rel_time)}</div>
<div style="font-size:10px; color:#64748b;">{html.escape(str(last[:19]))}</div>
</td>
<td>{bp_node_badge}</td>
<td>
<button onclick="openTelemetryModal('{esc_cid}', '{esc_host}', `{safe_info_json}`)" class="btn btn-outline" style="padding:4px 8px; font-size:11px;">
🔍 Specs JSON
</button>
</td>
</tr>
""")
nodes_table_html = "".join(node_rows) if node_rows else '<tr><td colspan="8" class="empty-state">No monitored nodes registered yet.</td></tr>'
# Render complete subpage HTML
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOGAR | Server &amp; Client Role Directory</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {{
--bg: #0b0f19;
--card: #111827;
--border: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--accent: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--purple: #8b5cf6;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
background-color: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}}
.container {{ max-width: 1540px; margin: 0 auto; padding: 24px; width: 100%; }}
header {{
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 20px; border-bottom: 1px solid var(--border); margin-bottom: 24px;
flex-wrap: wrap; gap: 16px;
}}
.logo-area {{ display: flex; align-items: center; gap: 14px; }}
.logo-icon {{
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800; color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
}}
.logo-title {{ font-size: 20px; font-weight: 800; letter-spacing: -0.5px; color: #fff; }}
.logo-sub {{ font-size: 12px; color: var(--text-muted); margin-top: 2px; }}
/* Buttons & Badges */
.btn {{
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; border: none; text-decoration: none; transition: all 0.2s;
}}
.btn-outline {{ background: transparent; border: 1px solid var(--border); color: var(--text); }}
.btn-outline:hover {{ border-color: var(--primary); background: rgba(59, 130, 246, 0.08); }}
.btn-accent {{ background: var(--accent); color: #022c22; }}
.btn-accent:hover {{ opacity: 0.9; }}
.btn-primary {{ background: var(--primary); color: #fff; }}
.btn-primary:hover {{ background: var(--primary-hover); }}
.badge {{
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
}}
.badge-active {{ background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }}
.badge-revoked, .badge-error {{ background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }}
.badge-warning {{ background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }}
.badge-os {{ background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }}
/* KPI Cards */
.grid-kpi {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}}
.card {{
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
margin-bottom: 24px;
}}
.card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }}
.card-title {{ font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; }}
.kpi-value {{ font-size: 26px; font-weight: 800; font-family: 'JetBrains Mono', monospace; margin-bottom: 4px; }}
.kpi-sub {{ font-size: 12px; color: var(--text-muted); }}
/* Filter Toolbar */
.filter-toolbar {{
display: flex; justify-content: space-between; align-items: center;
gap: 12px; margin-bottom: 16px; flex-wrap: wrap;
}}
.filter-pills {{ display: flex; gap: 8px; flex-wrap: wrap; }}
.filter-pill {{
padding: 7px 14px; border-radius: 8px; font-size: 12px; font-weight: 600;
background: #0f172a; border: 1px solid var(--border); color: var(--text-muted);
cursor: pointer; transition: all 0.2s;
}}
.filter-pill:hover {{ color: #fff; border-color: var(--primary); }}
.filter-pill.active {{ background: var(--primary); color: #fff; border-color: var(--primary); }}
.search-box {{
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border);
background: #0f172a; color: #fff; font-size: 13px; min-width: 280px;
outline: none; transition: border-color 0.2s;
}}
.search-box:focus {{ border-color: var(--primary); }}
/* Table */
table {{ width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }}
th {{
padding: 12px 14px; color: var(--text-muted); font-size: 11px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid var(--border); background: #0f172a;
}}
td {{ padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }}
tr:hover td {{ background: rgba(255, 255, 255, 0.02); }}
/* Modal */
.modal-overlay {{
display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(4px);
z-index: 1000; align-items: center; justify-content: center;
}}
.modal {{
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
width: 90%; max-width: 780px; max-height: 85vh; display: flex; flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8);
}}
.modal-header {{
padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}}
.modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
.modal-footer {{
padding: 14px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}}
pre.code-viewer {{
background: #0b0f19; border: 1px solid var(--border); border-radius: 8px;
padding: 14px; font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: #38bdf8; overflow-x: auto; max-height: 480px;
}}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header>
<div class="logo-area">
<div class="logo-icon">🏢</div>
<div>
<div class="logo-title">LOGAR Server &amp; Client Role Directory</div>
<div class="logo-sub">OS Classification • Windows &amp; Linux Server Roles • Hardware Telemetry • Best Practice Baseline</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<a href="/webui" class="btn btn-outline" style="font-size: 13px;">
← Back to Fleet Hub
</a>
<a href="/webui#pki" class="btn btn-outline" style="font-size: 13px;">
📜 Sub-CA &amp; Licensing
</a>
<span style="font-size: 12px; color: var(--text-muted); background: #0f172a; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);">
👤 {auth_display}
</span>
</div>
</header>
<!-- KPI Cards -->
<div class="grid-kpi">
<div class="card">
<div class="card-header">
<span class="card-title">Total Monitored Nodes</span>
<span class="badge badge-active">{total_nodes} Enrolled</span>
</div>
<div class="kpi-value">{total_nodes}</div>
<div class="kpi-sub">Total active edge nodes and local hub host</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Windows Server Fleet</span>
<span class="badge" style="background:rgba(147, 51, 234, 0.2); color:#c084fc; border:1px solid rgba(147, 51, 234, 0.4);">{win_servers} Servers</span>
</div>
<div class="kpi-value" style="color: #c084fc;">{win_servers}</div>
<div class="kpi-sub">{total_roles_detected} enterprise server roles inventoried</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Windows Workstations</span>
<span class="badge badge-os">{win_workstations} Clients</span>
</div>
<div class="kpi-value" style="color: #60a5fa;">{win_workstations}</div>
<div class="kpi-sub">Desktop Windows 10/11 edge forwarders</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Linux Server Nodes</span>
<span class="badge badge-os">{linux_nodes} Nodes</span>
</div>
<div class="kpi-value" style="color: #38bdf8;">{linux_nodes}</div>
<div class="kpi-sub">Linux distributions and cloud containers</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Best Practice Baseline</span>
{bp_kpi_badge}
</div>
<div class="kpi-value">{bp_kpi_value}</div>
<div class="kpi-sub">{bp_kpi_sub}</div>
</div>
</div>
<!-- Best Practice Analyzer Banner / Card -->
{bp_banner_html}
<!-- Server & Host Directory Section -->
<div class="card">
<div class="card-header" style="flex-wrap: wrap; gap: 12px;">
<div>
<h3 style="font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 3px;">Node Telemetry &amp; Server Roles Inventory</h3>
<div style="font-size: 12px; color: var(--text-muted);">
Real-time hardware metrics, OS edition introspection, and installed enterprise server roles across your fleet.
</div>
</div>
</div>
<!-- Filter Toolbar -->
<div class="filter-toolbar">
<div class="filter-pills">
<button class="filter-pill active" onclick="setFilter('all', this)">All Nodes ({total_nodes})</button>
<button class="filter-pill" onclick="setFilter('windows_server', this)">🏢 Windows Servers ({win_servers})</button>
<button class="filter-pill" onclick="setFilter('windows_client', this)">🖥️ Windows Clients ({win_workstations})</button>
<button class="filter-pill" onclick="setFilter('linux', this)">🐧 Linux Nodes ({linux_nodes})</button>
</div>
<div>
<input type="text" id="nodeSearchInput" class="search-box" placeholder="🔍 Search hostname, role, IP, OS..." oninput="filterTable()">
</div>
</div>
<div style="overflow-x: auto;">
<table id="nodesTable">
<thead>
<tr>
<th>Status</th>
<th>Host Node</th>
<th>Operating System</th>
<th>Installed Server Roles</th>
<th>Hardware Telemetry</th>
<th>Agent Version</th>
<th>Compliance</th>
<th>Specs</th>
</tr>
</thead>
<tbody>
{nodes_table_html}
</tbody>
</table>
</div>
</div>
</div>
<!-- Telemetry JSON Inspector Modal -->
<div id="telemetryModal" class="modal-overlay" onclick="closeTelemetryModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<div>
<h4 id="modalHostTitle" style="font-size: 15px; font-weight: 700; color: #fff;">Host Telemetry Inspector</h4>
<div id="modalClientIdSub" style="font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;"></div>
</div>
<button onclick="closeTelemetryModal()" class="btn btn-outline" style="padding: 4px 8px; font-size: 12px;">✕</button>
</div>
<div class="modal-body">
<pre id="modalJsonContent" class="code-viewer"></pre>
</div>
<div class="modal-footer">
<button onclick="copyModalJson()" class="btn btn-primary" id="copyJsonBtn">📋 Copy JSON</button>
<button onclick="closeTelemetryModal()" class="btn btn-outline">Close</button>
</div>
</div>
</div>
<script>
let currentFilter = 'all';
function setFilter(category, btn) {{
currentFilter = category;
document.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
filterTable();
}}
function filterTable() {{
const query = (document.getElementById('nodeSearchInput').value || '').toLowerCase().trim();
const rows = document.querySelectorAll('#nodesTable tbody tr.node-row');
rows.forEach(row => {{
const cat = row.getAttribute('data-category');
const searchData = (row.getAttribute('data-search') || '').toLowerCase();
const matchesCat = (currentFilter === 'all') || (cat === currentFilter);
const matchesSearch = !query || searchData.includes(query);
if (matchesCat && matchesSearch) {{
row.style.display = '';
}} else {{
row.style.display = 'none';
}}
}});
}}
function openTelemetryModal(clientId, hostname, jsonStr) {{
document.getElementById('modalHostTitle').textContent = 'Host Telemetry: ' + hostname;
document.getElementById('modalClientIdSub').textContent = 'Client ID: ' + clientId;
document.getElementById('modalJsonContent').textContent = jsonStr;
document.getElementById('copyJsonBtn').textContent = '📋 Copy JSON';
document.getElementById('telemetryModal').style.display = 'flex';
}}
function closeTelemetryModal(e) {{
document.getElementById('telemetryModal').style.display = 'none';
}}
function copyModalJson() {{
const text = document.getElementById('modalJsonContent').textContent;
navigator.clipboard.writeText(text).then(() => {{
const btn = document.getElementById('copyJsonBtn');
btn.textContent = '✅ Copied!';
setTimeout(() => {{ btn.textContent = '📋 Copy JSON'; }}, 2000);
}}).catch(() => {{
alert('Copying to clipboard failed.');
}});
}}
</script>
</body>
</html>"""
@@ -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 &amp; Client Role Directory", res1.text)
self.assertIn("Node Telemetry &amp; 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 &amp; 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()