Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4348e6ce9e | ||
|
|
d261802f8d | ||
|
|
39e0e6f219 | ||
|
|
62d4993e74 | ||
|
|
52519e258e | ||
|
|
1bf3f0ed6f | ||
|
|
b44191ed89 | ||
|
|
fc97964e95 | ||
|
|
6b281626da | ||
|
|
72d9646cc4 | ||
|
|
cccf928f29 | ||
|
|
bcaae4a9ee | ||
|
|
5456d50774 | ||
|
|
6b2b39b080 | ||
|
|
285360b59f | ||
|
|
02bc65698b | ||
|
|
b069e1c1c0 | ||
|
|
41563a8fb8 | ||
|
|
f0f6eba53b |
+1
-1
Submodule LOGAR-LICENSING updated: 38a41cfbac...8068652573
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -88,14 +88,25 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li
|
|||||||
return collector.collect_logs(hours=hours)
|
return collector.collect_logs(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.common.system_info import get_os_details
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from common.system_info import get_os_details
|
||||||
|
except ImportError:
|
||||||
|
get_os_details = lambda: {"os_type": "windows"}
|
||||||
|
|
||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||||
return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows")
|
node_os = get_os_details().get("os_type", "windows")
|
||||||
|
return transport_send_encrypted_logs(config=config, logs=logs, os_type=node_os)
|
||||||
|
|
||||||
|
|
||||||
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||||
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows")
|
node_os = get_os_details().get("os_type", "windows")
|
||||||
|
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type=node_os)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ except ImportError:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
from client_updater import ClientUpdater
|
from client_updater import ClientUpdater
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.common.system_info import get_full_system_inventory, get_os_details
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from common.system_info import get_full_system_inventory, get_os_details
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from system_info import get_full_system_inventory, get_os_details
|
||||||
|
except ImportError:
|
||||||
|
get_full_system_inventory = lambda: {}
|
||||||
|
get_os_details = lambda: {"os_type": "windows" if sys.platform == "win32" else "linux", "os_name": "", "is_server": False}
|
||||||
|
|
||||||
CONFIG_FILE_NAME = "client_config.json"
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -96,10 +108,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
# LOGAR Common Package
|
# LOGAR Common Package
|
||||||
|
from src.common.system_info import (
|
||||||
|
get_os_details,
|
||||||
|
get_installed_server_roles,
|
||||||
|
get_full_system_inventory,
|
||||||
|
get_ip_addresses
|
||||||
|
)
|
||||||
|
from src.common.version import LOGAR_VERSION
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -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 & Locked", html)
|
self.assertIn("Fleet Auto-Update is Inactive & 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
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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 & Tokens</button>', html_content)
|
||||||
|
self.assertIn('>📜 Sub-CA & Licensing</button>', html_content)
|
||||||
self.assertIn('>💾 DB Engine</button>', html_content)
|
self.assertIn('>💾 DB Engine</button>', html_content)
|
||||||
|
self.assertIn('>📦 Updates & 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 & 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 & 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 & Tokens</button>", html)
|
||||||
|
self.assertIn("id=\"tabBtn-pki\"", html)
|
||||||
|
self.assertIn(">📜 Sub-CA & Licensing</button>", html)
|
||||||
|
self.assertIn("id=\"tabBtn-db\"", html)
|
||||||
|
self.assertIn(">💾 DB Engine</button>", html)
|
||||||
|
self.assertIn("id=\"tabBtn-updates\"", html)
|
||||||
|
self.assertIn(">📦 Updates & 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 & 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 & 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 & 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 & 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
@@ -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 & 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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user