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
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m50s
This commit is contained in:
+1
-1
Submodule LOGAR-LICENSING updated: c5ba1d65b5...8068652573
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+249
-2
@@ -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,12 +1055,26 @@ 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():
|
||||||
|
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("""
|
c.execute("""
|
||||||
UPDATE clients
|
UPDATE clients
|
||||||
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
|
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
|
||||||
WHERE client_id = ?
|
WHERE client_id = ?
|
||||||
""", (machine_id, os_type, now_sql, machine_id))
|
""", (machine_id, os_type, now_sql, machine_id))
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json)
|
||||||
|
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
c.execute("""
|
c.execute("""
|
||||||
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
|
||||||
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
|
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
|
||||||
@@ -1394,7 +1457,20 @@ 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:
|
||||||
|
c.execute("""
|
||||||
|
UPDATE clients
|
||||||
|
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?,
|
||||||
|
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?
|
||||||
|
WHERE client_id = ?
|
||||||
|
""", (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, req.client_id))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
try:
|
try:
|
||||||
c.execute("""
|
c.execute("""
|
||||||
UPDATE clients
|
UPDATE clients
|
||||||
@@ -1408,6 +1484,12 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
|
|||||||
WHERE client_id = ?
|
WHERE client_id = ?
|
||||||
""", (req.hostname, req.os, fp, now_sql, req.client_id))
|
""", (req.hostname, req.os, fp, now_sql, req.client_id))
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json)
|
||||||
|
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info))
|
||||||
|
except sqlite3.OperationalError:
|
||||||
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)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
+789
-5
@@ -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:
|
||||||
|
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:
|
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")
|
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()]
|
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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & Client Role Directory</div>
|
||||||
|
<div class="logo-sub">OS Classification • Windows & 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 & 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 & 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 & 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()
|
||||||
Reference in New Issue
Block a user