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

This commit is contained in:
2026-10-11 09:37:05 +02:00
parent bcaae4a9ee
commit cccf928f29
11 changed files with 1902 additions and 38 deletions
+272 -25
View File
@@ -102,6 +102,22 @@ from src.license_manager import LicenseManager
from src.pki_manager import PKIManager
from src.web_ui import WebUIRenderer, format_relative_time
try:
from src.common.system_info import get_full_system_inventory, get_os_details
except ImportError:
try:
from common.system_info import get_full_system_inventory, get_os_details
except ImportError:
from system_info import get_full_system_inventory, get_os_details
try:
from src.hub.best_practices import BEST_PRACTICES_ENGINE
except ImportError:
try:
from best_practices import BEST_PRACTICES_ENGINE
except ImportError:
BEST_PRACTICES_ENGINE = None
CONFIG_FILE_NAME = "server_config.json"
DEFAULT_DB_FILE = "logar_state.db"
@@ -939,9 +955,15 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
processed_count += 1
# Update client node metrics & version if attached
# Update client node metrics, version & server role introspection if attached
metrics = payload.get("metrics")
client_ver = payload.get("version")
payload_ost = payload.get("os_type")
payload_os_name = payload.get("os_name")
payload_is_srv = payload.get("is_server")
payload_roles = payload.get("server_roles")
payload_info = payload.get("client_info")
if metrics and isinstance(metrics, dict) and client_server:
cpu_p = float(metrics.get("cpu_percent", 0.0))
mem_p = float(metrics.get("memory_percent", 0.0))
@@ -969,6 +991,24 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
except Exception:
pass
# Update OS details, server roles and client info telemetry if provided
if (payload_os_name or payload_roles is not None or payload_info is not None or payload_is_srv is not None or payload_ost) and client_server:
roles_val = json.dumps(payload_roles) if isinstance(payload_roles, list) else (str(payload_roles) if payload_roles is not None else None)
info_val = json.dumps(payload_info) if isinstance(payload_info, dict) else (str(payload_info) if payload_info is not None else None)
is_srv_val = 1 if payload_is_srv else (0 if payload_is_srv is not None else (1 if payload_ost == "windows_server" else None))
try:
cursor.execute("""
UPDATE clients
SET os_type = COALESCE(?, os_type),
os_name = COALESCE(NULLIF(?, ''), os_name),
is_server = COALESCE(?, is_server),
server_roles = COALESCE(?, server_roles),
client_info_json = COALESCE(?, client_info_json)
WHERE client_id = ? OR hostname = ?
""", (payload_ost, payload_os_name, is_srv_val, roles_val, info_val, client_server, client_server))
except Exception:
pass
conn.commit()
conn.close()
@@ -997,8 +1037,17 @@ def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list
def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int:
"""Executes one cycle of local host log collection and direct ingestion into SQLite."""
try:
inv = get_full_system_inventory() if "get_full_system_inventory" in globals() else {}
except Exception:
inv = {}
machine_id = get_machine_identifier()
os_type = "windows" if sys.platform == "win32" else "linux"
os_type = inv.get("os_type", "windows" if sys.platform == "win32" else "linux")
os_name = inv.get("os_name", "")
is_server = 1 if inv.get("is_server") else (1 if os_type == "windows_server" else 0)
server_roles = json.dumps(inv.get("server_roles", []))
client_info_json = json.dumps(inv)
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
try:
@@ -1006,16 +1055,30 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run
c = conn.cursor()
c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,))
if c.fetchone():
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, machine_id))
try:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?,
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json, machine_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, machine_id))
else:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql))
try:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql))
conn.commit()
conn.close()
except Exception as e:
@@ -1394,30 +1457,49 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
client_ver = req.version or ""
req_os_name = req.os_name or ""
req_is_server = 1 if req.is_server else (1 if req.os == "windows_server" else 0)
req_server_roles = json.dumps(req.server_roles) if req.server_roles else "[]"
req_client_info = json.dumps(req.client_info) if req.client_info else "{}"
if existing:
try:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?,
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, client_ver, req.client_id))
""", (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, req.client_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, req.client_id))
try:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, client_ver, req.client_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, req.client_id))
else:
try:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver))
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, ?, 'active', ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql))
try:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, ?, 'active', ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql))
conn.commit()
conn.close()
@@ -2484,6 +2566,171 @@ def serve_webui(request: Request = None):
return HTMLResponse(content=html_content)
@app.get("/client-info", response_class=HTMLResponse)
@app.get("/webui/client-info", response_class=HTMLResponse)
def serve_client_info(request: Request = None):
"""Renders the dedicated LOGAR Client & Server Role Directory subpage."""
auth_user = None
if request is not None:
auth_user = get_current_user_optional(request)
if not auth_user:
return RedirectResponse(url="/login", status_code=303)
else:
auth_user = DEFAULT_ADMIN_USER
html_content = WebUIRenderer.render_client_info_html(
auth_user=auth_user,
request=request,
server_state=SERVER_STATE
)
return HTMLResponse(content=html_content)
@app.get("/api/client-info")
def api_client_info(request: Request = None, auth_user: str = Depends(require_admin_auth)):
"""API endpoint providing comprehensive host telemetry, OS classification, and server roles."""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("""
SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen,
COALESCE(client_version, ''), COALESCE(os_name, ''), COALESCE(is_server, 0),
COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'),
COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0)
FROM clients
ORDER BY is_server DESC, last_seen DESC
""")
rows = c.fetchall()
except Exception as e:
conn.close()
raise HTTPException(status_code=500, detail=f"Database query error: {str(e)}")
conn.close()
clients_list = []
win_servers = 0
win_workstations = 0
linux_nodes = 0
for r in rows:
cid, host, ost, fp, st, first, last, cver, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p = r
try:
roles = json.loads(roles_raw) if roles_raw else []
except Exception:
roles = []
try:
info = json.loads(info_raw) if info_raw else {}
except Exception:
info = {}
is_srv_bool = bool(is_srv) or ost == "windows_server"
if "server" in ost or is_srv_bool:
if "windows" in ost:
win_servers += 1
else:
linux_nodes += 1
elif "windows" in ost:
win_workstations += 1
elif "linux" in ost:
linux_nodes += 1
clients_list.append({
"client_id": cid,
"hostname": host,
"os_type": ost,
"os_name": os_name,
"is_server": is_srv_bool,
"server_roles": roles,
"client_info": info,
"cert_fingerprint": fp,
"status": st,
"first_seen": first,
"last_seen": last,
"client_version": cver,
"metrics": {
"cpu_percent": cpu_p,
"memory_percent": mem_p,
"disk_percent_free": disk_p
}
})
return {
"status": "success",
"total_nodes": len(clients_list),
"windows_servers": win_servers,
"windows_workstations": win_workstations,
"linux_nodes": linux_nodes,
"clients": clients_list
}
@app.get("/api/best-practices/status")
def api_best_practices_status(request: Request = None, auth_user: str = Depends(require_admin_auth)):
"""API endpoint providing fleet-wide Best Practice baseline compliance evaluation."""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
is_licensed = is_feature_licensed("best_practices", db_path)
if not is_licensed:
return {
"status": "locked",
"licensed": False,
"message": "The Best Practice Analyzer is an enterprise capability that requires an active license with the 'best_practices' feature enabled.",
"rules_available": len(BEST_PRACTICES_ENGINE.get_rules()) if BEST_PRACTICES_ENGINE else 0
}
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("""
SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen,
COALESCE(client_version, ''), COALESCE(os_name, ''), COALESCE(is_server, 0),
COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'),
COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0),
COALESCE(update_status, 'up_to_date')
FROM clients
WHERE status = 'active'
""")
rows = c.fetchall()
except Exception:
rows = []
finally:
conn.close()
node_records = []
for r in rows:
cid, host, ost, fp, st, first, last, cver, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p, up_st = r
try:
roles = json.loads(roles_raw) if roles_raw else []
except Exception:
roles = []
try:
info = json.loads(info_raw) if info_raw else {}
except Exception:
info = {}
node_records.append({
"client_id": cid,
"hostname": host,
"os_type": ost,
"os_name": os_name,
"is_server": bool(is_srv) or ost == "windows_server",
"server_roles": roles,
"client_info": info,
"cpu_percent": cpu_p,
"memory_percent": mem_p,
"disk_percent_free": disk_p,
"client_version": cver,
"update_status": up_st
})
eval_result = BEST_PRACTICES_ENGINE.evaluate_fleet(node_records) if BEST_PRACTICES_ENGINE else {}
return {
"status": "active",
"licensed": True,
"evaluation": eval_result
}
@app.get("/health")
def health_check():
return {
+278
View File
@@ -0,0 +1,278 @@
"""
LOGAR Best Practice Analyzer & Security Baseline Engine
Provides a modular, extensible framework for verifying server configuration baselines,
role-specific best practices, and security hardening across enrolled fleet nodes.
Gated by the 'best_practices' enterprise license capability.
"""
import json
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Callable
class BestPracticeRule:
"""Represents a single modular Best Practice configuration check."""
def __init__(
self,
rule_id: str,
category: str,
title: str,
description: str,
recommendation: str,
severity: str = "WARNING", # "CRITICAL" | "WARNING" | "RECOMMENDED"
target_role: Optional[str] = None,
target_os: Optional[str] = None,
eval_fn: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None
):
self.rule_id = rule_id
self.category = category
self.title = title
self.description = description
self.recommendation = recommendation
self.severity = severity
self.target_role = target_role
self.target_os = target_os
self.eval_fn = eval_fn
def to_dict(self) -> Dict[str, Any]:
return {
"rule_id": self.rule_id,
"category": self.category,
"title": self.title,
"description": self.description,
"recommendation": self.recommendation,
"severity": self.severity,
"target_role": self.target_role,
"target_os": self.target_os
}
class BestPracticeEngine:
"""Evaluates rules across enrolled server nodes and collects compliance telemetry."""
def __init__(self):
self._rules: Dict[str, BestPracticeRule] = {}
self._load_baseline_rules()
def register_rule(self, rule: BestPracticeRule):
"""Registers a custom Best Practice check. Enables users to define rules later on."""
self._rules[rule.rule_id] = rule
def get_rules(self) -> List[Dict[str, Any]]:
return [r.to_dict() for r in self._rules.values()]
def _load_baseline_rules(self):
# 1. Disk Capacity Health
def eval_disk_space(node: Dict[str, Any]) -> Dict[str, Any]:
pct = float(node.get("disk_percent_free") or 0.0)
if pct <= 0.0:
return {"status": "NOT_APPLICABLE", "message": "Disk capacity metrics pending telemetry collection."}
if pct < 10.0:
return {"status": "FAIL", "message": f"Critical disk headroom: only {pct}% free capacity remaining on primary volume."}
if pct < 20.0:
return {"status": "WARN", "message": f"Low disk headroom: {pct}% free capacity remaining (recommended >= 20%)."}
return {"status": "PASS", "message": f"Healthy disk capacity: {pct}% free capacity available."}
self.register_rule(BestPracticeRule(
rule_id="BP-SYS-001",
category="System Health",
title="System Volume Storage Headroom (> 20% Free)",
description="Verifies that server and workstation system drives retain at least 20% free space to ensure unhindered OS paging, log buffering, and update installation.",
recommendation="Expand volume storage or clear transient files to maintain healthy write headroom.",
severity="CRITICAL",
eval_fn=eval_disk_space
))
# 2. Memory Headroom
def eval_memory_load(node: Dict[str, Any]) -> Dict[str, Any]:
pct = float(node.get("memory_percent") or 0.0)
if pct <= 0.0:
return {"status": "NOT_APPLICABLE", "message": "Memory metrics pending telemetry collection."}
if pct >= 95.0:
return {"status": "FAIL", "message": f"Exhausted memory headroom: {pct}% active memory consumption."}
if pct >= 88.0:
return {"status": "WARN", "message": f"Elevated memory load: {pct}% utilization (recommended < 85%)."}
return {"status": "PASS", "message": f"Memory utilization optimal: {pct}% in use."}
self.register_rule(BestPracticeRule(
rule_id="BP-SYS-002",
category="System Health",
title="Memory Saturation & Buffer Safety (< 85% Load)",
description="Ensures system RAM utilization remains below critical saturation thresholds to prevent kernel thread swapping and out-of-memory service crashes.",
recommendation="Review high-consumption service processes or allocate additional physical RAM to this server.",
severity="WARNING",
eval_fn=eval_memory_load
))
# 3. Server Role Segregation
def eval_dc_co_location(node: Dict[str, Any]) -> Dict[str, Any]:
roles = node.get("server_roles") or []
if isinstance(roles, str):
try:
roles = json.loads(roles)
except Exception:
roles = [r.strip() for r in roles.split(",") if r.strip()]
roles_lower = [str(r).lower() for r in roles]
has_ad = any("active directory" in r for r in roles_lower)
if not has_ad:
return {"status": "NOT_APPLICABLE", "message": "Node is not designated as an Active Directory Domain Controller."}
# Check for high-risk co-located roles on Domain Controllers
conflicts = []
if any("web server" in r or "iis" in r for r in roles_lower):
conflicts.append("Web Server (IIS)")
if any("remote desktop" in r or "terminal" in r for r in roles_lower):
conflicts.append("Remote Desktop Services")
if any("print" in r for r in roles_lower):
conflicts.append("Print & Document Services")
if conflicts:
return {"status": "FAIL", "message": f"Active Directory Domain Controller co-locates non-recommended roles: {', '.join(conflicts)}."}
return {"status": "PASS", "message": "Domain Controller adheres to dedicated role isolation standards."}
self.register_rule(BestPracticeRule(
rule_id="BP-AD-001",
category="Active Directory",
title="Domain Controller Role Isolation Baseline",
description="Domain Controllers should be dedicated solely to identity services. Co-locating IIS, Remote Desktop, or Print Spooler introduces lateral privilege escalation attack vectors.",
recommendation="Migrate auxiliary web and print services to separate member servers.",
severity="CRITICAL",
target_role="Active Directory Domain Services",
target_os="windows_server",
eval_fn=eval_dc_co_location
))
# 4. Windows Server Detection and Role Discovery
def eval_server_classification(node: Dict[str, Any]) -> Dict[str, Any]:
is_server = bool(node.get("is_server"))
os_type = str(node.get("os_type", "")).lower()
os_name = str(node.get("os_name", ""))
roles = node.get("server_roles") or []
if isinstance(roles, str):
try:
roles = json.loads(roles)
except Exception:
roles = []
if "server" in os_type or "server" in os_name.lower():
if len(roles) > 0:
return {"status": "PASS", "message": f"Windows Server edition identified ({os_name}) with {len(roles)} detected enterprise roles."}
else:
return {"status": "WARN", "message": f"Windows Server identified ({os_name}), but no server roles currently indexed."}
elif os_type in ("windows", "workstation"):
return {"status": "PASS", "message": f"Workstation node ({os_name}) correctly classified without server role footprint."}
return {"status": "PASS", "message": f"Host classification verified: {os_name or os_type}."}
self.register_rule(BestPracticeRule(
rule_id="BP-WIN-002",
category="Role Baseline",
title="Operating System Edition & Server Role Alignment",
description="Verifies that nodes are accurately differentiated between client workstations and enterprise servers, and that active server roles are fully inventoried.",
recommendation="Ensure the LOGAR edge forwarder runs with sufficient permissions to query Windows Server Manager.",
severity="RECOMMENDED",
eval_fn=eval_server_classification
))
# 5. Forwarder Agent Freshness
def eval_agent_version(node: Dict[str, Any]) -> Dict[str, Any]:
ver = str(node.get("client_version") or "").strip()
up_status = str(node.get("update_status") or "up_to_date").lower()
if not ver:
return {"status": "WARN", "message": "Client version not yet reported to hub."}
if up_status == "outdated":
return {"status": "WARN", "message": f"Edge client version (v{ver}) is outdated relative to hosted hub binaries."}
return {"status": "PASS", "message": f"Edge forwarder binary (v{ver}) is current."}
self.register_rule(BestPracticeRule(
rule_id="BP-SEC-003",
category="Security Baseline",
title="Edge Forwarder Binary Version Baseline",
description="Ensures forwarder nodes run the latest patched agent binary with current TLS 1.3 cryptographic cipher suites and security fixes.",
recommendation="Enable Fleet Auto-Update or update the forwarder executable to the latest hosted release.",
severity="RECOMMENDED",
eval_fn=eval_agent_version
))
def evaluate_node(self, node: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Evaluates all applicable rules against a single server/node record."""
results = []
for rule in self._rules.values():
# Check target OS constraint
if rule.target_os and str(node.get("os_type", "")).lower() != rule.target_os.lower():
continue
# Check target role constraint
if rule.target_role:
roles = node.get("server_roles") or []
if isinstance(roles, str):
try:
roles = json.loads(roles)
except Exception:
roles = [r.strip() for r in roles.split(",") if r.strip()]
roles_str = " ".join(str(r) for r in roles).lower()
if rule.target_role.lower() not in roles_str:
continue
eval_res = {"status": "PASS", "message": "Check passed baseline standards."}
if rule.eval_fn:
try:
eval_res = rule.eval_fn(node)
except Exception as ex:
eval_res = {"status": "ERROR", "message": f"Evaluation error: {str(ex)}"}
results.append({
"rule_id": rule.rule_id,
"category": rule.category,
"title": rule.title,
"severity": rule.severity,
"recommendation": rule.recommendation,
"status": eval_res.get("status", "PASS"),
"message": eval_res.get("message", "")
})
return results
def evaluate_fleet(self, nodes: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Evaluates all registered rules across all fleet nodes and returns aggregated summary."""
fleet_results = {}
pass_count = 0
warn_count = 0
fail_count = 0
for node in nodes:
cid = node.get("client_id") or node.get("hostname") or "unknown"
node_checks = self.evaluate_node(node)
fleet_results[cid] = {
"hostname": node.get("hostname", cid),
"os_type": node.get("os_type", "unknown"),
"os_name": node.get("os_name", ""),
"is_server": bool(node.get("is_server")),
"checks": node_checks
}
for chk in node_checks:
st = chk.get("status")
if st == "PASS":
pass_count += 1
elif st == "WARN":
warn_count += 1
elif st == "FAIL":
fail_count += 1
total_evaluated = pass_count + warn_count + fail_count
score = int((pass_count / total_evaluated * 100)) if total_evaluated > 0 else 100
return {
"score": score,
"pass_count": pass_count,
"warn_count": warn_count,
"fail_count": fail_count,
"total_evaluated": total_evaluated,
"evaluated_nodes_count": len(nodes),
"fleet_results": fleet_results,
"rules_count": len(self._rules)
}
# Global singleton instance
BEST_PRACTICES_ENGINE = BestPracticeEngine()
+4
View File
@@ -13,6 +13,10 @@ class ClientEnrollRequest(BaseModel):
os: str
enrollment_secret: str
version: Optional[str] = None
os_name: Optional[str] = None
is_server: Optional[bool] = None
server_roles: Optional[List[str]] = None
client_info: Optional[Dict[str, Any]] = None
class ClientUpdateRequest(BaseModel):
+10 -2
View File
@@ -233,7 +233,11 @@ class StorageEngine:
("disk_percent_free", "REAL DEFAULT 0.0"),
("client_version", "TEXT DEFAULT ''"),
("update_status", "TEXT DEFAULT 'up_to_date'"),
("last_update_check", "TEXT")
("last_update_check", "TEXT"),
("os_name", "TEXT DEFAULT ''"),
("is_server", "INTEGER DEFAULT 0"),
("server_roles", "TEXT DEFAULT '[]'"),
("client_info_json", "TEXT DEFAULT '{}'")
])
# 5. Users Table
@@ -429,7 +433,11 @@ class StorageEngine:
("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"),
("client_version", "VARCHAR(50) DEFAULT ''"),
("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"),
("last_update_check", "VARCHAR(50)")
("last_update_check", "VARCHAR(50)"),
("os_name", "VARCHAR(255) DEFAULT ''"),
("is_server", "INTEGER DEFAULT 0"),
("server_roles", "TEXT DEFAULT '[]'"),
("client_info_json", "TEXT DEFAULT '{}'")
]:
try:
cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}")
+790 -6
View File
@@ -19,6 +19,14 @@ try:
except ImportError:
from version import LOGAR_VERSION, is_newer_version
try:
from src.hub.best_practices import BEST_PRACTICES_ENGINE
except ImportError:
try:
from best_practices import BEST_PRACTICES_ENGINE
except ImportError:
BEST_PRACTICES_ENGINE = None
def format_relative_time(dt_str: Optional[str]) -> str:
"""Calculates human-readable relative time from ISO or SQL timestamp string."""
@@ -205,11 +213,20 @@ class WebUIRenderer:
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date') FROM clients ORDER BY last_seen DESC")
c.execute("""
SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen,
COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date'),
COALESCE(os_name, ''), COALESCE(is_server, 0), COALESCE(server_roles, '[]')
FROM clients ORDER BY last_seen DESC
""")
clients = c.fetchall()
except sqlite3.OperationalError:
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
clients = [r + ("", "up_to_date") for r in c.fetchall()]
try:
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date') FROM clients ORDER BY last_seen DESC")
clients = [r + ("", 0, "[]") for r in c.fetchall()]
except sqlite3.OperationalError:
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
clients = [r + ("", "up_to_date", "", 0, "[]") for r in c.fetchall()]
# Fetch verified anomalies
c.execute("SELECT fingerprint, server, severity, message, run_count, last_seen FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC LIMIT 50")
@@ -225,10 +242,18 @@ class WebUIRenderer:
hermes_tokens = c.fetchall()
conn.close()
# Build client rows with exact + relative activity time, version badges, and seat management actions
# Build client rows with exact + relative activity time, version badges, OS differentiation, and seat management actions
client_rows = []
for cl in clients:
cid, host, ost, fp, st, first, last, cver, up_st = cl[:9]
os_name = cl[9] if len(cl) > 9 else ""
is_server = bool(cl[10]) if len(cl) > 10 else (ost == "windows_server")
roles_raw = cl[11] if len(cl) > 11 else "[]"
try:
roles = json.loads(roles_raw) if roles_raw else []
except Exception:
roles = []
esc_cid = html.escape(str(cid))
esc_host = html.escape(str(host))
esc_ost = html.escape(str(ost))
@@ -246,6 +271,17 @@ class WebUIRenderer:
else:
ver_display = f'<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":
esc_st = "LOCAL HUB HOST"
badge_cls = "badge-active"
@@ -266,7 +302,7 @@ class WebUIRenderer:
<td><span class="badge {badge_cls}">{esc_st.upper()}</span></td>
<td><strong>{esc_host}</strong></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>{fp_display}</td>
<td>{last_display}</td>
@@ -424,6 +460,21 @@ class WebUIRenderer:
</div>
</div>
""")
elif feat == "best_practices":
feature_cards.append("""
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.35); display: flex; align-items: flex-start; gap: 14px;">
<div style="font-size: 22px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 3px;">
<strong style="color: #34d399; font-size: 14px;">best_practices</strong>
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.4;">
<strong>Best Practice &amp; Security Baseline Analyzer:</strong> Unlocks automated audit checks, server role configuration validation, Domain Controller isolation checks, and hardening baselines across your fleet.
</div>
</div>
</div>
""")
else:
esc_feat = html.escape(str(feat))
feat_title = html.escape(str(feat).replace("_", " ").title())
@@ -1071,6 +1122,7 @@ class WebUIRenderer:
<!-- Navigation Tabs -->
<div class="tabs-nav">
<button class="tab-btn active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders</button>
<a href="/client-info" class="tab-btn" style="text-decoration:none; display:inline-flex; align-items:center; gap:8px;" title="Open dedicated client info and server roles directory">🖥️ Server Roles &amp; Client Directory</a>
<button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens</button>
<button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry</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-header">
<span class="card-title" style="font-size: 15px;">Enrolled Forwarders & Seat Management</span>
<div style="display: flex; gap: 8px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="/client-info" class="btn btn-outline" style="padding:6px 12px; font-size:12px; text-decoration:none;" title="Open dedicated client info and server roles directory">📋 Server Roles &amp; Directory (/client-info)</a>
<button onclick="resetEnrollmentSecret()" class="btn btn-outline" style="padding:6px 12px; font-size:12px;" title="Reset shared enrollment secret key">🔄 Reset Shared Key</button>
<button onclick="openExportProfileModal()" class="btn btn-accent" style="padding:6px 12px; font-size:12px;">+ Export New Client Profile</button>
</div>
@@ -2171,3 +2224,734 @@ class WebUIRenderer:
</html>"""
return html_content
@staticmethod
def render_client_info_html(
auth_user: Optional[str] = None,
request: Optional[Any] = None,
server_state: Optional[Dict[str, Any]] = None
) -> str:
"""
Renders the dedicated LOGAR Client & Server Role Directory subpage (/client-info).
Explicitly differentiates Windows Client vs Windows Server vs Linux,
displays installed server roles, hardware specs, and Best Practice baseline compliance.
"""
state = server_state or {}
config = state.get("config", {})
db_path = config.get("db_path", "logar_state.db")
auth_display = html.escape(str(auth_user or "admin"))
# Check Best Practice licensing feature
is_bp_licensed = LicenseManager.is_feature_licensed("best_practices", db_path=db_path, server_state=state)
# Query clients and host telemetry
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("""
SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen,
COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date'),
COALESCE(os_name, ''), COALESCE(is_server, 0),
COALESCE(server_roles, '[]'), COALESCE(client_info_json, '{}'),
COALESCE(cpu_percent, 0.0), COALESCE(memory_percent, 0.0), COALESCE(disk_percent_free, 0.0)
FROM clients
ORDER BY is_server DESC, last_seen DESC
""")
raw_clients = c.fetchall()
except sqlite3.OperationalError:
try:
c.execute("""
SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen,
COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date')
FROM clients ORDER BY last_seen DESC
""")
raw_clients = [r + ("", 0, "[]", "{}", 0.0, 0.0, 0.0) for r in c.fetchall()]
except Exception:
raw_clients = []
finally:
conn.close()
clients_data = []
win_servers = 0
win_workstations = 0
linux_nodes = 0
total_roles_detected = 0
for r in raw_clients:
cid, host, ost, fp, st, first, last, cver, up_st, os_name, is_srv, roles_raw, info_raw, cpu_p, mem_p, disk_p = r
try:
roles = json.loads(roles_raw) if roles_raw else []
if not isinstance(roles, list):
roles = [str(roles)]
except Exception:
roles = []
try:
info = json.loads(info_raw) if info_raw else {}
if not isinstance(info, dict):
info = {}
except Exception:
info = {}
is_server_bool = bool(is_srv) or ost == "windows_server"
ost_lower = ost.lower()
if "server" in ost_lower or is_server_bool:
if "windows" in ost_lower or "win" in ost_lower:
win_servers += 1
else:
linux_nodes += 1
elif "windows" in ost_lower or "win" in ost_lower:
win_workstations += 1
elif "linux" in ost_lower:
linux_nodes += 1
total_roles_detected += len(roles)
clients_data.append({
"client_id": cid,
"hostname": host,
"os_type": ost,
"os_name": os_name,
"is_server": is_server_bool,
"server_roles": roles,
"client_info": info,
"cert_fingerprint": fp,
"status": st,
"first_seen": first,
"last_seen": last,
"client_version": cver,
"update_status": up_st,
"cpu_percent": float(cpu_p or 0.0),
"memory_percent": float(mem_p or 0.0),
"disk_percent_free": float(disk_p or 0.0)
})
total_nodes = len(clients_data)
# Evaluate fleet Best Practices if licensed
bp_evaluation = None
if is_bp_licensed and BEST_PRACTICES_ENGINE:
try:
bp_evaluation = BEST_PRACTICES_ENGINE.evaluate_fleet(clients_data)
except Exception as ex:
bp_evaluation = {"score": 0, "pass_count": 0, "warn_count": 0, "fail_count": 0, "rules_count": 0, "error": str(ex), "fleet_results": {}}
# Build Best Practice summary card HTML
if is_bp_licensed and bp_evaluation:
score = bp_evaluation.get("score", 100)
score_color = "#34d399" if score >= 90 else ("#fbbf24" if score >= 70 else "#f87171")
pass_cnt = bp_evaluation.get("pass_count", 0)
warn_cnt = bp_evaluation.get("warn_count", 0)
fail_cnt = bp_evaluation.get("fail_count", 0)
total_rules = bp_evaluation.get("rules_count", 0)
# Build detailed checks overview
rules_list = BEST_PRACTICES_ENGINE.get_rules() if BEST_PRACTICES_ENGINE else []
rule_rows = []
for r in rules_list:
rid = html.escape(str(r.get("rule_id", "")))
rtitle = html.escape(str(r.get("title", "")))
rcat = html.escape(str(r.get("category", "")))
rsev = html.escape(str(r.get("severity", "WARNING")))
rrec = html.escape(str(r.get("recommendation", "")))
rdesc = html.escape(str(r.get("description", "")))
sev_badge = "badge-error" if rsev == "CRITICAL" else ("badge-warning" if rsev == "WARNING" else "badge-os")
rule_rows.append(f"""
<div style="background: #0b0f19; border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 8px;">
<code style="font-size: 11px; background: rgba(59, 130, 246, 0.15); color: #60a5fa; padding: 2px 6px; border-radius: 4px;">{rid}</code>
<strong style="font-size: 13px; color: #f8fafc;">{rtitle}</strong>
<span class="badge {sev_badge}" style="font-size: 9px;">{rsev}</span>
</div>
<span style="font-size: 11px; color: var(--text-muted); background: #111827; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border);">{rcat}</span>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px; line-height: 1.4;">{rdesc}</div>
<div style="font-size: 11px; color: #34d399;"><strong style="color: #6ee7b7;">Recommendation:</strong> {rrec}</div>
</div>
""")
bp_rules_html = "".join(rule_rows)
bp_banner_html = f"""
<div class="card" style="border: 1px solid rgba(16, 185, 129, 0.35); background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(15, 23, 42, 0.8));">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 2px;">
<h3 style="margin: 0; font-size: 16px; color: #34d399;">Best Practice &amp; Server Role Baseline Analyzer</h3>
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED • ACTIVE</span>
</div>
<div style="font-size: 12px; color: var(--text-muted);">
Comprehensive security baseline, Active Directory role segregation, and storage buffer auditing active across {total_nodes} nodes.
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="text-align: right;">
<div style="font-size: 26px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: {score_color};">{score}%</div>
<div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Fleet Compliance Score</div>
</div>
<div style="display: flex; gap: 8px;">
<span class="badge badge-active" style="font-size: 11px; padding: 6px 10px;">✅ {pass_cnt} PASS</span>
<span class="badge badge-warning" style="font-size: 11px; padding: 6px 10px;">⚠️ {warn_cnt} WARN</span>
<span class="badge badge-error" style="font-size: 11px; padding: 6px 10px;">❌ {fail_cnt} FAIL</span>
</div>
</div>
</div>
<details style="cursor: pointer;">
<summary style="font-size: 12px; font-weight: 700; color: #60a5fa; outline: none; margin-bottom: 12px;">
▶ View {total_rules} Active Baseline Audit Rules &amp; Remediation Standards
</summary>
<div style="margin-top: 10px;">
{bp_rules_html}
</div>
</details>
</div>
"""
bp_kpi_value = f"{score}%"
bp_kpi_sub = f"{pass_cnt} passing • {warn_cnt} warnings • {fail_cnt} failures"
bp_kpi_badge = f'<span class="badge badge-active" style="color:{score_color};">{score}% COMPLIANT</span>'
else:
bp_banner_html = """
<div class="card" style="border: 1px solid rgba(234, 179, 8, 0.4); background: linear-gradient(135deg, rgba(30, 41, 59, 0.7), rgba(15, 23, 42, 0.9)); margin-bottom: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: flex-start; gap: 16px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
<h3 style="margin: 0; font-size: 16px; color: #facc15;">Best Practice Analyzer &amp; Role Baseline Engine</h3>
<span class="badge" style="background: rgba(234, 179, 8, 0.2); color: #facc15; border: 1px solid rgba(234, 179, 8, 0.4); font-size: 10px;">ENTERPRISE CAPABILITY • INACTIVE</span>
</div>
<p style="margin: 0; font-size: 13px; color: var(--text-muted); max-width: 820px; line-height: 1.5;">
Automated configuration baseline auditing, Active Directory role isolation checks (e.g. verifying Domain Controllers do not co-locate IIS or Remote Desktop), and fleet hardening standards require an active enterprise license with the <code>best_practices</code> capability enabled.
</p>
<div style="margin-top: 8px; font-size: 12px; color: #94a3b8;">
💡 <em>This capability is inactive by default. You can define rules later on and activate it in the LOGAR Licensing Portal at any time.</em>
</div>
</div>
</div>
<div>
<a href="/webui#pki" class="btn btn-outline" style="border-color: #facc15; color: #facc15; font-size: 12px; text-decoration: none;">📜 View Licensing Portal</a>
</div>
</div>
</div>
"""
bp_kpi_value = "INACTIVE"
bp_kpi_sub = "Requires 'best_practices' license capability"
bp_kpi_badge = '<span class="badge" style="background:rgba(234, 179, 8, 0.15); color:#facc15; border:1px solid rgba(234, 179, 8, 0.3);">LOCKED</span>'
# Build Host Rows
node_rows = []
for node in clients_data:
cid = node["client_id"]
host = node["hostname"]
ost = node["os_type"]
os_name = node["os_name"]
is_srv = node["is_server"]
roles = node["server_roles"]
info = node["client_info"]
fp = node["cert_fingerprint"]
st = node["status"]
last = node["last_seen"]
cver = node["client_version"]
cpu_p = node["cpu_percent"]
mem_p = node["memory_percent"]
disk_p = node["disk_percent_free"]
esc_cid = html.escape(str(cid))
esc_host = html.escape(str(host))
badge_st_cls = "badge-active" if st == "active" else "badge-revoked"
rel_time = format_relative_time(last)
# Node Classification Badge & Filter Category
ost_lower = ost.lower()
if "server" in ost_lower or is_srv:
if "windows" in ost_lower or "win" in ost_lower:
filter_cat = "windows_server"
os_badge = '<span class="badge" style="background:rgba(147, 51, 234, 0.2); color:#c084fc; border:1px solid rgba(147, 51, 234, 0.4); font-size:10px;">🏢 WINDOWS SERVER</span>'
os_display_name = html.escape(os_name or "Windows Server")
else:
filter_cat = "linux"
os_badge = '<span class="badge badge-os" style="font-size:10px;">🐧 LINUX SERVER</span>'
os_display_name = html.escape(os_name or "Linux Node")
elif "windows" in ost_lower or "win" in ost_lower:
filter_cat = "windows_client"
os_badge = '<span class="badge" style="background:rgba(59, 130, 246, 0.2); color:#60a5fa; border:1px solid rgba(59, 130, 246, 0.4); font-size:10px;">🖥️ WINDOWS CLIENT</span>'
os_display_name = html.escape(os_name or "Windows Workstation")
elif "linux" in ost_lower:
filter_cat = "linux"
os_badge = '<span class="badge badge-os" style="font-size:10px;">🐧 LINUX</span>'
os_display_name = html.escape(os_name or "Linux Node")
else:
filter_cat = "other"
os_badge = f'<span class="badge badge-os" style="font-size:10px;">{html.escape(ost.upper())}</span>'
os_display_name = html.escape(os_name or ost)
edition_str = html.escape(str(info.get("edition") or ""))
build_str = html.escape(str(info.get("build") or ""))
arch_str = html.escape(str(info.get("architecture") or ""))
specs_sub = f"{edition_str} • Build {build_str} • {arch_str}".strip(" •") if (edition_str or build_str or arch_str) else ""
# Server Roles Badges
role_badges = []
if roles:
for r in roles:
esc_r = html.escape(str(r))
r_lower = str(r).lower()
if "active directory" in r_lower or "domain" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(59, 130, 246, 0.25); color:#93c5fd; border:1px solid #3b82f6; font-size:10px; margin:2px;" title="{esc_r}">🌐 {esc_r}</span>')
elif "dns" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(168, 85, 247, 0.25); color:#d8b4fe; border:1px solid #a855f7; font-size:10px; margin:2px;" title="{esc_r}">📡 {esc_r}</span>')
elif "dhcp" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(234, 179, 8, 0.25); color:#fde047; border:1px solid #eab308; font-size:10px; margin:2px;" title="{esc_r}">📶 {esc_r}</span>')
elif "web server" in r_lower or "iis" in r_lower or "nginx" in r_lower or "apache" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(6, 182, 212, 0.25); color:#67e8f9; border:1px solid #06b6d4; font-size:10px; margin:2px;" title="{esc_r}">🌍 {esc_r}</span>')
elif "hyper-v" in r_lower or "kvm" in r_lower or "qemu" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(16, 185, 129, 0.25); color:#6ee7b7; border:1px solid #10b981; font-size:10px; margin:2px;" title="{esc_r}">⚡ {esc_r}</span>')
elif "remote desktop" in r_lower or "terminal" in r_lower or "ssh" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(244, 63, 94, 0.25); color:#fda4af; border:1px solid #f43f5e; font-size:10px; margin:2px;" title="{esc_r}">🖥️ {esc_r}</span>')
elif "file" in r_lower or "storage" in r_lower or "samba" in r_lower or "nfs" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(99, 102, 241, 0.25); color:#a5b4fc; border:1px solid #6366f1; font-size:10px; margin:2px;" title="{esc_r}">📁 {esc_r}</span>')
elif "print" in r_lower or "cups" in r_lower:
role_badges.append(f'<span class="badge" style="background:rgba(148, 163, 184, 0.25); color:#cbd5e1; border:1px solid #94a3b8; font-size:10px; margin:2px;" title="{esc_r}">🖨️ {esc_r}</span>')
else:
role_badges.append(f'<span class="badge" style="background:#1e293b; color:#38bdf8; border:1px solid var(--border); font-size:10px; margin:2px;" title="{esc_r}">⚙️ {esc_r}</span>')
roles_cell_html = "".join(role_badges)
else:
roles_cell_html = '<span style="color:#64748b; font-size:12px; font-style:italic;">Workstation / Member Node (0 Roles)</span>'
# Hardware specs & telemetry
ip_list = info.get("ip_addresses") or []
ip_str = ", ".join(ip_list[:2]) if ip_list else "Local"
mem_tot = info.get("memory_total_mb")
mem_tot_str = f"{round(mem_tot / 1024, 1)} GB" if mem_tot else ""
cpu_cnt = info.get("cpu_count")
cpu_cnt_str = f"{cpu_cnt} Cores • " if cpu_cnt else ""
cpu_bar_col = "#34d399" if cpu_p < 75 else ("#fbbf24" if cpu_p < 90 else "#f87171")
mem_bar_col = "#34d399" if mem_p < 75 else ("#fbbf24" if mem_p < 90 else "#f87171")
telemetry_html = f"""
<div style="font-size:11px; line-height: 1.5;">
<div><span style="color:var(--text-muted);">CPU:</span> {cpu_cnt_str}<strong style="color:{cpu_bar_col};">{cpu_p}%</strong></div>
<div><span style="color:var(--text-muted);">RAM:</span> {mem_tot_str} (<strong style="color:{mem_bar_col};">{mem_p}%</strong> in use)</div>
<div><span style="color:var(--text-muted);">Disk Free:</span> <strong style="color:#34d399;">{disk_p}%</strong></div>
<div style="color:#94a3b8; font-family:'JetBrains Mono',monospace; font-size:10px;">{html.escape(ip_str)}</div>
</div>
"""
# Builtin indicator
if fp == "LOCAL-HOST-BUILTIN":
host_tag = '<span class="badge badge-active" style="font-size:9px; margin-left:6px;">⚡ LOCAL HUB</span>'
else:
host_tag = ''
# Best Practice node compliance result
if is_bp_licensed and bp_evaluation and "fleet_results" in bp_evaluation:
node_eval = bp_evaluation["fleet_results"].get(cid) or bp_evaluation["fleet_results"].get(host)
if node_eval:
n_checks = node_eval.get("checks", [])
n_fails = sum(1 for c in n_checks if c.get("status") == "FAIL")
n_warns = sum(1 for c in n_checks if c.get("status") == "WARN")
n_passes = sum(1 for c in n_checks if c.get("status") == "PASS")
if n_fails > 0:
bp_node_badge = f'<span class="badge badge-error" style="font-size:10px;" title="{n_fails} failed checks">❌ {n_fails} FAIL</span>'
elif n_warns > 0:
bp_node_badge = f'<span class="badge badge-warning" style="font-size:10px;" title="{n_warns} warnings">⚠️ {n_warns} WARN</span>'
else:
bp_node_badge = f'<span class="badge badge-active" style="font-size:10px;" title="{n_passes} checks passed">✅ {n_passes} PASS</span>'
else:
bp_node_badge = '<span class="badge badge-active" style="font-size:10px;">✅ HEALTHY</span>'
else:
bp_node_badge = '<span class="badge" style="background:#1e293b; color:#94a3b8; font-size:10px;">🔒 Locked</span>'
# Client info JSON payload for inspect modal
safe_info_json = html.escape(json.dumps(node, indent=2))
node_rows.append(f"""
<tr class="node-row" data-category="{filter_cat}" data-search="{esc_host.lower()} {esc_cid.lower()} {os_display_name.lower()} {' '.join(str(x).lower() for x in roles)} {ip_str.lower()}">
<td>
<div style="display:flex; align-items:center; gap:6px;">
<span class="badge {badge_st_cls}">{st.upper()}</span>
</div>
</td>
<td>
<div style="font-weight:700; color:#fff; font-size:13px;">{esc_host}{host_tag}</div>
<div style="font-size:11px; color:var(--text-muted); font-family:'JetBrains Mono',monospace;">{esc_cid}</div>
</td>
<td>
<div>{os_badge}</div>
<div style="font-size:12px; font-weight:600; color:#e2e8f0; margin-top:3px;">{os_display_name}</div>
<div style="font-size:11px; color:#94a3b8;">{specs_sub}</div>
</td>
<td style="max-width: 320px;">
<div style="display:flex; flex-wrap:wrap; gap:4px;">
{roles_cell_html}
</div>
</td>
<td>{telemetry_html}</td>
<td>
<div><span class="badge badge-active" style="font-size:10px;">v{html.escape(str(cver or LOGAR_VERSION))}</span></div>
<div style="font-size:11px; color:var(--text-muted); margin-top:3px;">{html.escape(rel_time)}</div>
<div style="font-size:10px; color:#64748b;">{html.escape(str(last[:19]))}</div>
</td>
<td>{bp_node_badge}</td>
<td>
<button onclick="openTelemetryModal('{esc_cid}', '{esc_host}', `{safe_info_json}`)" class="btn btn-outline" style="padding:4px 8px; font-size:11px;">
🔍 Specs JSON
</button>
</td>
</tr>
""")
nodes_table_html = "".join(node_rows) if node_rows else '<tr><td colspan="8" class="empty-state">No monitored nodes registered yet.</td></tr>'
# Render complete subpage HTML
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LOGAR | Server &amp; Client Role Directory</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {{
--bg: #0b0f19;
--card: #111827;
--border: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--accent: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--purple: #8b5cf6;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
background-color: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}}
.container {{ max-width: 1540px; margin: 0 auto; padding: 24px; width: 100%; }}
header {{
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 20px; border-bottom: 1px solid var(--border); margin-bottom: 24px;
flex-wrap: wrap; gap: 16px;
}}
.logo-area {{ display: flex; align-items: center; gap: 14px; }}
.logo-icon {{
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800; color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
}}
.logo-title {{ font-size: 20px; font-weight: 800; letter-spacing: -0.5px; color: #fff; }}
.logo-sub {{ font-size: 12px; color: var(--text-muted); margin-top: 2px; }}
/* Buttons & Badges */
.btn {{
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; border: none; text-decoration: none; transition: all 0.2s;
}}
.btn-outline {{ background: transparent; border: 1px solid var(--border); color: var(--text); }}
.btn-outline:hover {{ border-color: var(--primary); background: rgba(59, 130, 246, 0.08); }}
.btn-accent {{ background: var(--accent); color: #022c22; }}
.btn-accent:hover {{ opacity: 0.9; }}
.btn-primary {{ background: var(--primary); color: #fff; }}
.btn-primary:hover {{ background: var(--primary-hover); }}
.badge {{
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
}}
.badge-active {{ background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }}
.badge-revoked, .badge-error {{ background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }}
.badge-warning {{ background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }}
.badge-os {{ background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }}
/* KPI Cards */
.grid-kpi {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}}
.card {{
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
margin-bottom: 24px;
}}
.card-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }}
.card-title {{ font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; }}
.kpi-value {{ font-size: 26px; font-weight: 800; font-family: 'JetBrains Mono', monospace; margin-bottom: 4px; }}
.kpi-sub {{ font-size: 12px; color: var(--text-muted); }}
/* Filter Toolbar */
.filter-toolbar {{
display: flex; justify-content: space-between; align-items: center;
gap: 12px; margin-bottom: 16px; flex-wrap: wrap;
}}
.filter-pills {{ display: flex; gap: 8px; flex-wrap: wrap; }}
.filter-pill {{
padding: 7px 14px; border-radius: 8px; font-size: 12px; font-weight: 600;
background: #0f172a; border: 1px solid var(--border); color: var(--text-muted);
cursor: pointer; transition: all 0.2s;
}}
.filter-pill:hover {{ color: #fff; border-color: var(--primary); }}
.filter-pill.active {{ background: var(--primary); color: #fff; border-color: var(--primary); }}
.search-box {{
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border);
background: #0f172a; color: #fff; font-size: 13px; min-width: 280px;
outline: none; transition: border-color 0.2s;
}}
.search-box:focus {{ border-color: var(--primary); }}
/* Table */
table {{ width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }}
th {{
padding: 12px 14px; color: var(--text-muted); font-size: 11px;
font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid var(--border); background: #0f172a;
}}
td {{ padding: 12px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; }}
tr:hover td {{ background: rgba(255, 255, 255, 0.02); }}
/* Modal */
.modal-overlay {{
display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(4px);
z-index: 1000; align-items: center; justify-content: center;
}}
.modal {{
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
width: 90%; max-width: 780px; max-height: 85vh; display: flex; flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.8);
}}
.modal-header {{
padding: 16px 20px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
}}
.modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
.modal-footer {{
padding: 14px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 10px;
}}
pre.code-viewer {{
background: #0b0f19; border: 1px solid var(--border); border-radius: 8px;
padding: 14px; font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: #38bdf8; overflow-x: auto; max-height: 480px;
}}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<header>
<div class="logo-area">
<div class="logo-icon">🏢</div>
<div>
<div class="logo-title">LOGAR Server &amp; Client Role Directory</div>
<div class="logo-sub">OS Classification • Windows &amp; Linux Server Roles • Hardware Telemetry • Best Practice Baseline</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<a href="/webui" class="btn btn-outline" style="font-size: 13px;">
← Back to Fleet Hub
</a>
<a href="/webui#pki" class="btn btn-outline" style="font-size: 13px;">
📜 Sub-CA &amp; Licensing
</a>
<span style="font-size: 12px; color: var(--text-muted); background: #0f172a; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);">
👤 {auth_display}
</span>
</div>
</header>
<!-- KPI Cards -->
<div class="grid-kpi">
<div class="card">
<div class="card-header">
<span class="card-title">Total Monitored Nodes</span>
<span class="badge badge-active">{total_nodes} Enrolled</span>
</div>
<div class="kpi-value">{total_nodes}</div>
<div class="kpi-sub">Total active edge nodes and local hub host</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Windows Server Fleet</span>
<span class="badge" style="background:rgba(147, 51, 234, 0.2); color:#c084fc; border:1px solid rgba(147, 51, 234, 0.4);">{win_servers} Servers</span>
</div>
<div class="kpi-value" style="color: #c084fc;">{win_servers}</div>
<div class="kpi-sub">{total_roles_detected} enterprise server roles inventoried</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Windows Workstations</span>
<span class="badge badge-os">{win_workstations} Clients</span>
</div>
<div class="kpi-value" style="color: #60a5fa;">{win_workstations}</div>
<div class="kpi-sub">Desktop Windows 10/11 edge forwarders</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Linux Server Nodes</span>
<span class="badge badge-os">{linux_nodes} Nodes</span>
</div>
<div class="kpi-value" style="color: #38bdf8;">{linux_nodes}</div>
<div class="kpi-sub">Linux distributions and cloud containers</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Best Practice Baseline</span>
{bp_kpi_badge}
</div>
<div class="kpi-value">{bp_kpi_value}</div>
<div class="kpi-sub">{bp_kpi_sub}</div>
</div>
</div>
<!-- Best Practice Analyzer Banner / Card -->
{bp_banner_html}
<!-- Server & Host Directory Section -->
<div class="card">
<div class="card-header" style="flex-wrap: wrap; gap: 12px;">
<div>
<h3 style="font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 3px;">Node Telemetry &amp; Server Roles Inventory</h3>
<div style="font-size: 12px; color: var(--text-muted);">
Real-time hardware metrics, OS edition introspection, and installed enterprise server roles across your fleet.
</div>
</div>
</div>
<!-- Filter Toolbar -->
<div class="filter-toolbar">
<div class="filter-pills">
<button class="filter-pill active" onclick="setFilter('all', this)">All Nodes ({total_nodes})</button>
<button class="filter-pill" onclick="setFilter('windows_server', this)">🏢 Windows Servers ({win_servers})</button>
<button class="filter-pill" onclick="setFilter('windows_client', this)">🖥️ Windows Clients ({win_workstations})</button>
<button class="filter-pill" onclick="setFilter('linux', this)">🐧 Linux Nodes ({linux_nodes})</button>
</div>
<div>
<input type="text" id="nodeSearchInput" class="search-box" placeholder="🔍 Search hostname, role, IP, OS..." oninput="filterTable()">
</div>
</div>
<div style="overflow-x: auto;">
<table id="nodesTable">
<thead>
<tr>
<th>Status</th>
<th>Host Node</th>
<th>Operating System</th>
<th>Installed Server Roles</th>
<th>Hardware Telemetry</th>
<th>Agent Version</th>
<th>Compliance</th>
<th>Specs</th>
</tr>
</thead>
<tbody>
{nodes_table_html}
</tbody>
</table>
</div>
</div>
</div>
<!-- Telemetry JSON Inspector Modal -->
<div id="telemetryModal" class="modal-overlay" onclick="closeTelemetryModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<div>
<h4 id="modalHostTitle" style="font-size: 15px; font-weight: 700; color: #fff;">Host Telemetry Inspector</h4>
<div id="modalClientIdSub" style="font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;"></div>
</div>
<button onclick="closeTelemetryModal()" class="btn btn-outline" style="padding: 4px 8px; font-size: 12px;">✕</button>
</div>
<div class="modal-body">
<pre id="modalJsonContent" class="code-viewer"></pre>
</div>
<div class="modal-footer">
<button onclick="copyModalJson()" class="btn btn-primary" id="copyJsonBtn">📋 Copy JSON</button>
<button onclick="closeTelemetryModal()" class="btn btn-outline">Close</button>
</div>
</div>
</div>
<script>
let currentFilter = 'all';
function setFilter(category, btn) {{
currentFilter = category;
document.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
filterTable();
}}
function filterTable() {{
const query = (document.getElementById('nodeSearchInput').value || '').toLowerCase().trim();
const rows = document.querySelectorAll('#nodesTable tbody tr.node-row');
rows.forEach(row => {{
const cat = row.getAttribute('data-category');
const searchData = (row.getAttribute('data-search') || '').toLowerCase();
const matchesCat = (currentFilter === 'all') || (cat === currentFilter);
const matchesSearch = !query || searchData.includes(query);
if (matchesCat && matchesSearch) {{
row.style.display = '';
}} else {{
row.style.display = 'none';
}}
}});
}}
function openTelemetryModal(clientId, hostname, jsonStr) {{
document.getElementById('modalHostTitle').textContent = 'Host Telemetry: ' + hostname;
document.getElementById('modalClientIdSub').textContent = 'Client ID: ' + clientId;
document.getElementById('modalJsonContent').textContent = jsonStr;
document.getElementById('copyJsonBtn').textContent = '📋 Copy JSON';
document.getElementById('telemetryModal').style.display = 'flex';
}}
function closeTelemetryModal(e) {{
document.getElementById('telemetryModal').style.display = 'none';
}}
function copyModalJson() {{
const text = document.getElementById('modalJsonContent').textContent;
navigator.clipboard.writeText(text).then(() => {{
const btn = document.getElementById('copyJsonBtn');
btn.textContent = '✅ Copied!';
setTimeout(() => {{ btn.textContent = '📋 Copy JSON'; }}, 2000);
}}).catch(() => {{
alert('Copying to clipboard failed.');
}});
}}
</script>
</body>
</html>"""