19 Commits
Author SHA1 Message Date
me0nline 4348e6ce9e fix(pipeline): prevent event loop blocking in local collector and increase CI pipeline timeouts
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m15s
2026-10-11 19:36:52 +02:00
me0nline d261802f8d chore(release): bump version to 0.3.6.0-rc1 and support prerelease metadata
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 4m56s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m40s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 11m54s
2026-10-11 16:37:18 +02:00
me0nline 39e0e6f219 Remove Best Practice Baseline KPI card and locked banner from Server Roles and client directory
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m21s
2026-10-11 16:29:31 +02:00
me0nline 62d4993e74 Remove repository mode, distribution source, storage engine, and license status entries from version info modal
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m8s
2026-10-11 11:10:44 +02:00
me0nline 52519e258e Relocate baseline config to administration subtabs, refine nav row, and add live CPU and RAM reporting
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m12s
2026-10-11 11:08:41 +02:00
me0nline 1bf3f0ed6f Collect Linux distribution and version telemetry across forwarders and web UI
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m7s
2026-10-11 10:48:05 +02:00
me0nline b44191ed89 feat(webui): unify dashboard into 4 primary tabs and administration subtabs row, deprecate separate client-info
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m24s
2026-10-11 10:33:14 +02:00
me0nline fc97964e95 feat(webui): consolidate client-info into main dashboard with primary and settings subtabs layout
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m5s
2026-10-11 10:24:44 +02:00
me0nline 6b281626da Add Version and System Info tab in admin dropdown menu
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m55s
2026-10-11 09:51:06 +02:00
me0nline 72d9646cc4 Separate Best Practice rules definition into dedicated best_practice_rules module
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m56s
2026-10-11 09:40:47 +02:00
me0nline cccf928f29 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
2026-10-11 09:37:05 +02:00
me0nline bcaae4a9ee fix(licensing): add /api/license/sync endpoint, support comm credentials, and wire auto_update capability toggle
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 3m52s
2026-10-04 01:42:48 +02:00
me0nline 5456d50774 feat(hub): add three image acquisition sources (official repo, custom repo, manual provided)
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m54s
2026-10-04 01:31:38 +02:00
me0nline 6b2b39b080 feat(hub): enforce license requirement for fleet auto-updates while keeping server upstream check free
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m4s
2026-10-04 01:27:55 +02:00
me0nline 285360b59f feat(ui): persist active tab in localStorage and update release info in-place without page reload
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m43s
2026-10-04 01:25:48 +02:00
me0nline 02bc65698b fix(ui): shorten repository status to Ready or Failed
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m50s
2026-10-04 01:23:34 +02:00
me0nline b069e1c1c0 feat(hub): add fleet distribution policy controls and unblock update checks
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m1s
2026-10-04 01:22:42 +02:00
me0nline 41563a8fb8 fix(packaging): prioritize standalone Linux_Client.bin binary over shell installer and add Hub auto-download fallback
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m46s
2026-10-04 01:18:58 +02:00
me0nline f0f6eba53b feat(hub): add auto-update switches for on/off & acquisition modes and remove manual download buttons from webui
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m21s
2026-10-04 01:15:20 +02:00
24 changed files with 3379 additions and 215 deletions
+34 -4
View File
@@ -31,16 +31,46 @@ echo "[+] Installing LOGAR Client..."
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs" mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
chmod 700 "${CONFIG_DIR}/certs" chmod 700 "${CONFIG_DIR}/certs"
if [ -f "dist/Linux_Client.bin" ]; then BIN_INSTALLED=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
if [ -f "${SCRIPT_DIR}/Linux_Client.bin" ]; then
cp "${SCRIPT_DIR}/Linux_Client.bin" "${INSTALL_DIR}/Linux_Client"
BIN_INSTALLED=1
elif [ -f "${SCRIPT_DIR}/Linux_Client" ]; then
cp "${SCRIPT_DIR}/Linux_Client" "${INSTALL_DIR}/Linux_Client"
BIN_INSTALLED=1
elif [ -f "dist/Linux_Client.bin" ]; then
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client" cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
BIN_INSTALLED=1
elif [ -f "dist/Linux_Client" ]; then elif [ -f "dist/Linux_Client" ]; then
cp dist/Linux_Client "${INSTALL_DIR}/Linux_Client" cp dist/Linux_Client "${INSTALL_DIR}/Linux_Client"
else BIN_INSTALLED=1
echo "[!] Warning: dist/Linux_Client binary not found in current directory. Continuing with existing binary if present." elif [ -f "Linux_Client.bin" ]; then
cp Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
BIN_INSTALLED=1
elif [ -f "Linux_Client" ]; then
cp Linux_Client "${INSTALL_DIR}/Linux_Client"
BIN_INSTALLED=1
fi fi
if [ -f "${INSTALL_DIR}/Linux_Client" ]; then # If binary is not available locally, fetch standalone binary from LOGAR Hub
if [ "${BIN_INSTALLED}" -eq 0 ] && [ ! -f "${INSTALL_DIR}/Linux_Client" ]; then
echo "[*] Client binary not found locally. Fetching standalone Linux client binary from LOGAR Hub (${HUB_URL}/api/client/update/download?os=linux)..."
if command -v curl >/dev/null 2>&1; then
curl -fsSL "${HUB_URL}/api/client/update/download?os=linux" -o "${INSTALL_DIR}/Linux_Client" || true
elif command -v wget >/dev/null 2>&1; then
wget -qO "${INSTALL_DIR}/Linux_Client" "${HUB_URL}/api/client/update/download?os=linux" || true
fi
fi
if [ -f "${INSTALL_DIR}/Linux_Client" ] && [ -s "${INSTALL_DIR}/Linux_Client" ]; then
chmod +x "${INSTALL_DIR}/Linux_Client" chmod +x "${INSTALL_DIR}/Linux_Client"
echo "[+] Standalone Linux Client executable installed at ${INSTALL_DIR}/Linux_Client"
else
echo "[-] Error: Failed to acquire LOGAR Linux client executable binary." >&2
echo "[-] Please provide Linux_Client.bin in the installer directory or verify HUB_URL connectivity." >&2
exit 1
fi fi
# Bootstrap certificate if missing and enrollment secret is provided # Bootstrap certificate if missing and enrollment secret is provided
+7 -3
View File
@@ -82,7 +82,7 @@ def upload_file_to_release(base_url, repo, release_id, token, file_path, max_ret
else: else:
return None return None
def create_or_get_release(base_url, repo, tag, token, title=None, notes=None): def create_or_get_release(base_url, repo, tag, token, title=None, notes=None, prerelease=None):
url = f"{base_url}/api/v1/repos/{repo}/releases" url = f"{base_url}/api/v1/repos/{repo}/releases"
headers = { headers = {
"Authorization": get_auth_header(token), "Authorization": get_auth_header(token),
@@ -90,6 +90,8 @@ def create_or_get_release(base_url, repo, tag, token, title=None, notes=None):
"Accept": "application/json" "Accept": "application/json"
} }
is_prerelease = prerelease if prerelease is not None else any(x in tag.lower() for x in ["-rc", "-pre", "-beta", "-alpha", "preview"])
# Check if release exists # Check if release exists
check_url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}" check_url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}"
check_req = urllib.request.Request(check_url, headers={"Authorization": get_auth_header(token)}) check_req = urllib.request.Request(check_url, headers={"Authorization": get_auth_header(token)})
@@ -97,13 +99,15 @@ def create_or_get_release(base_url, repo, tag, token, title=None, notes=None):
with urllib.request.urlopen(check_req) as resp: with urllib.request.urlopen(check_req) as resp:
existing = json.loads(resp.read().decode("utf-8")) existing = json.loads(resp.read().decode("utf-8"))
print(f"[*] Found existing release for tag {tag} (ID: {existing['id']})") print(f"[*] Found existing release for tag {tag} (ID: {existing['id']})")
if notes or title: if notes or title or (prerelease is not None):
patch_url = f"{base_url}/api/v1/repos/{repo}/releases/{existing['id']}" patch_url = f"{base_url}/api/v1/repos/{repo}/releases/{existing['id']}"
patch_payload = {} patch_payload = {}
if title: if title:
patch_payload["name"] = title patch_payload["name"] = title
if notes: if notes:
patch_payload["body"] = notes patch_payload["body"] = notes
if prerelease is not None:
patch_payload["prerelease"] = is_prerelease
patch_req = urllib.request.Request( patch_req = urllib.request.Request(
patch_url, patch_url,
data=json.dumps(patch_payload).encode("utf-8"), data=json.dumps(patch_payload).encode("utf-8"),
@@ -125,7 +129,7 @@ def create_or_get_release(base_url, repo, tag, token, title=None, notes=None):
"name": title or f"LOGAR Release {tag}", "name": title or f"LOGAR Release {tag}",
"body": notes or f"Automated binary release for {tag}.", "body": notes or f"Automated binary release for {tag}.",
"draft": False, "draft": False,
"prerelease": False "prerelease": is_prerelease
} }
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST") req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
try: try:
+13 -2
View File
@@ -88,14 +88,25 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li
return collector.collect_logs(hours=hours) return collector.collect_logs(hours=hours)
try:
from src.common.system_info import get_os_details
except ImportError:
try:
from common.system_info import get_os_details
except ImportError:
get_os_details = lambda: {"os_type": "windows"}
def send_encrypted_logs_over_socket(config: dict, logs: list): def send_encrypted_logs_over_socket(config: dict, logs: list):
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows") node_os = get_os_details().get("os_type", "windows")
return transport_send_encrypted_logs(config=config, logs=logs, os_type=node_os)
def ensure_enrolled(config: dict, machine_id: str) -> bool: def ensure_enrolled(config: dict, machine_id: str) -> bool:
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows") node_os = get_os_details().get("os_type", "windows")
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type=node_os)
def main(): def main():
+34 -3
View File
@@ -52,6 +52,18 @@ except ImportError:
except ImportError: except ImportError:
from client_updater import ClientUpdater from client_updater import ClientUpdater
try:
from src.common.system_info import get_full_system_inventory, get_os_details
except ImportError:
try:
from common.system_info import get_full_system_inventory, get_os_details
except ImportError:
try:
from system_info import get_full_system_inventory, get_os_details
except ImportError:
get_full_system_inventory = lambda: {}
get_os_details = lambda: {"os_type": "windows" if sys.platform == "win32" else "linux", "os_name": "", "is_server": False}
CONFIG_FILE_NAME = "client_config.json" CONFIG_FILE_NAME = "client_config.json"
@@ -96,10 +108,17 @@ 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,
"metrics": collect_system_metrics(),
"enrollment_secret": enrollment_secret, "enrollment_secret": enrollment_secret,
"version": LOGAR_VERSION "version": LOGAR_VERSION
} }
@@ -271,12 +290,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
@@ -309,12 +334,18 @@ def send_encrypted_logs_over_socket(
if expected_fp and actual_fp != expected_fp: if expected_fp and actual_fp != expected_fp:
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.") raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
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_json = json.dumps(payload) payload_json = json.dumps(payload)
pgp_msg = pgpy.PGPMessage.new(payload_json) pgp_msg = pgpy.PGPMessage.new(payload_json)
+1 -1
View File
@@ -144,7 +144,7 @@ class ClientUpdater:
# If running as a systemd service, restart service # If running as a systemd service, restart service
if shutil.which("systemctl"): if shutil.which("systemctl"):
try: try:
subprocess.run(["systemctl", "restart", "logar-client"], check=False) subprocess.run(["systemctl", "restart", "logar-client"], check=False, timeout=10)
print("[+] Signaled systemd to restart logar-client service.") print("[+] Signaled systemd to restart logar-client service.")
except Exception: except Exception:
pass pass
+44 -8
View File
@@ -11,6 +11,7 @@ import socket
import shutil import shutil
import ctypes import ctypes
import subprocess import subprocess
import time
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -106,7 +107,42 @@ def collect_system_metrics() -> Dict[str, Any]:
# 3. CPU metrics # 3. CPU metrics
try: try:
if hasattr(os, "getloadavg"): if sys.platform == "win32":
class FILETIME(ctypes.Structure):
_fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)]
idle, kernel, user = FILETIME(), FILETIME(), FILETIME()
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
i1 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
k1 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
u1 = (user.dwHighDateTime << 32) + user.dwLowDateTime
time.sleep(0.06)
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
i2 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
k2 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
u2 = (user.dwHighDateTime << 32) + user.dwLowDateTime
idle_delta = i2 - i1
total_delta = (k2 - k1) + (u2 - u1)
if total_delta > 0:
pct = 100.0 * (1.0 - (idle_delta / total_delta))
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
elif os.path.exists("/proc/stat"):
def _read_proc_stat():
with open("/proc/stat", "r") as f:
fields = [float(col) for col in f.readline().strip().split()[1:]]
idle = fields[3] + (fields[4] if len(fields) > 4 else 0.0)
total = sum(fields)
return idle, total
i1, t1 = _read_proc_stat()
time.sleep(0.06)
i2, t2 = _read_proc_stat()
total_delta = t2 - t1
idle_delta = i2 - i1
if total_delta > 0:
pct = 100.0 * (1.0 - (idle_delta / total_delta))
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
elif hasattr(os, "getloadavg"):
load1, _, _ = os.getloadavg() load1, _, _ = os.getloadavg()
cpu_count = os.cpu_count() or 1 cpu_count = os.cpu_count() or 1
metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1) metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1)
@@ -529,23 +565,23 @@ class LinuxLogCollector(BaseLogCollector):
last_timestamp_us = 0.0 last_timestamp_us = 0.0
sent_cursors = set(self.state.get("sent_cursors", [])) sent_cursors = set(self.state.get("sent_cursors", []))
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"] cmd = ["journalctl", "--no-pager", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
result = None result = None
if last_cursor: if last_cursor:
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"] cmd_with_cursor = ["journalctl", "--no-pager", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
try: try:
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False) res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False, timeout=8)
if res.returncode == 0: if res.returncode == 0:
result = res result = res
except Exception: except (subprocess.TimeoutExpired, Exception):
pass pass
if result is None: if result is None:
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False) result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=8)
except FileNotFoundError: except (subprocess.TimeoutExpired, FileNotFoundError):
# journalctl not present, attempt fallback to /var/log/syslog or messages # journalctl not present or timed out, attempt fallback to /var/log/syslog or messages
return self._collect_syslog_fallback(hours=hours) return self._collect_syslog_fallback(hours=hours)
except Exception as e: except Exception as e:
print(f"[!] Error running journalctl: {e}") print(f"[!] Error running journalctl: {e}")
+7
View File
@@ -1 +1,8 @@
# LOGAR Common Package # LOGAR Common Package
from src.common.system_info import (
get_os_details,
get_installed_server_roles,
get_full_system_inventory,
get_ip_addresses
)
from src.common.version import LOGAR_VERSION
+495
View File
@@ -0,0 +1,495 @@
"""
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 re
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 parse_os_release_text(content: str) -> Dict[str, str]:
"""Parses key=value lines from standard Linux /etc/os-release files."""
data = {}
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"\'')
data[k] = v
return data
def get_linux_distro_info(os_release_path: Optional[str] = None) -> Dict[str, str]:
"""
Accurately detects Linux distribution name, version, ID, and codename.
Uses platform.freedesktop_os_release() (Python 3.10+), followed by standard
/etc/os-release and /usr/lib/os-release parsing, with legacy release file fallbacks.
"""
res = {
"distro": "",
"distro_version": "",
"distro_id": "",
"distro_codename": "",
"pretty_name": ""
}
raw: Dict[str, str] = {}
if os_release_path and os.path.exists(os_release_path):
try:
with open(os_release_path, "r", encoding="utf-8", errors="replace") as f:
raw = parse_os_release_text(f.read())
except Exception:
pass
if not raw:
# 1. Try standard library platform.freedesktop_os_release() (Python 3.10+)
if hasattr(platform, "freedesktop_os_release"):
try:
raw = dict(platform.freedesktop_os_release())
except Exception:
pass
if not raw:
# 2. Check standard /etc/os-release and /usr/lib/os-release
for fpath in ("/etc/os-release", "/usr/lib/os-release"):
if os.path.exists(fpath):
try:
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
raw = parse_os_release_text(f.read())
if raw:
break
except Exception:
pass
if raw:
distro = raw.get("NAME") or raw.get("ID", "").capitalize() or "Linux"
version = raw.get("VERSION_ID") or raw.get("VERSION") or ""
pretty = raw.get("PRETTY_NAME") or f"{distro} {version}".strip()
distro_id = raw.get("ID", "").lower()
codename = raw.get("VERSION_CODENAME") or ""
if not codename and "(" in raw.get("VERSION", ""):
m = re.search(r"\(([^)]+)\)", raw.get("VERSION", ""))
if m:
codename = m.group(1).lower()
res["distro"] = distro
res["distro_version"] = version
res["distro_id"] = distro_id
res["distro_codename"] = codename
res["pretty_name"] = pretty
return res
# 3. Legacy file fallbacks for older or non-systemd Linux distributions
for rh_file in ("/etc/redhat-release", "/etc/centos-release", "/etc/system-release"):
if os.path.exists(rh_file):
try:
with open(rh_file, "r", encoding="utf-8", errors="replace") as f:
content = f.read().strip()
if content:
res["pretty_name"] = content
m = re.search(r"release\s+([\d\.]+)", content, re.IGNORECASE)
if m:
res["distro_version"] = m.group(1)
parts = content.split("release")[0].strip()
res["distro"] = parts or "Red Hat / CentOS"
res["distro_id"] = "rhel" if "red hat" in content.lower() else ("centos" if "centos" in content.lower() else "linux")
return res
except Exception:
pass
if os.path.exists("/etc/debian_version"):
try:
with open("/etc/debian_version", "r", encoding="utf-8", errors="replace") as f:
ver = f.read().strip()
res["distro"] = "Debian"
res["distro_version"] = ver
res["distro_id"] = "debian"
res["pretty_name"] = f"Debian {ver}".strip()
return res
except Exception:
pass
if os.path.exists("/etc/alpine-release"):
try:
with open("/etc/alpine-release", "r", encoding="utf-8", errors="replace") as f:
ver = f.read().strip()
res["distro"] = "Alpine Linux"
res["distro_version"] = ver
res["distro_id"] = "alpine"
res["pretty_name"] = f"Alpine Linux {ver}".strip()
return res
except Exception:
pass
if os.path.exists("/etc/arch-release"):
res["distro"] = "Arch Linux"
res["distro_version"] = "Rolling"
res["distro_id"] = "arch"
res["pretty_name"] = "Arch Linux"
return res
if os.path.exists("/etc/SuSE-release"):
try:
with open("/etc/SuSE-release", "r", encoding="utf-8", errors="replace") as f:
content = f.read()
m = re.search(r"VERSION\s*=\s*([\d\.]+)", content)
ver = m.group(1) if m else ""
res["distro"] = "openSUSE"
res["distro_version"] = ver
res["distro_id"] = "opensuse"
res["pretty_name"] = f"openSUSE {ver}".strip()
return res
except Exception:
pass
# Generic Linux fallback
kernel_ver = platform.release()
res["distro"] = "Linux"
res["distro_version"] = kernel_ver
res["distro_id"] = "linux"
res["pretty_name"] = f"Linux {kernel_ver}".strip()
return res
def get_os_details(os_release_path: Optional[str] = None) -> 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
"distro": str, # e.g. "Ubuntu", "Debian GNU/Linux", "Red Hat Enterprise Linux"
"distro_version": str,# e.g. "22.04", "12", "9.3"
"distro_id": str, # e.g. "ubuntu", "debian", "rhel", "alpine"
"distro_codename": str,# e.g. "jammy", "bookworm"
"edition": str, # e.g. "ServerDatacenter", "Professional", "Enterprise"
"build": str, # OS build number or kernel release
"architecture": str, # "AMD64", "x86_64", "ARM64"
"hostname": str,
"ip_addresses": List[str]
}
"""
is_server = False
os_type = "unknown"
os_name = platform.platform()
distro = ""
distro_version = ""
distro_id = ""
distro_codename = ""
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
lin_info = get_linux_distro_info(os_release_path=os_release_path)
distro = lin_info.get("distro", "Linux")
distro_version = lin_info.get("distro_version", "")
distro_id = lin_info.get("distro_id", "")
distro_codename = lin_info.get("distro_codename", "")
os_name = lin_info.get("pretty_name") or f"{distro} {distro_version}".strip() or "Linux"
edition = distro_codename or distro
build = platform.release()
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,
"distro": distro,
"distro_version": distro_version,
"distro_id": distro_id,
"distro_codename": distro_codename,
"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
mem_percent = 0.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))
mem_percent = float(stat.dwMemoryLoad)
elif os.path.exists("/proc/meminfo"):
tot = 0
avail = 0
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("MemTotal:"):
tot = int(line.split(":")[1].strip().split()[0])
elif line.startswith(("MemAvailable:", "MemFree:")) and avail == 0:
avail = int(line.split(":")[1].strip().split()[0])
if tot > 0:
mem_total_mb = tot // 1024
mem_percent = round(((tot - avail) / tot) * 100, 1)
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"),
"distro": os_info.get("distro", ""),
"distro_version": os_info.get("distro_version", ""),
"distro_id": os_info.get("distro_id", ""),
"distro_codename": os_info.get("distro_codename", ""),
"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,
"memory_percent": mem_percent,
"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
}
+1 -1
View File
@@ -8,7 +8,7 @@ import re
from typing import Tuple, Optional from typing import Tuple, Optional
LOGAR_VERSION = "0.3.5.2" LOGAR_VERSION = "0.3.6.0-rc1"
__version__ = LOGAR_VERSION __version__ = LOGAR_VERSION
+556 -50
View File
@@ -62,7 +62,8 @@ try:
WindowsLogCollector, WindowsLogCollector,
LinuxLogCollector, LinuxLogCollector,
get_log_collector, get_log_collector,
is_routine_service_noise is_routine_service_noise,
collect_system_metrics
) )
except ImportError: except ImportError:
from log_collector import ( from log_collector import (
@@ -70,7 +71,8 @@ except ImportError:
WindowsLogCollector, WindowsLogCollector,
LinuxLogCollector, LinuxLogCollector,
get_log_collector, get_log_collector,
is_routine_service_noise is_routine_service_noise,
collect_system_metrics
) )
# Import specialized modular components # Import specialized modular components
@@ -86,7 +88,8 @@ from src.server_models import (
DbSwitchBackendRequest, DbSwitchBackendRequest,
DbFullConfigRequest, DbFullConfigRequest,
ClientUpdateRequest, ClientUpdateRequest,
ServerUpdateCheckRequest ServerUpdateCheckRequest,
ServerUpdateConfigRequest
) )
from src.auth_manager import ( from src.auth_manager import (
SessionManager, SessionManager,
@@ -101,6 +104,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"
@@ -412,8 +431,10 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
print(f"[*] Loading server configuration from: {os.path.abspath(actual_config_path)}") print(f"[*] Loading server configuration from: {os.path.abspath(actual_config_path)}")
with open(actual_config_path, "r", encoding="utf-8") as f: with open(actual_config_path, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
config_changed = False
if "enrollment_secret" not in config: if "enrollment_secret" not in config:
config["enrollment_secret"] = secrets.token_hex(24) config["enrollment_secret"] = secrets.token_hex(24)
config_changed = True
if "max_seats" not in config: if "max_seats" not in config:
config["max_seats"] = 0 config["max_seats"] = 0
if "cert_dir" not in config: if "cert_dir" not in config:
@@ -450,9 +471,26 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
config["encrypt_at_rest"] = False config["encrypt_at_rest"] = False
if "auth_token" not in config: if "auth_token" not in config:
config["auth_token"] = secrets.token_hex(24) config["auth_token"] = secrets.token_hex(24)
if "enrollment_secret" not in config: config_changed = True
config["enrollment_secret"] = secrets.token_hex(24) if config_changed:
if "server_fingerprint" not in config and "public_key" in config: try:
with open(actual_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except Exception:
pass
if "auto_update_enabled" not in config:
config["auto_update_enabled"] = True
if "update_mode" not in config:
config["update_mode"] = "auto"
if "upstream_release_url" not in config:
config["upstream_release_url"] = UPDATE_MANAGER.DEFAULT_GITEA_RELEASE_URL
if "private_key" not in config or "public_key" not in config:
sname = config.get("server_name", "LOGAR Enterprise Hub")
pk, pubk, fp = generate_server_keypair(sname)
config["private_key"] = pk
config["public_key"] = pubk
config["server_fingerprint"] = fp
elif "server_fingerprint" not in config and "public_key" in config:
try: try:
pub, _ = pgpy.PGPKey.from_blob(config["public_key"]) pub, _ = pgpy.PGPKey.from_blob(config["public_key"])
config["server_fingerprint"] = str(pub.fingerprint) config["server_fingerprint"] = str(pub.fingerprint)
@@ -463,6 +501,11 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs")
config["config_file_path"] = os.path.abspath(actual_config_path) config["config_file_path"] = os.path.abspath(actual_config_path)
UPDATE_MANAGER.auto_update_enabled = config.get("auto_update_enabled", True)
UPDATE_MANAGER.update_mode = config.get("update_mode", "auto")
if config.get("upstream_release_url"):
UPDATE_MANAGER.upstream_url = config["upstream_release_url"]
global SESSION_SECRET global SESSION_SECRET
if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config:
SESSION_SECRET = config["session_secret"] SESSION_SECRET = config["session_secret"]
@@ -921,9 +964,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))
@@ -951,6 +1000,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()
@@ -979,25 +1046,61 @@ 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 = {}
try:
metrics = collect_system_metrics() if "collect_system_metrics" in globals() else {}
except Exception:
metrics = {}
cpu_p = float(metrics.get("cpu_percent", 0.0))
mem_p = float(metrics.get("memory_percent", 0.0))
disk_p = float(metrics.get("disk_percent_free", inv.get("disk_percent_free", 0.0)))
metrics_json = json.dumps(metrics) if metrics else "{}"
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:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, timeout=10.0)
c = conn.cursor() c = conn.cursor()
c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,))
if c.fetchone(): if c.fetchone():
c.execute(""" try:
UPDATE clients c.execute("""
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? UPDATE clients
WHERE client_id = ? SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?,
""", (machine_id, os_type, now_sql, machine_id)) os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?,
cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, metrics_json = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json,
cpu_p, mem_p, disk_p, metrics_json, machine_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, machine_id))
else: else:
c.execute(""" try:
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) c.execute("""
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json)
""", (machine_id, machine_id, os_type, now_sql, now_sql)) VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json,
cpu_p, mem_p, disk_p, metrics_json))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql))
conn.commit() conn.commit()
conn.close() conn.close()
except Exception as e: except Exception as e:
@@ -1034,8 +1137,9 @@ async def local_collector_loop(interval_seconds: int = 30):
except Exception: except Exception:
state = {} state = {}
loop = asyncio.get_running_loop()
try: try:
run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) await loop.run_in_executor(None, lambda: run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state))
with open(state_file, "w", encoding="utf-8") as f: with open(state_file, "w", encoding="utf-8") as f:
json.dump(state, f) json.dump(state, f)
except Exception as e: except Exception as e:
@@ -1044,7 +1148,7 @@ async def local_collector_loop(interval_seconds: int = 30):
while True: while True:
try: try:
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) await loop.run_in_executor(None, lambda: run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state))
with open(state_file, "w", encoding="utf-8") as f: with open(state_file, "w", encoding="utf-8") as f:
json.dump(state, f) json.dump(state, f)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -1058,11 +1162,12 @@ async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days:
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"]
loop = asyncio.get_running_loop()
while True: while True:
try: try:
await asyncio.sleep(interval_seconds) await asyncio.sleep(interval_seconds)
check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) await loop.run_in_executor(None, lambda: check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days))
report_seat_quota_to_authority_sync() await loop.run_in_executor(None, report_seat_quota_to_authority_sync)
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
@@ -1376,30 +1481,59 @@ 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 "{}"
req_metrics = getattr(req, "metrics", None) or {}
cpu_p = float(req_metrics.get("cpu_percent", 0.0))
mem_p = float(req_metrics.get("memory_percent", 0.0))
disk_p = float(req_metrics.get("disk_percent_free", 0.0))
metrics_json = json.dumps(req_metrics) if req_metrics else "{}"
if existing: if existing:
try: try:
c.execute(""" c.execute("""
UPDATE clients UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?,
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?,
cpu_percent = CASE WHEN ? > 0.0 THEN ? ELSE cpu_percent END,
memory_percent = CASE WHEN ? > 0.0 THEN ? ELSE memory_percent END,
disk_percent_free = CASE WHEN ? > 0.0 THEN ? ELSE disk_percent_free END,
metrics_json = CASE WHEN ? != '{}' THEN ? ELSE metrics_json END
WHERE client_id = ? WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, client_ver, req.client_id)) """, (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info,
cpu_p, cpu_p, mem_p, mem_p, disk_p, disk_p, metrics_json, metrics_json, req.client_id))
except sqlite3.OperationalError: except sqlite3.OperationalError:
c.execute(""" try:
UPDATE clients c.execute("""
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? UPDATE clients
WHERE client_id = ? SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?
""", (req.hostname, req.os, fp, now_sql, req.client_id)) WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, client_ver, req.client_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, req.client_id))
else: else:
try: try:
c.execute(""" c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, cpu_p, mem_p, disk_p, metrics_json))
except sqlite3.OperationalError: except sqlite3.OperationalError:
c.execute(""" try:
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) c.execute("""
VALUES (?, ?, ?, ?, 'active', ?, ?) INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, ?, 'active', ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1473,6 +1607,87 @@ def check_server_updates(user: str = Depends(get_current_user_optional)):
return mgr.check_upstream_updates(force=True) return mgr.check_upstream_updates(force=True)
@app.post("/api/update/sync-packages")
@app.post("/api/server/update/sync-packages")
def sync_upstream_packages_api(auth_user: str = Depends(require_admin_auth)):
"""
Downloads latest client binary packages from upstream release assets to local Hub storage.
"""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Fleet package acquisition and distribution requires an active license with the 'auto_update' capability."
)
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
results = mgr.sync_upstream_packages(force=True)
return {
"status": "success",
"message": "Upstream client packages synchronized and hosted successfully.",
"results": results,
"update_status": mgr.get_status()
}
@app.post("/api/update/config")
@app.post("/api/server/update/config")
def update_server_update_config(req: ServerUpdateConfigRequest, auth_user: str = Depends(require_admin_auth)):
"""
Updates fleet auto-update settings (on/off switch and automatic vs manual acquisition mode).
"""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Fleet auto-update settings configuration requires an active license with the 'auto_update' capability."
)
config = SERVER_STATE.get("config", {})
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
mode = (req.update_mode or "official").lower()
if mode not in ("official", "custom", "manual", "auto"):
raise HTTPException(
status_code=400,
detail="Invalid update_mode. Must be 'official' (Official production repo), 'custom' (Custom upstream repo), or 'manual' (Provided only)."
)
mgr.auto_update_enabled = bool(req.auto_update_enabled)
if mode == "auto":
mode = "official" if (not req.upstream_url or req.upstream_url == mgr.DEFAULT_GITEA_RELEASE_URL) else "custom"
mgr.update_mode = mode
if mode == "official":
mgr.upstream_url = mgr.DEFAULT_GITEA_RELEASE_URL
elif mode == "custom" and req.upstream_url:
mgr.upstream_url = req.upstream_url.strip()
config["auto_update_enabled"] = mgr.auto_update_enabled
config["update_mode"] = mgr.update_mode
config["upstream_release_url"] = mgr.upstream_url
cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME
try:
with open(cfg_file, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except Exception as e:
print(f"[!] Warning: Could not save config file: {e}")
# If enabled and in automatic fetch mode (official or custom), trigger upstream check
if mgr.auto_update_enabled and mgr.update_mode in ("official", "custom", "auto"):
try:
mgr.check_upstream_updates(force=False, auto_download_clients=True)
except Exception:
pass
return {
"status": "success",
"message": "Fleet update configuration saved successfully.",
"update_status": mgr.get_status()
}
@app.get("/api/client/update/check") @app.get("/api/client/update/check")
def check_client_update( def check_client_update(
client_id: str, client_id: str,
@@ -1484,12 +1699,10 @@ def check_client_update(
""" """
Invoked by edge forwarders to check whether a newer client binary package is available. Invoked by edge forwarders to check whether a newer client binary package is available.
""" """
import os as _os
target_os = os_name or os or "windows" target_os = os_name or os or "windows"
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
# Record check timestamp in database # Record check timestamp in database
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if target_os: if target_os:
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:
@@ -1506,11 +1719,12 @@ def check_client_update(
if not is_feature_licensed("auto_update", db_path): if not is_feature_licensed("auto_update", db_path):
return { return {
"update_available": False, "update_available": False,
"current_version": current_version or "0.0.0", "latest_version": LOGAR_VERSION,
"target_version": current_version or "0.0.0", "current_version": current_version,
"reason": "Fleet auto-update is not authorized by the server license." "reason": "Fleet auto-update is not authorized by the server license."
} }
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch)
@@ -1527,6 +1741,13 @@ def download_client_update_package(
import os as _os import os as _os
import email.utils import email.utils
target_os = os_name or os or "windows" target_os = os_name or os or "windows"
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not is_feature_licensed("auto_update", db_path):
raise HTTPException(
status_code=403,
detail="Client binary package fleet distribution requires an active license with the 'auto_update' capability."
)
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
pkg_tuple = mgr.get_client_package_path(os_type=target_os) pkg_tuple = mgr.get_client_package_path(os_type=target_os)
if not pkg_tuple: if not pkg_tuple:
@@ -2022,21 +2243,24 @@ def import_signed_license(req: LicenseImportRequest, request: Request = None, au
license_id = res.get("license_id") or lic_info.get("license_id") license_id = res.get("license_id") or lic_info.get("license_id")
expires_at = res.get("expires_at") or lic_info.get("not_valid_after") expires_at = res.get("expires_at") or lic_info.get("not_valid_after")
issued_at = res.get("issued_at") or lic_info.get("not_valid_before") issued_at = res.get("issued_at") or lic_info.get("not_valid_before")
features = res.get("features") or lic_info.get("features", []) features = res.get("features") if res.get("features") is not None else lic_info.get("features", [])
lic_authority_url = res.get("license_authority") or lic_info.get("issuer_url") or config.get("license_server_url")
if new_seats: if new_seats:
config["max_seats"] = new_seats config["max_seats"] = new_seats
if license_id: if license_id:
config["license_id"] = license_id config["license_id"] = license_id
if lic_authority_url:
config["license_server_url"] = lic_authority_url
db_path = config.get("db_path", DEFAULT_DB_FILE) db_path = config.get("db_path", DEFAULT_DB_FILE)
try: try:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, timeout=10.0)
conn.execute(""" conn.execute("""
UPDATE license_config UPDATE license_config
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? SET max_seats = ?, license_id = ?, license_server_url = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
WHERE id = 1 WHERE id = 1
""", (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) """, (new_seats or config.get("max_seats", 10), license_id or "", lic_authority_url or "", issued_at or "", expires_at or "", json.dumps(features)))
conn.commit() conn.commit()
conn.close() conn.close()
except Exception as e: except Exception as e:
@@ -2074,6 +2298,133 @@ def import_signed_license(req: LicenseImportRequest, request: Request = None, au
} }
@app.post("/api/license/sync")
def api_sync_license(auth_user: str = Depends(require_admin_auth)):
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
config = SERVER_STATE.get("config", {})
db_path = config.get("db_path", DEFAULT_DB_FILE)
ca_path = os.path.join(cert_dir, "ca.crt")
ca_info = {}
if os.path.exists(ca_path):
try:
with open(ca_path, "r", encoding="utf-8") as f:
ca_info = enrollment.extract_license_info_from_cert(f.read())
except Exception:
pass
lic_id = ca_info.get("license_id") or config.get("license_id")
lic_server = ca_info.get("issuer_url") or config.get("license_server_url", "wss://logar.eibl.tech/ws/license")
if not lic_id and os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
row = conn.execute("SELECT license_id, license_server_url FROM license_config WHERE id = 1").fetchone()
if row:
if row[0]:
lic_id = row[0]
if row[1]:
lic_server = row[1]
conn.close()
except Exception:
pass
if not lic_id or lic_id == "UNLICENSED":
raise HTTPException(status_code=400, detail="No active license registered to sync. Please import a license package (.lic) first.")
ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license"
active_seats = 0
if os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone()
if row:
active_seats = row[0]
conn.close()
except Exception:
pass
success, msg, res = enrollment.enroll_and_sync_license_ws(
cert_dir=cert_dir,
license_id=lic_id,
license_authority_ws_url=ws_url,
server_fingerprint=config.get("server_fingerprint", ""),
hostname=config.get("server_name", ""),
seats_taken=active_seats
)
if not success or not res:
http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "")
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
cert_dir=cert_dir,
license_server_url=http_url,
seats_taken=active_seats,
active_seats=active_seats,
hostname=config.get("server_name", ""),
server_fingerprint=config.get("server_fingerprint", "")
)
if not success or not new_pem:
raise HTTPException(status_code=502, detail=f"License sync failed: {msg}")
info = enrollment.extract_license_info_from_cert(new_pem)
new_seats = info.get("seats") or config.get("max_seats", 10)
lic_id = info.get("license_id") or lic_id
features = info.get("features", [])
expires_at = info.get("not_valid_after")
issued_at = info.get("not_valid_before")
else:
info = res.get("license_info", {})
new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10)
lic_id = res.get("license_id") or info.get("license_id") or lic_id
features = res.get("features") if res.get("features") is not None else info.get("features", [])
expires_at = res.get("expires_at") or info.get("not_valid_after")
issued_at = res.get("issued_at") or info.get("not_valid_before")
config["max_seats"] = new_seats
config["license_id"] = lic_id
try:
conn = sqlite3.connect(db_path)
conn.execute("""
UPDATE license_config
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ?
WHERE id = 1
""", (new_seats, lic_id or "", issued_at or "", expires_at or "", json.dumps(features)))
conn.commit()
conn.close()
except Exception as e:
print(f"[!] Warning: Failed to update license_config in DB: {e}")
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
SERVER_STATE["ca_cert"] = ca_cert
SERVER_STATE["ca_key"] = ca_key
SERVER_STATE["ca_cert_pem"] = ca_pem
enrollment.generate_server_cert_if_needed(
ca_cert, ca_key,
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
cert_dir=cert_dir,
force_renew=True
)
ssl_ctx = SERVER_STATE.get("ssl_ctx")
if ssl_ctx is not None:
try:
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
except Exception as e:
print(f"[!] Warning: Failed to reload in-flight SSLContext: {e}")
return {
"status": "success",
"message": f"License '{lic_id}' successfully synchronized!",
"license_id": lic_id,
"seats": new_seats,
"features": features,
"expires_at": expires_at
}
@app.get("/api/db/stats") @app.get("/api/db/stats")
def api_db_stats(auth_user: str = Depends(require_admin_auth)): def api_db_stats(auth_user: str = Depends(require_admin_auth)):
return { return {
@@ -2249,6 +2600,162 @@ 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):
"""Redirects to the integrated Server Roles directory tab within the main Web UI."""
if request is not None:
auth_user = get_current_user_optional(request)
if not auth_user:
return RedirectResponse(url="/login", status_code=303)
return RedirectResponse(url="/webui#server-roles", status_code=303)
@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 {
@@ -2272,11 +2779,10 @@ async def update_checker_loop(interval_hours: int = 1):
await asyncio.sleep(initial_delay) await asyncio.sleep(initial_delay)
while True: while True:
try: try:
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER
if is_feature_licensed("auto_update", db_path): loop = asyncio.get_running_loop()
mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER should_dl = bool(mgr.auto_update_enabled and mgr.update_mode in ("official", "custom", "auto"))
loop = asyncio.get_running_loop() await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=should_dl))
await loop.run_in_executor(None, lambda: mgr.check_upstream_updates(force=False, auto_download_clients=True))
except Exception as e: except Exception as e:
print(f"[!] Warning in update checker background loop: {e}") print(f"[!] Warning in update checker background loop: {e}")
await asyncio.sleep(interval_hours * 3600) await asyncio.sleep(interval_hours * 3600)
+196
View File
@@ -0,0 +1,196 @@
"""
LOGAR Best Practice & Security Baseline Rules Definition
Contains modular configuration checks, role-specific baselines,
and security hardening rules evaluated across enrolled server nodes.
Separated from the core evaluation engine to allow simple addition,
customization, and maintenance of compliance checks.
"""
import json
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
}
# ============================================================================
# Rule Evaluation Functions
# ============================================================================
def eval_disk_space(node: Dict[str, Any]) -> Dict[str, Any]:
"""BP-SYS-001: Verifies primary volume retains healthy free capacity."""
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."}
def eval_memory_load(node: Dict[str, Any]) -> Dict[str, Any]:
"""BP-SYS-002: Verifies RAM load remains safely below critical paging saturation."""
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."}
def eval_dc_co_location(node: Dict[str, Any]) -> Dict[str, Any]:
"""BP-AD-001: Verifies Active Directory Domain Controllers do not co-locate high-risk services."""
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."}
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."}
def eval_server_classification(node: Dict[str, Any]) -> Dict[str, Any]:
"""BP-WIN-002: Verifies operating system classification and role inventory alignment."""
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 or 'Windows Client'}) correctly classified without server role footprint."}
return {"status": "PASS", "message": f"Host classification verified: {os_name or os_type}."}
def eval_agent_version(node: Dict[str, Any]) -> Dict[str, Any]:
"""BP-SEC-003: Ensures edge forwarder binary version is actively patched."""
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."}
# ============================================================================
# Baseline Rule Definitions
# ============================================================================
BASELINE_RULES: List[BestPracticeRule] = [
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
),
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
),
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
),
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
),
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 get_baseline_rules() -> List[BestPracticeRule]:
"""Returns a list of all pre-configured Best Practice rules."""
return list(BASELINE_RULES)
+125
View File
@@ -0,0 +1,125 @@
"""
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.
Rule definitions are maintained separately in best_practice_rules.py.
"""
import json
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Callable
try:
from src.hub.best_practice_rules import BestPracticeRule, get_baseline_rules
except ImportError:
try:
from best_practice_rules import BestPracticeRule, get_baseline_rules
except ImportError:
from .best_practice_rules import BestPracticeRule, get_baseline_rules
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 unregister_rule(self, rule_id: str):
"""Removes a rule by its identifier."""
self._rules.pop(rule_id, None)
def get_rules(self) -> List[Dict[str, Any]]:
return [r.to_dict() for r in self._rules.values()]
def _load_baseline_rules(self):
"""Loads baseline checks defined in the separate best_practice_rules.py module."""
for rule in get_baseline_rules():
self.register_rule(rule)
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()
+32 -8
View File
@@ -47,7 +47,8 @@ def generate_ca_if_needed(
cert_dir: str = "certs", cert_dir: str = "certs",
common_name: str = "LOGAR-Root-CA", common_name: str = "LOGAR-Root-CA",
force_renew: bool = False, force_renew: bool = False,
threshold_days: int = 30 threshold_days: int = 30,
key_size: int = 4096
) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]: ) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]:
""" """
Loads an existing Root CA or generates a self-signed Root CA certificate and private key. Loads an existing Root CA or generates a self-signed Root CA certificate and private key.
@@ -81,8 +82,8 @@ def generate_ca_if_needed(
except Exception: except Exception:
pass pass
# Generate RSA 4096 private key for Root CA # Generate RSA private key for Root CA
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) ca_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
subject = issuer = x509.Name([ subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"),
@@ -785,6 +786,15 @@ def enroll_and_sync_license_ws(
) )
csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
if not comm_cert_pem:
comm_crt_path = os.path.join(cert_dir, "comm.crt")
if os.path.exists(comm_crt_path):
try:
with open(comm_crt_path, "r", encoding="utf-8") as f:
comm_cert_pem = f.read()
except Exception:
pass
# 3. Connect via WebSocket # 3. Connect via WebSocket
try: try:
from websockets.sync.client import connect as ws_connect from websockets.sync.client import connect as ws_connect
@@ -797,12 +807,18 @@ def enroll_and_sync_license_ws(
if not verify_tls: if not verify_tls:
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
elif master_root_ca_pem:
ssl_context.load_verify_locations(cadata=master_root_ca_pem)
else: else:
if master_root_ca_pem:
try:
ssl_context.load_verify_locations(cadata=master_root_ca_pem)
except Exception:
pass
upstream_path = os.path.join(cert_dir, "upstream_ca.crt") upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_path): if os.path.exists(upstream_path):
ssl_context.load_verify_locations(cafile=upstream_path) try:
ssl_context.load_verify_locations(cafile=upstream_path)
except Exception:
pass
try: try:
with ws_connect( with ws_connect(
@@ -849,7 +865,7 @@ def enroll_and_sync_license_ws(
f.write(upstream_ca) f.write(upstream_ca)
lic_info = extract_license_info_from_cert(new_ca_pem) lic_info = extract_license_info_from_cert(new_ca_pem)
features = reply.get("features") or lic_info.get("features", []) features = reply.get("features") if reply.get("features") is not None else lic_info.get("features", [])
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
"license_id": license_id, "license_id": license_id,
"seats": reply.get("seats") or lic_info.get("seats"), "seats": reply.get("seats") or lic_info.get("seats"),
@@ -858,7 +874,8 @@ def enroll_and_sync_license_ws(
"license_info": lic_info, "license_info": lic_info,
"features": features, "features": features,
"expires_at": lic_info.get("not_valid_after"), "expires_at": lic_info.get("not_valid_after"),
"issued_at": lic_info.get("not_valid_before") "issued_at": lic_info.get("not_valid_before"),
"license_authority": license_authority_ws_url
} }
except Exception as e: except Exception as e:
@@ -923,11 +940,18 @@ def import_license_package(
lic_id = data.get("license_id") lic_id = data.get("license_id")
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
comm_cert = data.get("communication_cert") comm_cert = data.get("communication_cert")
comm_key = data.get("communication_key")
master_ca = data.get("master_root_ca") or data.get("upstream_ca") master_ca = data.get("master_root_ca") or data.get("upstream_ca")
if master_ca: if master_ca:
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
f.write(master_ca) f.write(master_ca)
if comm_cert:
with open(os.path.join(cert_dir, "comm.crt"), "w", encoding="utf-8") as f:
f.write(comm_cert)
if comm_key:
with open(os.path.join(cert_dir, "comm.key"), "w", encoding="utf-8") as f:
f.write(comm_key)
ok, msg, res = enroll_and_sync_license_ws( ok, msg, res = enroll_and_sync_license_ws(
cert_dir=cert_dir, cert_dir=cert_dir,
+11
View File
@@ -13,6 +13,11 @@ 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
metrics: Optional[Dict[str, Any]] = None
class ClientUpdateRequest(BaseModel): class ClientUpdateRequest(BaseModel):
@@ -26,6 +31,12 @@ class ServerUpdateCheckRequest(BaseModel):
force: bool = False force: bool = False
class ServerUpdateConfigRequest(BaseModel):
auto_update_enabled: bool = True
update_mode: str = "official" # "official" | "custom" | "manual"
upstream_url: Optional[str] = None
class ClientActionRequest(BaseModel): class ClientActionRequest(BaseModel):
client_id: str client_id: str
+10 -2
View File
@@ -233,7 +233,11 @@ class StorageEngine:
("disk_percent_free", "REAL DEFAULT 0.0"), ("disk_percent_free", "REAL DEFAULT 0.0"),
("client_version", "TEXT DEFAULT ''"), ("client_version", "TEXT DEFAULT ''"),
("update_status", "TEXT DEFAULT 'up_to_date'"), ("update_status", "TEXT DEFAULT 'up_to_date'"),
("last_update_check", "TEXT") ("last_update_check", "TEXT"),
("os_name", "TEXT DEFAULT ''"),
("is_server", "INTEGER DEFAULT 0"),
("server_roles", "TEXT DEFAULT '[]'"),
("client_info_json", "TEXT DEFAULT '{}'")
]) ])
# 5. Users Table # 5. Users Table
@@ -429,7 +433,11 @@ class StorageEngine:
("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"), ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0"),
("client_version", "VARCHAR(50) DEFAULT ''"), ("client_version", "VARCHAR(50) DEFAULT ''"),
("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"), ("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"),
("last_update_check", "VARCHAR(50)") ("last_update_check", "VARCHAR(50)"),
("os_name", "VARCHAR(255) DEFAULT ''"),
("is_server", "INTEGER DEFAULT 0"),
("server_roles", "TEXT DEFAULT '[]'"),
("client_info_json", "TEXT DEFAULT '{}'")
]: ]:
try: try:
cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}")
+53 -14
View File
@@ -26,12 +26,21 @@ class ServerUpdateManager:
self, self,
update_dir: Optional[str] = None, update_dir: Optional[str] = None,
upstream_url: Optional[str] = None, upstream_url: Optional[str] = None,
check_enabled: bool = True check_enabled: bool = True,
auto_update_enabled: bool = True,
update_mode: str = "auto"
): ):
self.update_dir = update_dir or os.path.join(os.getcwd(), "updates") self.update_dir = update_dir or os.path.join(os.getcwd(), "updates")
os.makedirs(self.update_dir, exist_ok=True)
self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL
self.check_enabled = check_enabled self.check_enabled = check_enabled
self.auto_update_enabled = auto_update_enabled
raw_mode = (update_mode or "official").lower()
if raw_mode in ("official", "custom", "manual"):
self.update_mode = raw_mode
elif raw_mode == "auto":
self.update_mode = "official" if (not upstream_url or upstream_url == self.DEFAULT_GITEA_RELEASE_URL) else "custom"
else:
self.update_mode = "official"
# Cached upstream release state # Cached upstream release state
self.last_checked_utc: Optional[str] = None self.last_checked_utc: Optional[str] = None
@@ -195,8 +204,10 @@ class ServerUpdateManager:
os_dir = os.path.join(self.update_dir, os_name) os_dir = os.path.join(self.update_dir, os_name)
found_pkg = None found_pkg = None
if os.path.exists(os_dir): if os.path.exists(os_dir):
for f in os.listdir(os_dir): # Search by priority: .bin/.exe first, then archives, never .sh
if f.endswith((".exe", ".bin", ".zip", ".tar.gz", ".sh")) and not f.startswith("."): valid_exts = (".exe", ".msi", ".zip") if os_name == "windows" else (".bin", ".tar.gz", ".tgz")
for f in sorted(os.listdir(os_dir), key=lambda x: (not x.endswith((".exe", ".bin")), x)):
if f.endswith(valid_exts) and not f.startswith(".") and not f.endswith(".sh"):
found_pkg = os.path.join(os_dir, f) found_pkg = os.path.join(os_dir, f)
break break
@@ -205,9 +216,9 @@ class ServerUpdateManager:
dist_dir = os.path.join(os.path.dirname(self.update_dir), "dist") dist_dir = os.path.join(os.path.dirname(self.update_dir), "dist")
if os.path.exists(dist_dir): if os.path.exists(dist_dir):
patterns = [ patterns = [
"LOGAR_Client_Setup.exe", "LOGAR_Client.exe", "Win_Client.exe" "LOGAR_Client_Setup.exe", "LOGAR-Client-Setup.exe", "Win_Client.exe", "LOGAR_Client.exe", "LOGAR-Windows-Client.zip"
] if os_name == "windows" else [ ] if os_name == "windows" else [
"Linux_Client.bin", "LOGAR_Client_Linux", "install_linux_client.sh" "Linux_Client.bin", "Linux_Client", "LOGAR_Client_Linux", "LOGAR-Linux-Client.tar.gz"
] ]
for pat in patterns: for pat in patterns:
candidate = os.path.join(dist_dir, pat) candidate = os.path.join(dist_dir, pat)
@@ -275,7 +286,17 @@ class ServerUpdateManager:
has_pkg = bool(pkg.get("exists")) has_pkg = bool(pkg.get("exists"))
client_ver = (current_version or "0.0.0.0").strip().lstrip("v") client_ver = (current_version or "0.0.0.0").strip().lstrip("v")
# An update is available if the hosted version is strictly newer than the client version # An update is available if auto_update is enabled, package exists, and hosted version is strictly newer
if not self.auto_update_enabled:
return {
"update_available": False,
"client_id": client_id,
"os": norm_os,
"current_version": client_ver,
"latest_version": target_version,
"reason": "Fleet auto-update is disabled on this server."
}
update_available = has_pkg and is_newer_version(target_version, client_ver) update_available = has_pkg and is_newer_version(target_version, client_ver)
return { return {
@@ -315,13 +336,17 @@ class ServerUpdateManager:
for asset in release_assets: for asset in release_assets:
name = (asset.get("name") or "").lower() name = (asset.get("name") or "").lower()
download_url = asset.get("download_url") download_url = asset.get("download_url")
if not download_url: if not download_url or name.endswith(".sh") or "server" in name or "checksum" in name or "sha256" in name:
continue continue
target_os = None target_os = None
if ("win" in name or name.endswith(".exe") or "setup" in name) and not results["windows"]: if ("win" in name or "setup" in name or name.endswith((".exe", ".msi", ".zip"))) and "client" in name and not results["windows"]:
target_os = "windows" target_os = "windows"
elif ("linux" in name or name.endswith((".bin", ".sh")) or "client_linux" in name) and not results["linux"]: elif ("win" in name or name.endswith(".exe")) and not results["windows"]:
target_os = "windows"
elif ("linux" in name or name.endswith((".bin", ".tar.gz", ".tgz"))) and ("client" in name or "agent" in name) and not results["linux"]:
target_os = "linux"
elif name in ("linux_client.bin", "linux_client", "logar_client_linux") and not results["linux"]:
target_os = "linux" target_os = "linux"
if not target_os: if not target_os:
@@ -377,10 +402,21 @@ class ServerUpdateManager:
return results return results
def sync_upstream_packages(self, force: bool = True, timeout: int = 60) -> Dict[str, Any]:
"""
Manually or programmatically triggers an immediate upstream release check and downloads
the latest client binary packages to the Hub repository.
"""
self.check_upstream_updates(force=force, auto_download_clients=True, timeout=timeout)
if self.release_assets:
return self.download_and_cache_client_packages(assets=self.release_assets, timeout=timeout)
return {"windows": False, "linux": False, "downloaded": [], "error": self.last_error or "No release assets found."}
def check_upstream_updates(self, force: bool = False, auto_download_clients: bool = True, timeout: int = 15) -> Dict[str, Any]: def check_upstream_updates(self, force: bool = False, auto_download_clients: bool = True, timeout: int = 15) -> Dict[str, Any]:
""" """
Queries the upstream repository release API (Gitea / GitHub / JSON manifest) Queries the upstream repository release API (Gitea / GitHub / JSON manifest)
to check if a newer server or client release is available, and automatically caches client packages. to check if a newer server or client release is available, and automatically caches client packages
when in automatic acquisition mode.
""" """
if not self.check_enabled and not force: if not self.check_enabled and not force:
return self.get_status() return self.get_status()
@@ -423,9 +459,9 @@ class ServerUpdateManager:
if self.server_update_available: if self.server_update_available:
print(f"[!] 🔔 LOGAR Server Update Available: Current v{LOGAR_VERSION} -> Upstream v{upstream_ver}") print(f"[!] 🔔 LOGAR Server Update Available: Current v{LOGAR_VERSION} -> Upstream v{upstream_ver}")
# Automatically download and cache client update packages on the Hub # Automatically download and cache client update packages on the Hub if in official or custom repo mode
if auto_download_clients and assets: should_auto_download = auto_download_clients and (self.update_mode in ("official", "custom", "auto") or force)
# Check if hosted packages need updating if should_auto_download and assets:
hosted = self.get_hosted_packages() hosted = self.get_hosted_packages()
needs_win = not hosted.get("windows", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("windows", {}).get("version", "0.0.0")) needs_win = not hosted.get("windows", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("windows", {}).get("version", "0.0.0"))
needs_linux = not hosted.get("linux", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("linux", {}).get("version", "0.0.0")) needs_linux = not hosted.get("linux", {}).get("exists") or is_newer_version(upstream_ver, hosted.get("linux", {}).get("version", "0.0.0"))
@@ -452,5 +488,8 @@ class ServerUpdateManager:
"last_checked_utc": self.last_checked_utc, "last_checked_utc": self.last_checked_utc,
"last_error": self.last_error, "last_error": self.last_error,
"check_enabled": self.check_enabled, "check_enabled": self.check_enabled,
"auto_update_enabled": self.auto_update_enabled,
"update_mode": self.update_mode,
"upstream_url": self.upstream_url,
"hosted_packages": self.get_hosted_packages() "hosted_packages": self.get_hosted_packages()
} }
+1226 -81
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -190,8 +190,10 @@ class TestHubComprehensive(unittest.TestCase):
) )
self.assertIn("LOGAR Fleet Hub", html) self.assertIn("LOGAR Fleet Hub", html)
self.assertIn("tabPane-fleet", html) self.assertIn("tabPane-fleet", html)
self.assertIn("tabPane-server-roles", html)
self.assertIn("tabPane-telemetry", html)
self.assertIn("tabPane-events", html)
self.assertIn("tabPane-hermes", html) self.assertIn("tabPane-hermes", html)
self.assertIn("tabPane-anomalies", html)
self.assertIn("tabPane-pki", html) self.assertIn("tabPane-pki", html)
self.assertIn("tabPane-db", html) self.assertIn("tabPane-db", html)
self.assertIn("tabPane-updates", html) self.assertIn("tabPane-updates", html)
+2 -1
View File
@@ -323,10 +323,11 @@ class TestServerLicensingAndWebUI(unittest.TestCase):
conn.commit() conn.commit()
conn.close() conn.close()
# WebUI should render locked banner and disabled upload controls # WebUI should render locked banner and disabled update controls
resp = Server.serve_webui() resp = Server.serve_webui()
html = resp.body.decode("utf-8") html = resp.body.decode("utf-8")
self.assertIn("Fleet Auto-Update is Inactive &amp; Locked", html) self.assertIn("Fleet Auto-Update is Inactive &amp; Locked", html)
self.assertIn("ENTERPRISE LICENSE REQUIRED", html)
self.assertIn("disabled", html) self.assertIn("disabled", html)
def test_json_lic_package_import(self): def test_json_lic_package_import(self):
+11 -10
View File
@@ -28,6 +28,7 @@ TCP_HOST = "127.0.0.1"
TCP_PORT = 9443 TCP_PORT = 9443
HERMES_HOST = "127.0.0.1" HERMES_HOST = "127.0.0.1"
HERMES_PORT = 8443 HERMES_PORT = 8443
HTTP_TIMEOUT = 30
def run_tests(): def run_tests():
@@ -69,7 +70,7 @@ def run_tests():
# 1. Check current license status # 1. Check current license status
req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status") req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
with urllib.request.urlopen(req_status_init, timeout=15) as resp: with urllib.request.urlopen(req_status_init, timeout=HTTP_TIMEOUT) as resp:
init_lic_stat = json.loads(resp.read().decode("utf-8")) init_lic_stat = json.loads(resp.read().decode("utf-8"))
if init_lic_stat.get("status") == "UNLICENSED": if init_lic_stat.get("status") == "UNLICENSED":
@@ -85,7 +86,7 @@ def run_tests():
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
try: try:
with urllib.request.urlopen(req_unlicensed, timeout=15): with urllib.request.urlopen(req_unlicensed, timeout=HTTP_TIMEOUT):
assert False, "Expected HTTP 403 on unlicensed server enrollment" assert False, "Expected HTTP 403 on unlicensed server enrollment"
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}" assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}"
@@ -96,7 +97,7 @@ def run_tests():
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr", f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr",
headers={"Authorization": f"Bearer {auth_token}"} headers={"Authorization": f"Bearer {auth_token}"}
) )
with urllib.request.urlopen(req_csr, timeout=15) as resp: with urllib.request.urlopen(req_csr, timeout=HTTP_TIMEOUT) as resp:
assert resp.status == 200 assert resp.status == 200
csr_data = json.loads(resp.read().decode("utf-8")) csr_data = json.loads(resp.read().decode("utf-8"))
csr_pem = csr_data["csr"] csr_pem = csr_data["csr"]
@@ -104,7 +105,7 @@ def run_tests():
# 3. Generate test master CA and sign subordinate CA with 10 seats # 3. Generate test master CA and sign subordinate CA with 10 seats
master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca") master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca")
master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA") master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA", key_size=2048)
sub_pem = server_enrollment.sign_ca_csr_with_seats( sub_pem = server_enrollment.sign_ca_csr_with_seats(
csr_pem=csr_pem, csr_pem=csr_pem,
master_ca_cert_pem=master_pem, master_ca_cert_pem=master_pem,
@@ -121,7 +122,7 @@ def run_tests():
data=json.dumps(import_payload).encode("utf-8"), data=json.dumps(import_payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"} headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"}
) )
with urllib.request.urlopen(req_import, timeout=15) as resp: with urllib.request.urlopen(req_import, timeout=HTTP_TIMEOUT) as resp:
assert resp.status == 200 assert resp.status == 200
import_res = json.loads(resp.read().decode("utf-8")) import_res = json.loads(resp.read().decode("utf-8"))
assert import_res["status"] == "success" assert import_res["status"] == "success"
@@ -130,7 +131,7 @@ def run_tests():
# 5. Verify license status # 5. Verify license status
req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status") req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
with urllib.request.urlopen(req_status, timeout=15) as resp: with urllib.request.urlopen(req_status, timeout=HTTP_TIMEOUT) as resp:
assert resp.status == 200 assert resp.status == 200
lic_stat = json.loads(resp.read().decode("utf-8")) lic_stat = json.loads(resp.read().decode("utf-8"))
assert lic_stat["status"] == "ACTIVE" assert lic_stat["status"] == "ACTIVE"
@@ -153,7 +154,7 @@ def run_tests():
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
try: try:
with urllib.request.urlopen(req_bad, timeout=15): with urllib.request.urlopen(req_bad, timeout=HTTP_TIMEOUT):
assert False, "Expected HTTP 403 on invalid secret" assert False, "Expected HTTP 403 on invalid secret"
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
assert e.code == 403, f"Expected HTTP 403, got {e.code}" assert e.code == 403, f"Expected HTTP 403, got {e.code}"
@@ -171,7 +172,7 @@ def run_tests():
data=json.dumps(valid_enroll_payload).encode("utf-8"), data=json.dumps(valid_enroll_payload).encode("utf-8"),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
with urllib.request.urlopen(req_valid, timeout=15) as resp: with urllib.request.urlopen(req_valid, timeout=HTTP_TIMEOUT) as resp:
assert resp.status == 200, f"Expected 200, got {resp.status}" assert resp.status == 200, f"Expected 200, got {resp.status}"
enroll_data = json.loads(resp.read().decode("utf-8")) enroll_data = json.loads(resp.read().decode("utf-8"))
assert "ca_cert" in enroll_data assert "ca_cert" in enroll_data
@@ -194,7 +195,7 @@ def run_tests():
auth_tok = server_conf.get("auth_token") or client_conf.get("auth_token") auth_tok = server_conf.get("auth_token") or client_conf.get("auth_token")
client_headers = {"Authorization": f"Bearer {auth_tok}"} if auth_tok else {} client_headers = {"Authorization": f"Bearer {auth_tok}"} if auth_tok else {}
clients_req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/clients", headers=client_headers) clients_req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/clients", headers=client_headers)
with urllib.request.urlopen(clients_req, timeout=15) as resp: with urllib.request.urlopen(clients_req, timeout=HTTP_TIMEOUT) as resp:
clients_data = json.loads(resp.read().decode("utf-8")) clients_data = json.loads(resp.read().decode("utf-8"))
assert clients_data["active_seats"] >= 1 assert clients_data["active_seats"] >= 1
found_c = any(c["client_id"] == client_id for c in clients_data["clients"]) found_c = any(c["client_id"] == client_id for c in clients_data["clients"])
@@ -294,7 +295,7 @@ def run_tests():
token = server_conf.get("auth_token") or client_conf.get("auth_token") token = server_conf.get("auth_token") or client_conf.get("auth_token")
headers = {"Authorization": f"Bearer {token}"} if token else {} headers = {"Authorization": f"Bearer {token}"} if token else {}
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report", headers=headers) req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report", headers=headers)
with urllib.request.urlopen(req, timeout=15) as response: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as response:
assert response.status == 200, f"Expected 200, got {response.status}" assert response.status == 200, f"Expected 200, got {response.status}"
hermes_data = json.loads(response.read().decode("utf-8")) hermes_data = json.loads(response.read().decode("utf-8"))
+8 -3
View File
@@ -36,6 +36,8 @@ class TestServerComponent(unittest.TestCase):
os.remove(self.test_config) os.remove(self.test_config)
except Exception: except Exception:
pass pass
if "config" in Server.SERVER_STATE and isinstance(Server.SERVER_STATE["config"], dict):
Server.SERVER_STATE["config"]["db_path"] = Server.DEFAULT_DB_FILE
def test_first_run_config_and_keypair_generation(self): def test_first_run_config_and_keypair_generation(self):
config = Server.load_or_init_config(self.test_config) config = Server.load_or_init_config(self.test_config)
@@ -823,10 +825,13 @@ class TestServerComponent(unittest.TestCase):
# Verify clean tab buttons without number suffixes # Verify clean tab buttons without number suffixes
self.assertIn('>💻 Fleet Forwarders</button>', html_content) self.assertIn('>💻 Fleet Forwarders</button>', html_content)
self.assertIn('>🤖 Hermes AI & Tokens</button>', html_content) self.assertIn('>🖥️ Server Roles &amp; Client Directory</button>', html_content)
self.assertIn('>📊 Verified Telemetry</button>', html_content) self.assertIn('>📊 Events</button>', html_content)
self.assertIn('>📜 Sub-CA & Licensing</button>', html_content) self.assertIn('>⚙️ Administration', html_content)
self.assertIn('>🤖 Hermes AI &amp; Tokens</button>', html_content)
self.assertIn('>📜 Sub-CA &amp; Licensing</button>', html_content)
self.assertIn('>💾 DB Engine</button>', html_content) self.assertIn('>💾 DB Engine</button>', html_content)
self.assertIn('>📦 Updates &amp; Distribution', html_content)
self.assertNotIn('🟢 UNLOCKED', html_content) self.assertNotIn('🟢 UNLOCKED', html_content)
self.assertNotIn('Fleet Forwarders (', html_content) self.assertNotIn('Fleet Forwarders (', html_content)
@@ -0,0 +1,444 @@
"""
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,
get_linux_distro_info,
parse_os_release_text
)
from src.hub.best_practices import BestPracticeEngine, BestPracticeRule, BEST_PRACTICES_ENGINE
from src.hub.best_practice_rules import BASELINE_RULES, get_baseline_rules
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
if not os.path.exists(self.db_path):
self.db_path = DEFAULT_DB_FILE
SERVER_STATE.setdefault("config", {})["db_path"] = self.db_path
from src.hub.Server import init_db
init_db(self.db_path)
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_rules_defined_in_separate_file(self):
"""Verifies Best Practice rules are defined in separate best_practice_rules module."""
rules = get_baseline_rules()
self.assertGreaterEqual(len(rules), 5)
for r in rules:
self.assertIsInstance(r, BestPracticeRule)
self.assertTrue(r.rule_id.startswith("BP-"))
self.assertTrue(r.title)
self.assertTrue(r.description)
self.assertTrue(r.recommendation)
self.assertIn(r.severity, ["CRITICAL", "WARNING", "RECOMMENDED"])
self.assertIsNotNone(r.eval_fn)
# Engine automatically registers all rules from best_practice_rules module
engine = BestPracticeEngine()
loaded_ids = [r["rule_id"] for r in engine.get_rules()]
self.assertIn("BP-SYS-001", loaded_ids)
self.assertIn("BP-SYS-002", loaded_ids)
self.assertIn("BP-AD-001", loaded_ids)
self.assertIn("BP-WIN-002", loaded_ids)
self.assertIn("BP-SEC-003", loaded_ids)
def test_client_info_html_rendering(self):
"""Verifies GET /client-info redirects to /login when unauthenticated, and redirects to /webui#server-roles 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 redirects to /webui#server-roles
auth_client = TestClient(app)
auth_client.cookies.set(SessionManager.SESSION_COOKIE_NAME, self.session_token)
res1 = auth_client.get("/client-info", follow_redirects=False)
self.assertEqual(res1.status_code, 303)
self.assertEqual(res1.headers.get("location"), "/webui#server-roles")
res2 = auth_client.get("/webui/client-info", follow_redirects=False)
self.assertEqual(res2.status_code, 303)
self.assertEqual(res2.headers.get("location"), "/webui#server-roles")
# Following redirect leads to main dashboard with Server Roles tab pane
res_follow = auth_client.get("/client-info", follow_redirects=True)
self.assertEqual(res_follow.status_code, 200)
self.assertIn("id=\"tabPane-server-roles\"", res_follow.text)
self.assertIn("Node Telemetry &amp; Server Roles Inventory", res_follow.text)
def test_dashboard_tabs_structure(self):
"""Verifies primary tabs (Fleet Forwarders, Server Roles & Client Directory, Events, Administration) and secondary admin subtabs (Hermes, Sub-CA, DB, Updates)."""
res = self.client.get("/webui", cookies={SessionManager.SESSION_COOKIE_NAME: self.session_token})
self.assertEqual(res.status_code, 200)
html = res.text
# Primary navigation tabs (only 4 tabs on top row)
self.assertIn("id=\"mainTabsNav\"", html)
self.assertIn("id=\"tabBtn-fleet\"", html)
self.assertIn(">💻 Fleet Forwarders</button>", html)
self.assertIn("id=\"tabBtn-server-roles\"", html)
self.assertIn(">🖥️ Server Roles &amp; Client Directory</button>", html)
self.assertIn("id=\"tabBtn-events\"", html)
self.assertIn(">📊 Events</button>", html)
self.assertIn("id=\"tabBtn-admin\"", html)
self.assertIn(">⚙️ Administration", html)
# Administration sub-navigation tabs (row below)
self.assertIn("id=\"adminSubtabs\"", html)
self.assertNotIn("⚙️ Administration:</span>", html)
self.assertIn("id=\"tabBtn-baseline\"", html)
self.assertIn(">🛡️ Baseline Config</button>", html)
self.assertIn("id=\"tabBtn-hermes\"", html)
self.assertIn(">🤖 Hermes AI &amp; Tokens</button>", html)
self.assertIn("id=\"tabBtn-pki\"", html)
self.assertIn(">📜 Sub-CA &amp; Licensing</button>", html)
self.assertIn("id=\"tabBtn-db\"", html)
self.assertIn(">💾 DB Engine</button>", html)
self.assertIn("id=\"tabBtn-updates\"", html)
self.assertIn(">📦 Updates &amp; Distribution", html)
# Tab panes
self.assertIn("id=\"tabPane-fleet\"", html)
self.assertIn("id=\"tabPane-server-roles\"", html)
self.assertIn("id=\"tabPane-events\"", html)
self.assertIn("id=\"tabPane-baseline\"", html)
self.assertIn("id=\"tabPane-pki\"", html)
self.assertIn("id=\"tabPane-db\"", html)
self.assertIn("id=\"tabPane-updates\"", html)
self.assertIn("id=\"tabPane-hermes\"", html)
# Telemetry JSON inspector modal
self.assertIn("id=\"telemetryModal\"", html)
self.assertIn("id=\"modalJsonContent\"", html)
# Server Roles & Client Directory tab should not contain locked/inactive Best Practice Baseline card or banner
server_roles_pane = html.split('id="tabPane-server-roles"')[1].split('id="tabPane-')[0]
self.assertNotIn("Best Practice Baseline", server_roles_pane)
self.assertNotIn("Requires 'best_practices' license capability", server_roles_pane)
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)
def test_admin_version_info_modal(self):
"""Verifies admin dropdown menu contains Version & System Info option and renders telemetry modal in /webui."""
from src.version import LOGAR_VERSION
auth_client = TestClient(app)
auth_client.cookies.set(SessionManager.SESSION_COOKIE_NAME, self.session_token)
# Check /webui
res_webui = auth_client.get("/webui")
self.assertEqual(res_webui.status_code, 200)
self.assertIn("id=\"userDropdown\"", res_webui.text)
self.assertIn("Version &amp; System Info", res_webui.text)
self.assertIn("openVersionInfoModal()", res_webui.text)
self.assertIn("id=\"versionInfoModal\"", res_webui.text)
self.assertIn(f"v{LOGAR_VERSION}", res_webui.text)
self.assertIn("Server Hub Release", res_webui.text)
self.assertIn("Hosted Client Binaries", res_webui.text)
self.assertIn("Python Environment:", res_webui.text)
self.assertIn("Host Platform &amp; OS:", res_webui.text)
self.assertIn("TLS 1.3 mTLS Encrypted", res_webui.text)
self.assertNotIn("Repository Update Mode:", res_webui.text)
self.assertNotIn("Distribution Source:", res_webui.text)
self.assertNotIn("Storage Backend Engine:", res_webui.text)
self.assertNotIn("License Status &amp; Seats:", res_webui.text)
# Check /client-info redirects to /webui#server-roles and shows the version info modal
res_client_info = auth_client.get("/client-info", follow_redirects=True)
self.assertEqual(res_client_info.status_code, 200)
self.assertIn("id=\"userDropdown\"", res_client_info.text)
self.assertIn("Version &amp; System Info", res_client_info.text)
self.assertIn("id=\"versionInfoModal\"", res_client_info.text)
self.assertIn(f"v{LOGAR_VERSION}", res_client_info.text)
def test_linux_distro_collection_ubuntu(self):
"""Verifies parsing of standard Ubuntu os-release data."""
ubuntu_os_release = (
'NAME="Ubuntu"\n'
'VERSION="22.04.4 LTS (Jammy Jellyfish)"\n'
'ID=ubuntu\n'
'ID_LIKE=debian\n'
'PRETTY_NAME="Ubuntu 22.04.4 LTS"\n'
'VERSION_ID="22.04"\n'
'VERSION_CODENAME=jammy\n'
)
import tempfile
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
f.write(ubuntu_os_release)
tmp_path = f.name
try:
info = get_linux_distro_info(os_release_path=tmp_path)
self.assertEqual(info["distro"], "Ubuntu")
self.assertEqual(info["distro_version"], "22.04")
self.assertEqual(info["distro_id"], "ubuntu")
self.assertEqual(info["distro_codename"], "jammy")
self.assertEqual(info["pretty_name"], "Ubuntu 22.04.4 LTS")
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def test_linux_distro_collection_debian(self):
"""Verifies parsing of standard Debian os-release data."""
debian_os_release = (
'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"\n'
'NAME="Debian GNU/Linux"\n'
'VERSION_ID="12"\n'
'VERSION="12 (bookworm)"\n'
'VERSION_CODENAME=bookworm\n'
'ID=debian\n'
)
import tempfile
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
f.write(debian_os_release)
tmp_path = f.name
try:
info = get_linux_distro_info(os_release_path=tmp_path)
self.assertEqual(info["distro"], "Debian GNU/Linux")
self.assertEqual(info["distro_version"], "12")
self.assertEqual(info["distro_id"], "debian")
self.assertEqual(info["distro_codename"], "bookworm")
self.assertEqual(info["pretty_name"], "Debian GNU/Linux 12 (bookworm)")
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def test_linux_distro_collection_rhel_and_alpine(self):
"""Verifies parsing of RHEL and Alpine os-release data."""
rhel_release = (
'NAME="Red Hat Enterprise Linux"\n'
'VERSION="9.3 (Plow)"\n'
'ID="rhel"\n'
'ID_LIKE="fedora"\n'
'VERSION_ID="9.3"\n'
'PRETTY_NAME="Red Hat Enterprise Linux 9.3 (Plow)"\n'
)
alpine_release = (
'NAME="Alpine Linux"\n'
'ID=alpine\n'
'VERSION_ID=3.19.1\n'
'PRETTY_NAME="Alpine Linux v3.19"\n'
)
import tempfile
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
f.write(rhel_release)
rhel_path = f.name
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
f.write(alpine_release)
alpine_path = f.name
try:
rhel_info = get_linux_distro_info(os_release_path=rhel_path)
self.assertEqual(rhel_info["distro"], "Red Hat Enterprise Linux")
self.assertEqual(rhel_info["distro_version"], "9.3")
self.assertEqual(rhel_info["distro_id"], "rhel")
alpine_info = get_linux_distro_info(os_release_path=alpine_path)
self.assertEqual(alpine_info["distro"], "Alpine Linux")
self.assertEqual(alpine_info["distro_version"], "3.19.1")
self.assertEqual(alpine_info["distro_id"], "alpine")
finally:
if os.path.exists(rhel_path):
os.unlink(rhel_path)
if os.path.exists(alpine_path):
os.unlink(alpine_path)
def test_get_os_details_linux_simulation(self):
"""Tests get_os_details when simulated on Linux."""
ubuntu_os_release = (
'NAME="Ubuntu"\n'
'VERSION="24.04 LTS (Noble Numbat)"\n'
'ID=ubuntu\n'
'VERSION_ID="24.04"\n'
'PRETTY_NAME="Ubuntu 24.04 LTS"\n'
'VERSION_CODENAME=noble\n'
)
import tempfile
with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as f:
f.write(ubuntu_os_release)
tmp_path = f.name
try:
with patch("sys.platform", "linux"):
details = get_os_details(os_release_path=tmp_path)
self.assertEqual(details["os_type"], "linux")
self.assertTrue(details["is_server"])
self.assertEqual(details["distro"], "Ubuntu")
self.assertEqual(details["distro_version"], "24.04")
self.assertEqual(details["distro_id"], "ubuntu")
self.assertEqual(details["distro_codename"], "noble")
self.assertEqual(details["os_name"], "Ubuntu 24.04 LTS")
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def test_webui_renders_linux_distro_badge_and_specs(self):
"""Verifies /webui properly formats Linux distribution badges and specs."""
db_path = self.db_path
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("""
INSERT OR REPLACE 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 (
'test-linux-node-01', 'ubuntu-srv-01.corp.internal', 'linux', 'FP-LINUX-TEST-01', 'active',
'2026-10-11 10:00:00', '2026-10-11 10:05:00', '1.2.0', 'Ubuntu 22.04.4 LTS', 1,
'["Web Server (Nginx)", "SSH Remote Access"]',
'{"distro": "Ubuntu", "distro_version": "22.04", "distro_id": "ubuntu", "distro_codename": "jammy", "architecture": "x86_64", "cpu_count": 4, "memory_total_mb": 8192}'
)
""")
conn.commit()
conn.close()
auth_client = TestClient(app)
auth_client.cookies.set(SessionManager.SESSION_COOKIE_NAME, self.session_token)
res = auth_client.get("/webui")
self.assertEqual(res.status_code, 200)
self.assertIn("ubuntu-srv-01.corp.internal", res.text)
self.assertIn("Ubuntu 22.04.4 LTS", res.text)
self.assertIn("UBUNTU", res.text)
if __name__ == "__main__":
unittest.main()
+65 -22
View File
@@ -32,7 +32,10 @@ class TestUpdateSystem(unittest.TestCase):
Server.init_db(self.test_db) Server.init_db(self.test_db)
self.update_mgr = ServerUpdateManager(update_dir=self.test_updates_dir, check_enabled=False) self.update_mgr = ServerUpdateManager(update_dir=self.test_updates_dir, check_enabled=False)
Server.SERVER_STATE["update_manager"] = self.update_mgr Server.SERVER_STATE["update_manager"] = self.update_mgr
Server.SERVER_STATE["config"] = {"db_path": self.test_db} Server.SERVER_STATE["config"] = {
"db_path": self.test_db,
"config_file_path": os.path.join(self.test_dir, "test_server_config.json")
}
def tearDown(self): def tearDown(self):
import shutil import shutil
@@ -127,14 +130,14 @@ class TestUpdateSystem(unittest.TestCase):
f.write(b"WIN_CLIENT_V0360") f.write(b"WIN_CLIENT_V0360")
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0") self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
# 3. Client update check endpoint (Unlicensed should block auto_update) # 3. Client update check endpoint (Unlicensed blocks client auto_update)
chk_blocked = Server.check_client_update( chk_unlicensed = Server.check_client_update(
client_id="win-client-01", client_id="win-client-01",
os="windows", os="windows",
current_version="0.3.5.1" current_version="0.3.5.1"
) )
self.assertFalse(chk_blocked["update_available"]) self.assertFalse(chk_unlicensed["update_available"])
self.assertIn("not authorized", chk_blocked.get("reason", "")) self.assertIn("not authorized", chk_unlicensed.get("reason", ""))
# 4. Enable auto_update feature flag in license config # 4. Enable auto_update feature flag in license config
import sqlite3 import sqlite3
@@ -194,27 +197,67 @@ class TestUpdateSystem(unittest.TestCase):
self.assertIn("tabPane-updates", html) self.assertIn("tabPane-updates", html)
self.assertIn("Client Package Distribution Repository", html) self.assertIn("Client Package Distribution Repository", html)
def test_update_memoization_and_cache_invalidation(self): def test_auto_update_switches_and_modes(self):
"""Tests that package info is served from memory cache and invalidates on update.""" """Tests On/Off toggle, Auto vs Manual modes, and config API endpoint."""
dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Linux") import sqlite3
conn = sqlite3.connect(self.test_db)
conn.execute("UPDATE license_config SET license_status = 'active', features = '[\"auto_update\"]' WHERE id = 1")
conn.commit()
conn.close()
dummy_pkg = os.path.join(self.test_dir, "LOGAR_Client_Setup.exe")
with open(dummy_pkg, "wb") as f: with open(dummy_pkg, "wb") as f:
f.write(b"LINUX_CLIENT_PAYLOAD_V1") f.write(b"WIN_CLIENT_V0360")
self.update_mgr.register_client_package("windows", dummy_pkg, version="0.3.6.0")
self.update_mgr.register_client_package("linux", dummy_pkg, version="0.3.6.0") # 1. Active & auto mode -> update offered
pkgs_1 = self.update_mgr.get_hosted_packages() chk1 = Server.check_client_update(client_id="win-node", os="windows", current_version="0.3.5.2")
sha1 = pkgs_1["linux"]["sha256"] self.assertTrue(chk1["update_available"])
# Repeated call should hit memory cache with identical hash # 2. Toggle Auto-Update OFF via API
pkgs_2 = self.update_mgr.get_hosted_packages() from server_models import ServerUpdateConfigRequest
self.assertEqual(pkgs_2["linux"]["sha256"], sha1) cfg_req = ServerUpdateConfigRequest(auto_update_enabled=False, update_mode="manual")
res = Server.update_server_update_config(req=cfg_req, auth_user="admin")
self.assertEqual(res["status"], "success")
self.assertFalse(self.update_mgr.auto_update_enabled)
self.assertEqual(self.update_mgr.update_mode, "manual")
# Invalidate cache on new registration # Client update check while OFF -> blocked
with open(dummy_pkg, "wb") as f: chk2 = Server.check_client_update(client_id="win-node", os="windows", current_version="0.3.5.2")
f.write(b"LINUX_CLIENT_PAYLOAD_V2_UPDATED") self.assertFalse(chk2["update_available"])
self.update_mgr.register_client_package("linux", dummy_pkg, version="0.3.7.0") self.assertIn("disabled", chk2.get("reason", ""))
pkgs_3 = self.update_mgr.get_hosted_packages()
self.assertEqual(pkgs_3["linux"]["version"], "0.3.7.0") # 3. Toggle back ON with 'official' mode
self.assertNotEqual(pkgs_3["linux"]["sha256"], sha1) cfg_req2 = ServerUpdateConfigRequest(auto_update_enabled=True, update_mode="official")
res2 = Server.update_server_update_config(req=cfg_req2, auth_user="admin")
self.assertEqual(res2["status"], "success")
self.assertTrue(self.update_mgr.auto_update_enabled)
self.assertEqual(self.update_mgr.update_mode, "official")
# 4. Configure 'custom' mode with custom upstream URL
cfg_req3 = ServerUpdateConfigRequest(
auto_update_enabled=True,
update_mode="custom",
upstream_url="https://git.corp.local/api/v1/releases/latest"
)
res3 = Server.update_server_update_config(req=cfg_req3, auth_user="admin")
self.assertEqual(res3["status"], "success")
self.assertEqual(self.update_mgr.update_mode, "custom")
self.assertEqual(self.update_mgr.upstream_url, "https://git.corp.local/api/v1/releases/latest")
# 5. Verify WebUI renders official, custom, manual mode switches and no download buttons
html = Server.WebUIRenderer.render_dashboard_html(
server_state=Server.SERVER_STATE,
ingest_queue=Server.INGEST_QUEUE
)
self.assertNotIn("/api/client/update/download?os=windows", html)
self.assertNotIn("/api/client/update/download?os=linux", html)
self.assertIn("Fleet Auto-Update Service", html)
self.assertIn("Image Acquisition Source &amp; Hosting Mode", html)
self.assertIn("modeOfficial", html)
self.assertIn("modeCustom", html)
self.assertIn("modeManual", html)
self.assertIn("Ready", html)
if __name__ == "__main__": if __name__ == "__main__":