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,8 @@
|
||||
# LOGAR Common Package
|
||||
from src.common.system_info import (
|
||||
get_os_details,
|
||||
get_installed_server_roles,
|
||||
get_full_system_inventory,
|
||||
get_ip_addresses
|
||||
)
|
||||
from src.common.version import LOGAR_VERSION
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user