""" LOGAR - Central Cloud Ingestion Hub & Fleet Licensing Server Main orchestrator and FastAPI application server. Delegates domain responsibilities to specialized modular managers: - server_models: Pydantic request & response data models - auth_manager: Session, Rate Limiting, Password, 2FA TOTP, Hermes token managers - ingest_queue: Ingestion ring buffering and batch processing - storage_engine: Multi-engine SQL schema, migrations, and data-at-rest encryption - license_manager: Sub-CA licensing, OID parsing, and seat synchronization - pki_manager: TLS 1.3 mTLS context creation and dynamic certificate rotation - web_ui: Dark-mode administrative WebUI & reactive dashboard renderer """ import os import sys import json import uuid import time import struct import socket import sqlite3 import argparse import asyncio import secrets import warnings import threading from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional, Tuple # Ensure script directory and repo root are in sys.path SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) if REPO_ROOT not in sys.path: sys.path.insert(0, REPO_ROOT) # Suppress cryptography / pgpy deprecation notices for a clean terminal output warnings.filterwarnings("ignore") import pgpy import ssl from fastapi import FastAPI, HTTPException, Request, Depends from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse, FileResponse import uvicorn try: from src import server_enrollment as enrollment except ImportError: import server_enrollment as enrollment try: from src.version import LOGAR_VERSION, is_newer_version, compare_versions from src.update_manager import ServerUpdateManager except ImportError: from version import LOGAR_VERSION, is_newer_version, compare_versions from update_manager import ServerUpdateManager try: from src.log_collector import ( get_machine_identifier, WindowsLogCollector, LinuxLogCollector, get_log_collector, is_routine_service_noise ) except ImportError: from log_collector import ( get_machine_identifier, WindowsLogCollector, LinuxLogCollector, get_log_collector, is_routine_service_noise ) # Import specialized modular components from src.server_models import ( ClientEnrollRequest, ClientActionRequest, LicenseImportRequest, HermesTokenCreateRequest, HermesTokenRevokeRequest, ClientProfileExportRequest, ChangePasswordRequest, DbConnectionTestRequest, DbSwitchBackendRequest, DbFullConfigRequest, ClientUpdateRequest, ServerUpdateCheckRequest ) from src.auth_manager import ( SessionManager, RateLimiter, PasswordManager, TOTPManager, HermesTokenManager ) from src.ingest_queue import IngestQueueManager from src.storage_engine import StorageEngine, StorageCryptoManager from src.license_manager import LicenseManager from src.pki_manager import PKIManager from src.web_ui import WebUIRenderer, format_relative_time CONFIG_FILE_NAME = "server_config.json" DEFAULT_DB_FILE = "logar_state.db" EVALUATION_WINDOW_HOURS = 12 RUN_THRESHOLD = 1 SESSION_COOKIE_NAME = SessionManager.SESSION_COOKIE_NAME DEFAULT_ADMIN_USER = StorageEngine.DEFAULT_ADMIN_USER DEFAULT_ADMIN_PASS = StorageEngine.DEFAULT_ADMIN_PASS app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version=LOGAR_VERSION) # Global context holding server state UPDATE_MANAGER = ServerUpdateManager() SERVER_STATE: Dict[str, Any] = { "update_manager": UPDATE_MANAGER } # Global rate limiter & ingestion queue RATE_LIMITER = RateLimiter() INGEST_QUEUE = IngestQueueManager() # ===================================================================== # Backward-Compatible Auth & Helper Functions (delegating to managers) # ===================================================================== def get_session_secret(db_path: Optional[str] = None) -> str: target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE return SessionManager.get_session_secret(db_path=target_db, fallback_db=DEFAULT_DB_FILE) SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32) def get_client_ip(request: Optional[Request] = None) -> str: return RateLimiter.get_client_ip(request) def check_ip_rate_limit( ip: str, store: Any = None, max_attempts: int = 10, window_seconds: int = 60, scope: str = "login", db_path: Optional[str] = None ) -> bool: target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE return RATE_LIMITER.check_ip_rate_limit( ip=ip, store=store, max_attempts=max_attempts, window_seconds=window_seconds, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE ) def record_failed_ip_attempt( ip: str, store: Any = None, scope: str = "login", db_path: Optional[str] = None ): target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE RATE_LIMITER.record_failed_ip_attempt( ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE ) def reset_failed_ip_attempts( ip: str, store: Any = None, scope: str = "login", db_path: Optional[str] = None ): target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE RATE_LIMITER.reset_failed_ip_attempts( ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE ) def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: return PasswordManager.hash_password(password, salt=salt) def verify_password(password: str, salt: str, password_hash: str) -> bool: return PasswordManager.verify_password(password, salt, password_hash) def generate_session_token(username: str, db_path: Optional[str] = None) -> str: target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE return SessionManager.generate_session_token(username, db_path=target_db, fallback_db=DEFAULT_DB_FILE) def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]: target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE return SessionManager.verify_session_token(token, max_age_hours=max_age_hours, db_path=target_db, fallback_db=DEFAULT_DB_FILE) def get_current_user_optional(request: Request) -> Optional[str]: """Extracts authenticated username from cookie or Bearer header if valid.""" db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE cookie = request.cookies.get(SESSION_COOKIE_NAME) if cookie: user = verify_session_token(cookie, db_path=db_path) if user: return user auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1].strip() user = verify_session_token(token, db_path=db_path) if user: return user return None def require_admin_auth(request: Request) -> str: """Enforces administrator authentication for management endpoints.""" # 1. Check if logged in admin user via session cookie or Bearer session token user = get_current_user_optional(request) if user: return user # 2. Check Bearer token or token query param matching config auth_token token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1].strip() if not token: token = request.query_params.get("token") if token: cfg_token = SERVER_STATE.get("config", {}).get("auth_token") if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): return "config_auth_token" raise HTTPException(status_code=401, detail="Authentication required. Please login or provide admin token.") def verify_user_credentials(username: str, password: str, db_path: str = DEFAULT_DB_FILE) -> bool: return PasswordManager.verify_user_credentials(username, password, db_path=db_path) def change_user_password(username: str, new_password: str, db_path: str = DEFAULT_DB_FILE) -> bool: return PasswordManager.change_user_password(username, new_password, db_path=db_path) def generate_totp_secret(length: int = 20) -> str: return TOTPManager.generate_totp_secret(length=length) def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: return TOTPManager.get_totp_token(secret, intervals_no=intervals_no) def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: return TOTPManager.verify_totp_code(secret, code, window=window) def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: return TOTPManager.get_totp_uri(username, secret, issuer=issuer) def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: return TOTPManager.get_user_totp_info(username, db_path=db_path) def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool: return TOTPManager.enable_user_totp(username, secret, db_path=db_path) def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool: return TOTPManager.disable_user_totp(username, db_path=db_path) def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: return HermesTokenManager.create_hermes_token(name, token=token, db_path=db_path) def list_hermes_tokens(db_path: str = DEFAULT_DB_FILE) -> List[Dict[str, Any]]: return HermesTokenManager.list_hermes_tokens(db_path=db_path) def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: return HermesTokenManager.revoke_hermes_token(token, db_path=db_path) def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: return HermesTokenManager.delete_hermes_token(token, db_path=db_path) def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: return HermesTokenManager.verify_hermes_token(token, db_path=db_path) def require_hermes_or_admin_auth(request: Request) -> str: """Authorizes Hermes API access via Bearer token, query parameter, admin session, or legacy auth token.""" user = get_current_user_optional(request) if user: return f"user:{user}" token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): token = auth_header.split(" ", 1)[1].strip() if not token: token = request.query_params.get("token") db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if token: if verify_hermes_token(token, db_path=db_path): return f"hermes_token:{token[:8]}..." cfg_token = SERVER_STATE.get("config", {}).get("auth_token") if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): return "config_auth_token" # Fallback if no hermes tokens are defined in database yet try: conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT COUNT(*) FROM hermes_tokens WHERE status = 'active'") active_count = c.fetchone()[0] conn.close() if active_count == 0: return "anonymous_fallback" except Exception: pass raise HTTPException(status_code=401, detail="Valid Hermes Bearer Token or Administrator Login Required") # ===================================================================== # Keypair, Storage & Path Resolution Utilities # ===================================================================== def generate_server_keypair(server_name: str) -> Tuple[str, str, str]: return StorageCryptoManager.generate_server_keypair(server_name) def is_path_writable(filepath: str) -> bool: """Checks if a file or directory path is writable by the current process.""" try: if os.path.exists(filepath): if os.path.isdir(filepath): test_f = os.path.join(filepath, f".perm_{os.getpid()}") with open(test_f, "w") as f: f.write("1") os.remove(test_f) return True else: with open(filepath, "a") as f: pass return True else: parent = os.path.dirname(os.path.abspath(filepath)) or "." os.makedirs(parent, exist_ok=True) test_f = os.path.join(parent, f".perm_{os.getpid()}") with open(test_f, "w") as f: f.write("1") os.remove(test_f) return True except Exception: return False def get_default_data_dir(app_name: str = "LOGAR-Server") -> str: """Returns a suitable writable application data directory.""" if sys.platform == "win32": prog_data = os.environ.get("ProgramData", r"C:\ProgramData") target_prog = os.path.join(prog_data, app_name) if is_path_writable(target_prog): return target_prog local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") target_local = os.path.join(local_app, app_name) if is_path_writable(target_local): return target_local temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) if is_path_writable(temp_dir): return temp_dir cwd = os.getcwd() if is_path_writable(cwd): return cwd return os.path.expanduser("~") def resolve_writable_path(path: str, app_name: str = "LOGAR-Server", default_filename: str = "logar_state.db") -> str: """Resolves a path, ensuring it is writable or falling back to a writable data directory.""" if not path: path = default_filename if os.path.isabs(path) and is_path_writable(path): return path if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): return os.path.abspath(path) data_dir = get_default_data_dir(app_name) fname = os.path.basename(path) return os.path.join(data_dir, fname) def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: """Loads existing server_config.json or creates a new one on first run.""" actual_config_path = config_path if not os.path.isabs(actual_config_path) and not os.path.exists(actual_config_path): data_dir_cfg = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) if os.path.exists(data_dir_cfg): actual_config_path = data_dir_cfg if os.path.exists(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: config = json.load(f) if "enrollment_secret" not in config: config["enrollment_secret"] = secrets.token_hex(24) if "max_seats" not in config: config["max_seats"] = 0 if "cert_dir" not in config: config["cert_dir"] = "certs" if "tls_enabled" not in config: config["tls_enabled"] = True if "license_server_url" not in config: config["license_server_url"] = "https://logar.eibl.tech" if "admin_user" not in config: config["admin_user"] = DEFAULT_ADMIN_USER if "session_secret" not in config: config["session_secret"] = secrets.token_hex(32) if "db_path" not in config: config["db_path"] = DEFAULT_DB_FILE if "server_name" not in config: config["server_name"] = "LOGAR Enterprise Hub" if "tcp_host" not in config: config["tcp_host"] = "0.0.0.0" if "tcp_port" not in config: config["tcp_port"] = 9443 if "hermes_host" not in config: config["hermes_host"] = "0.0.0.0" if "hermes_port" not in config: config["hermes_port"] = 8443 if "evaluation_window_hours" not in config: config["evaluation_window_hours"] = 12 if "min_persistence_runs" not in config: config["min_persistence_runs"] = 2 if "storage_engine" not in config: config["storage_engine"] = config.get("db_type", "sqlite") if "cache_mode" not in config: config["cache_mode"] = "direct" if "encrypt_at_rest" not in config: config["encrypt_at_rest"] = False config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE) config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") config["config_file_path"] = os.path.abspath(actual_config_path) global SESSION_SECRET if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: SESSION_SECRET = config["session_secret"] return config if not os.path.isabs(config_path) and ("Program Files" in os.getcwd() or not is_path_writable(os.getcwd())): config_path = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...") server_name = "LOGAR-Cloud-Hub" private_key, public_key, fingerprint = generate_server_keypair(server_name) auth_token = secrets.token_hex(24) enrollment_secret = secrets.token_hex(24) session_sec = secrets.token_hex(32) db_path = resolve_writable_path(DEFAULT_DB_FILE, "LOGAR-Server", DEFAULT_DB_FILE) cert_dir = resolve_writable_path("certs", "LOGAR-Server", "certs") config = { "server_name": server_name, "tcp_host": "0.0.0.0", "tcp_port": 9443, "hermes_host": "0.0.0.0", "hermes_port": 8443, "admin_user": DEFAULT_ADMIN_USER, "auth_token": auth_token, "enrollment_secret": enrollment_secret, "session_secret": session_sec, "max_seats": 0, "license_status": "unlicensed", "license_server_url": "https://logar.eibl.tech", "cert_dir": cert_dir, "tls_enabled": True, "db_path": db_path, "evaluation_window_hours": EVALUATION_WINDOW_HOURS, "min_persistence_runs": RUN_THRESHOLD, "server_fingerprint": fingerprint, "public_key": public_key, "private_key": private_key, "config_file_path": os.path.abspath(config_path) } if not os.environ.get("LOGAR_SESSION_SECRET"): SESSION_SECRET = session_sec os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) print("[+] Successfully generated new server config and OpenPGP keypair.") print(f"[+] Server Encryption Fingerprint: {fingerprint}") print(f"[+] Saved to: {os.path.abspath(config_path)}") return config def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> str: """Generates a fresh enrollment secret, invalidates the previous secret, and persists change to disk & DB.""" cfg = config_dict if config_dict is not None else SERVER_STATE.get("config", {}) new_secret = secrets.token_hex(24) cfg["enrollment_secret"] = new_secret config_file_path = cfg.get("config_file_path") or CONFIG_FILE_NAME try: if os.path.exists(config_file_path): with open(config_file_path, "r", encoding="utf-8") as f: saved = json.load(f) saved["enrollment_secret"] = new_secret with open(config_file_path, "w", encoding="utf-8") as f: json.dump(saved, f, indent=2) except Exception as e: print(f"[!] Warning: Could not write new enrollment secret to config file: {e}") db_path = cfg.get("db_path", DEFAULT_DB_FILE) try: conn = sqlite3.connect(db_path, timeout=5.0) c = conn.cursor() c.execute("UPDATE license_config SET enrollment_secret = ? WHERE id = 1", (new_secret,)) conn.commit() conn.close() except Exception as e: print(f"[!] Warning: Could not update database with new enrollment secret: {e}") SERVER_STATE["config"] = cfg return new_secret def generate_client_profile_dict( server_host: str, server_port: int, client_id: Optional[str] = None, config_dict: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Generates a complete standalone client profile dictionary for export.""" cfg = config_dict or SERVER_STATE.get("config", {}) ca_pem = SERVER_STATE.get("ca_cert_pem", "") if not ca_pem: cert_dir = cfg.get("cert_dir", "certs") ca_path = os.path.join(cert_dir, "ca.crt") if os.path.exists(ca_path): with open(ca_path, "r", encoding="utf-8") as f: ca_pem = f.read() return { "format": "LOGAR_CLIENT_PROFILE", "version": "3.0.0", "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), "server_host": server_host, "server_port": int(server_port), "hermes_host": server_host if cfg.get("hermes_host") in ("0.0.0.0", "::", None) else cfg.get("hermes_host", server_host), "hermes_port": int(cfg.get("hermes_port", 8443)), "enrollment_secret": cfg.get("enrollment_secret", ""), "server_fingerprint": cfg.get("server_fingerprint", ""), "server_public_key": cfg.get("public_key", ""), "auth_token": cfg.get("auth_token", ""), "ca_cert": ca_pem, "client_id": client_id or "" } def create_client_config( server_host: str, server_port: int, output_path: str, config_path: str = CONFIG_FILE_NAME, hermes_host: Optional[str] = None, hermes_port: Optional[int] = None ) -> Dict[str, Any]: """Creates a client configuration file containing the server address, auth token, and encryption key.""" server_conf = load_or_init_config(config_path) effective_hermes_host = hermes_host if not effective_hermes_host: cfg_hermes = server_conf.get("hermes_host") effective_hermes_host = server_host if cfg_hermes in ("0.0.0.0", "::", None) else cfg_hermes client_conf = { "server_host": server_host, "server_port": server_port, "hermes_host": effective_hermes_host, "hermes_port": hermes_port or server_conf.get("hermes_port", 8443), "enrollment_secret": server_conf.get("enrollment_secret"), "cert_dir": "certs", "server_fingerprint": server_conf["server_fingerprint"], "server_public_key": server_conf["public_key"], "auth_token": server_conf["auth_token"] } out_dir = os.path.dirname(os.path.abspath(output_path)) if out_dir and not os.path.exists(out_dir): os.makedirs(out_dir, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: json.dump(client_conf, f, indent=2) print(f"[+] Client configuration successfully written to: {os.path.abspath(output_path)}") print(f" - Server Target: {server_host}:{server_port}") print(f" - Encryption Fingerprint: {server_conf['server_fingerprint']}") return client_conf # ===================================================================== # Database, Storage & Crypto Integrations # ===================================================================== def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str: return StorageEngine.init_db(db_path, enrollment_secret=enrollment_secret, max_seats=max_seats) def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]: return StorageEngine.init_postgres_db(pg_config) def migrate_sqlite_to_postgres(sqlite_path: str, pg_config: Dict[str, Any]) -> Tuple[bool, str, Dict[str, int]]: return StorageEngine.migrate_sqlite_to_postgres(sqlite_path, pg_config, encrypt_fn=encrypt_field_for_storage) def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: return LicenseManager.export_license_request_lic(config) def get_active_licensed_features(db_path: Optional[str] = None) -> List[str]: return LicenseManager.get_active_licensed_features(db_path=db_path, server_state=SERVER_STATE) def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool: return LicenseManager.is_feature_licensed(feature, db_path=db_path, server_state=SERVER_STATE) def get_server_public_key() -> Optional[pgpy.PGPKey]: return StorageCryptoManager.get_server_public_key(server_state=SERVER_STATE) def encrypt_field_for_storage(text: str, force: bool = False) -> str: db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) is_unlocked = is_feature_licensed("enterprise_db", db_path) return StorageCryptoManager.encrypt_field_for_storage(text, force=force, server_state=SERVER_STATE, is_unlocked=is_unlocked) def decrypt_field_from_storage(text: str) -> str: return StorageCryptoManager.decrypt_field_from_storage(text, server_state=SERVER_STATE) def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]: target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db) return StorageEngine.get_storage_engine_info( db_path=target_db, server_state=SERVER_STATE, ingest_queue=INGEST_QUEUE, is_unlocked=is_unlocked ) def get_license_summary(db_path: Optional[str] = None) -> Dict[str, Any]: return LicenseManager.get_license_summary(server_state=SERVER_STATE, db_path=db_path) def report_seat_quota_to_authority_sync(): LicenseManager.report_seat_quota_to_authority_sync(server_state=SERVER_STATE) def trigger_async_seat_report(): LicenseManager.trigger_async_seat_report(server_state=SERVER_STATE) # ===================================================================== # PKI & mTLS Management # ===================================================================== def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: return PKIManager.init_mtls_server_context(cert_dir=cert_dir) def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): PKIManager.reload_mtls_context(ssl_ctx, cert_dir=cert_dir) def check_and_rotate_server_certs( cert_dir: str = "certs", hostnames: Optional[List[str]] = None, threshold_days: int = 30 ) -> bool: return PKIManager.check_and_rotate_server_certs( cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days, server_state=SERVER_STATE ) # ===================================================================== # Log Ingestion & Processing # ===================================================================== async def ingest_buffer_worker_loop(): """Periodically flushes the in-memory ingestion buffer to persistent database storage.""" while True: try: await asyncio.sleep(INGEST_QUEUE.flush_interval) config = SERVER_STATE.get("config", {}) db_path = config.get("db_path", DEFAULT_DB_FILE) window_hours = config.get("evaluation_window_hours", 12) min_runs = config.get("min_persistence_runs", 1) INGEST_QUEUE.flush( db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs ) except asyncio.CancelledError: break except Exception as e: print(f"[!] Ingestion buffer worker error: {e}") def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]: """ Evaluates ingested log anomalies and updates temporal run tracking. All anomalies are promoted directly to VERIFIED for Hermes AI reasoning and decision-making. Supports SQLite and external PostgreSQL with optional OpenPGP data encryption at rest. """ client_server = payload.get("server", "unknown-host") site_name = payload.get("site_name") or (client_server.split(".", 1)[1] if "." in client_server else "default") logs = payload.get("logs", []) run_id = str(uuid.uuid4()) now = datetime.now(timezone.utc) now_iso = now.isoformat() config = SERVER_STATE.get("config", {}) db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower() if db_type in ("postgres", "postgresql"): try: try: import psycopg2 except ImportError: import psycopg as psycopg2 pg_cfg = config.get("postgres", {}) conn = psycopg2.connect( host=pg_cfg.get("host", "localhost"), port=int(pg_cfg.get("port", 5432)), dbname=pg_cfg.get("database", "logar"), user=pg_cfg.get("user", "postgres"), password=pg_cfg.get("password", ""), sslmode=pg_cfg.get("sslmode", "prefer"), connect_timeout=int(pg_cfg.get("connect_timeout", 5)) ) cursor = conn.cursor() cursor.execute( "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (%s, %s, %s, %s, %s)", (run_id, site_name, client_server, now_iso, len(logs)) ) processed_count = 0 promoted_to_verified = 0 for log in logs: severity = str(log.get("severity", "WARNING")).upper() if severity in ["DEBUG", "TRACE"]: continue signature = log.get("signature", "unknown") server = log.get("server", client_server) message = log.get("message", "") os_type = log.get("os_type", "unknown") # Suppress repetitive benign routine service start/stop cycles if is_routine_service_noise(signature, message, severity): continue fp = f"{site_name}:{server}:{signature}" stored_message = encrypt_field_for_storage(message) cursor.execute( "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = %s", (fp,) ) row = cursor.fetchone() if row: run_count, first_seen_str, last_seen_str, current_status, last_run_id = row try: last_seen_dt = datetime.fromisoformat(last_seen_str) except Exception: last_seen_dt = now if (now - last_seen_dt) > timedelta(hours=window_hours): new_runs = 1 new_first_seen = now_iso else: new_runs = run_count + 1 if last_run_id != run_id else run_count new_first_seen = first_seen_str new_status = "VERIFIED" if current_status != "VERIFIED": promoted_to_verified += 1 cursor.execute(""" UPDATE active_issues SET run_count = %s, last_seen = %s, first_seen = %s, status = %s, last_run_id = %s, message = %s, severity = %s WHERE fingerprint = %s """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) else: initial_status = "VERIFIED" promoted_to_verified += 1 cursor.execute(""" INSERT INTO active_issues (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) processed_count += 1 # Update client node metrics if attached metrics = payload.get("metrics") if metrics and isinstance(metrics, dict) and client_server: cpu_p = float(metrics.get("cpu_percent", 0.0)) mem_p = float(metrics.get("memory_percent", 0.0)) disk_p = float(metrics.get("disk_percent_free", 0.0)) try: cursor.execute(""" UPDATE clients SET last_seen = %s, metrics_json = %s, cpu_percent = %s, memory_percent = %s, disk_percent_free = %s WHERE client_id = %s OR hostname = %s """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) except Exception: pass conn.commit() conn.close() return { "status": "success", "run_id": run_id, "processed": processed_count, "promoted_verified": promoted_to_verified } except Exception as pg_err: print(f"[!] Warning: PostgreSQL ingestion failed ({pg_err}), falling back to SQLite...") # SQLite standard / fallback path conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute( "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (?, ?, ?, ?, ?)", (run_id, site_name, client_server, now_iso, len(logs)) ) processed_count = 0 promoted_to_verified = 0 for log in logs: severity = str(log.get("severity", "WARNING")).upper() if severity in ["DEBUG", "TRACE"]: continue signature = log.get("signature", "unknown") server = log.get("server", client_server) message = log.get("message", "") os_type = log.get("os_type", "unknown") # Suppress repetitive benign routine service start/stop cycles if is_routine_service_noise(signature, message, severity): continue fp = f"{site_name}:{server}:{signature}" stored_message = encrypt_field_for_storage(message) cursor.execute( "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = ?", (fp,) ) row = cursor.fetchone() if row: run_count, first_seen_str, last_seen_str, current_status, last_run_id = row try: last_seen_dt = datetime.fromisoformat(last_seen_str) except Exception: last_seen_dt = now if (now - last_seen_dt) > timedelta(hours=window_hours): new_runs = 1 new_first_seen = now_iso else: new_runs = run_count + 1 if last_run_id != run_id else run_count new_first_seen = first_seen_str new_status = "VERIFIED" if current_status != "VERIFIED": promoted_to_verified += 1 cursor.execute(""" UPDATE active_issues SET run_count = ?, last_seen = ?, first_seen = ?, status = ?, last_run_id = ?, message = ?, severity = ? WHERE fingerprint = ? """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) else: initial_status = "VERIFIED" promoted_to_verified += 1 cursor.execute(""" INSERT INTO active_issues (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) processed_count += 1 # Update client node metrics & version if attached metrics = payload.get("metrics") client_ver = payload.get("version") if metrics and isinstance(metrics, dict) and client_server: cpu_p = float(metrics.get("cpu_percent", 0.0)) mem_p = float(metrics.get("memory_percent", 0.0)) disk_p = float(metrics.get("disk_percent_free", 0.0)) try: if client_ver: cursor.execute(""" UPDATE clients SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, client_version = ? WHERE client_id = ? OR hostname = ? """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_ver, client_server, client_server)) else: cursor.execute(""" UPDATE clients SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? WHERE client_id = ? OR hostname = ? """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) except Exception: pass elif client_ver and client_server: try: cursor.execute(""" UPDATE clients SET last_seen = ?, client_version = ? WHERE client_id = ? OR hostname = ? """, (now_iso, client_ver, client_server, client_server)) except Exception: pass conn.commit() conn.close() return { "status": "success", "run_id": run_id, "processed": processed_count, "promoted_verified": promoted_to_verified } def get_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: collector = WindowsLogCollector(state=state, channels=["Application", "System"]) return collector.collect_logs(hours=hours) def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: collector = LinuxLogCollector(state=state) return collector.collect_logs(hours=hours) def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list: collector = get_log_collector(state=state) return collector.collect_logs(hours=hours) 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.""" machine_id = get_machine_identifier() os_type = "windows" if sys.platform == "win32" else "linux" now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") try: conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) if c.fetchone(): c.execute(""" UPDATE clients SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? WHERE client_id = ? """, (machine_id, os_type, now_sql, machine_id)) else: c.execute(""" INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) """, (machine_id, machine_id, os_type, now_sql, now_sql)) conn.commit() conn.close() except Exception as e: print(f"[!] Warning: Failed to update local hub client record: {e}") logs = get_local_system_logs(hours=window_hours, state=state) if not logs: return 0 payload = { "server": machine_id, "site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub", "timestamp": datetime.now(timezone.utc).isoformat(), "logs": logs } res = process_ingested_logs(payload, db_path=db_path, window_hours=window_hours, min_runs=min_runs) print(f"[+] Integrated Local Collector: Processed {len(logs)} local host events ({res.get('promoted_verified', 0)} verified anomalies).") return len(logs) async def local_collector_loop(interval_seconds: int = 30): """Background worker that periodically ingests local host logs without requiring a client license.""" config = SERVER_STATE.get("config", {}) db_path = config.get("db_path", DEFAULT_DB_FILE) window_hours = config.get("evaluation_window_hours", 12) min_runs = config.get("min_persistence_runs", 1) state_file = resolve_writable_path("local_collector_state.json", "LOGAR-Server", "local_collector_state.json") state = {} if os.path.exists(state_file): try: with open(state_file, "r", encoding="utf-8") as f: state = json.load(f) except Exception: state = {} try: 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: json.dump(state, f) except Exception as e: print(f"[!] Error in initial local log collection cycle: {e}") while True: try: await asyncio.sleep(interval_seconds) 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: json.dump(state, f) except asyncio.CancelledError: break except Exception as e: print(f"[!] Exception in local_collector_loop: {e}") async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30): """Periodically checks the validity of Hub Root CA and Server TLS certificates and reports seat quotas.""" config = SERVER_STATE.get("config", {}) cert_dir = config.get("cert_dir", "certs") hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] while True: try: await asyncio.sleep(interval_seconds) check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) report_seat_quota_to_authority_sync() except asyncio.CancelledError: break except Exception as e: print(f"[!] Exception in cert_validity_watchdog: {e}") async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): """mTLS TCP socket handler with certificate validation, decryption, and buffering.""" client_id = None ssl_obj = writer.get_extra_info("ssl_object") if ssl_obj: peercert = ssl_obj.getpeercert() if peercert and "subject" in peercert: for rdn in peercert["subject"]: for key, val in rdn: if key == "commonName": client_id = val break if SERVER_STATE.get("tls_enabled", False): if not client_id: writer.close() await writer.wait_closed() return db_path = SERVER_STATE["config"]["db_path"] conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,)) row = c.fetchone() if not row or row[0] != "active": conn.close() writer.close() await writer.wait_closed() return now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") c.execute("UPDATE clients SET last_seen = ? WHERE client_id = ?", (now_sql, client_id)) conn.commit() conn.close() try: length_bytes = await reader.readexactly(4) length = struct.unpack(">I", length_bytes)[0] if length <= 0 or length > 10 * 1024 * 1024: raise ValueError(f"Invalid frame size: {length}") payload_bytes = await reader.readexactly(length) raw_payload = json.loads(payload_bytes.decode("utf-8")) if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"): pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"]) priv_key = SERVER_STATE["private_key_obj"] decrypted_obj = priv_key.decrypt(pgp_msg) log_payload = json.loads(decrypted_obj.message) else: log_payload = raw_payload if client_id and "server" not in log_payload: log_payload["server"] = client_id cache_mode = SERVER_STATE.get("config", {}).get("cache_mode", "direct") if cache_mode == "buffered": INGEST_QUEUE.push(log_payload) if len(INGEST_QUEUE._buffer) >= INGEST_QUEUE.batch_size: res = INGEST_QUEUE.flush( db_path=SERVER_STATE["config"]["db_path"], window_hours=SERVER_STATE["config"]["evaluation_window_hours"], min_runs=SERVER_STATE["config"]["min_persistence_runs"], process_callback=process_ingested_logs ) else: res = { "status": "success", "buffered": True, "processed": len(log_payload.get("logs", [])), "queue_size": len(INGEST_QUEUE._buffer) } else: res = process_ingested_logs( log_payload, db_path=SERVER_STATE["config"]["db_path"], window_hours=SERVER_STATE["config"]["evaluation_window_hours"], min_runs=SERVER_STATE["config"]["min_persistence_runs"] ) with INGEST_QUEUE._lock: INGEST_QUEUE._total_ingested += len(log_payload.get("logs", [])) INGEST_QUEUE._total_flushed += len(log_payload.get("logs", [])) resp_bytes = json.dumps(res).encode("utf-8") writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes) await writer.drain() except Exception as e: err = json.dumps({"status": "error", "message": str(e)}).encode("utf-8") try: writer.write(struct.pack(">I", len(err)) + err) await writer.drain() except Exception: pass finally: writer.close() try: await writer.wait_closed() except Exception: pass # ===================================================================== # FastAPI Route Handlers # ===================================================================== @app.get("/login", response_class=HTMLResponse) def login_page(request: Request): """Renders the dark mode login page for the aggregation server.""" if get_current_user_optional(request): return RedirectResponse(url="/", status_code=303) return HTMLResponse(content=WebUIRenderer.render_login_html()) @app.post("/login") async def process_login(request: Request): """Processes login credentials and issues session cookie with 2FA TOTP enforcement.""" client_ip = get_client_ip(request) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not check_ip_rate_limit(client_ip, max_attempts=10, window_seconds=60, scope="login", db_path=db_path): raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.") username, password, totp_code = None, None, None content_type = request.headers.get("content-type", "") if "application/json" in content_type: try: body = await request.json() username = body.get("username") password = body.get("password") totp_code = body.get("totp_code") except Exception: pass else: form = await request.form() username = form.get("username") password = form.get("password") totp_code = form.get("totp_code") if not username or not password: raise HTTPException(status_code=400, detail="Username and password are required") if not verify_user_credentials(username, password, db_path=db_path): record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) await asyncio.sleep(0.5) raise HTTPException(status_code=401, detail="Invalid username or password") # Check 2FA TOTP requirement totp_info = get_user_totp_info(username, db_path=db_path) if totp_info.get("enabled"): if not totp_code: return JSONResponse(status_code=200, content={ "status": "2fa_required", "requires_2fa": True, "message": "Two-factor authentication code required" }) if not verify_totp_code(totp_info["secret"], totp_code): record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) await asyncio.sleep(0.3) raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code") reset_failed_ip_attempts(client_ip, scope="login", db_path=db_path) token = generate_session_token(username, db_path=db_path) is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https" response = JSONResponse(content={"status": "success", "username": username, "token": token}) response.set_cookie( key=SESSION_COOKIE_NAME, value=token, httponly=True, secure=is_https, max_age=86400, samesite="lax" ) return response @app.get("/logout") def process_logout(): """Logs out and redirects to /login.""" resp = RedirectResponse(url="/login", status_code=303) resp.delete_cookie(SESSION_COOKIE_NAME) return resp @app.post("/api/auth/change-password") async def api_change_password(req: ChangePasswordRequest, current_user: str = Depends(require_admin_auth)): """Allows authenticated administrator to change password.""" db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not verify_user_credentials(current_user, req.old_password, db_path=db_path): raise HTTPException(status_code=403, detail="Current password incorrect") if len(req.new_password) < 6: raise HTTPException(status_code=400, detail="New password must be at least 6 characters") change_user_password(current_user, req.new_password, db_path=db_path) return {"status": "success", "message": "Password updated successfully"} @app.get("/api/user/2fa/status") def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)): """Returns 2FA status for currently logged in administrator.""" db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) info = get_user_totp_info(auth_user, db_path=db_path) return {"enabled": info["enabled"], "username": auth_user} @app.get("/api/user/2fa/setup") def api_user_2fa_setup(auth_user: str = Depends(require_admin_auth)): """Generates a new TOTP secret and otpauth URI for 2FA provisioning.""" secret = generate_totp_secret() otpauth = get_totp_uri(auth_user, secret, issuer="LOGAR Server Hub") return {"secret": secret, "otpauth_uri": otpauth, "username": auth_user} @app.post("/api/user/2fa/enable") async def api_user_2fa_enable(request: Request, auth_user: str = Depends(require_admin_auth)): """Enables TOTP for the current user after validating 6-digit code.""" body = await request.json() secret = body.get("secret", "").strip() code = body.get("code", "").strip() if not secret or not code: raise HTTPException(status_code=400, detail="Secret and 6-digit verification code required") if not verify_totp_code(secret, code): raise HTTPException(status_code=400, detail="Invalid 6-digit TOTP verification code. Please check your device clock.") db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) enable_user_totp(auth_user, secret, db_path=db_path) return {"status": "success", "message": "2FA successfully activated"} @app.post("/api/user/2fa/disable") async def api_user_2fa_disable(request: Request, auth_user: str = Depends(require_admin_auth)): """Disables TOTP for the current user after verifying password.""" body = await request.json() password = body.get("password", "") db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not verify_user_credentials(auth_user, password, db_path=db_path): raise HTTPException(status_code=403, detail="Current password incorrect") disable_user_totp(auth_user, db_path=db_path) return {"status": "success", "message": "2FA successfully disabled"} @app.post("/api/client/enroll") def enroll_client(req: ClientEnrollRequest, request: Request = None): """Enrolls an edge client by validating the enrollment secret and issuing a signed certificate.""" client_ip = get_client_ip(request) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not check_ip_rate_limit(client_ip, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path): raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.") conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1") row = c.fetchone() if not row: conn.close() raise HTTPException(status_code=500, detail="License configuration not initialized") expected_secret = row[0] lic_summary = LicenseManager.get_license_summary(SERVER_STATE, db_path=db_path) max_seats = lic_summary.get("max_seats", 0) license_status = lic_summary.get("status", "UNLICENSED").lower() if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"): conn.close() raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.") if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): conn.close() record_failed_ip_attempt(client_ip, scope="enroll", db_path=db_path) time.sleep(0.5) raise HTTPException(status_code=403, detail="Invalid enrollment secret") reset_failed_ip_attempts(client_ip, scope="enroll", db_path=db_path) ca_cert = SERVER_STATE.get("ca_cert") ca_key = SERVER_STATE.get("ca_key") ca_cert_pem = SERVER_STATE.get("ca_cert_pem") if not ca_cert or not ca_key: conn.close() raise HTTPException(status_code=500, detail="Root CA not loaded on server") cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") if os.path.exists(upstream_ca_path): try: with open(upstream_ca_path, "r", encoding="utf-8") as f: upstream_pem = f.read().strip() if upstream_pem and upstream_pem not in ca_cert_pem: ca_cert_pem = ca_cert_pem.strip() + "\n" + upstream_pem except Exception: pass c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) existing = c.fetchone() if existing: if existing[0] == "revoked": conn.close() raise HTTPException(status_code=403, detail="Client seat revoked by administrator.") else: c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") active_count = c.fetchone()[0] if active_count >= max_seats: conn.close() raise HTTPException(status_code=403, detail=f"License seat limit reached ({active_count}/{max_seats} seats used).") cert_pem, key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key) fp = enrollment.calculate_cert_fingerprint(cert_pem) now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") client_ver = req.version or "" if existing: try: c.execute(""" UPDATE clients SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? WHERE client_id = ? """, (req.hostname, req.os, fp, now_sql, client_ver, req.client_id)) except sqlite3.OperationalError: c.execute(""" UPDATE clients SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? WHERE client_id = ? """, (req.hostname, req.os, fp, now_sql, req.client_id)) else: try: c.execute(""" INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) except sqlite3.OperationalError: c.execute(""" INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) VALUES (?, ?, ?, ?, 'active', ?, ?) """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) conn.commit() conn.close() trigger_async_seat_report() return { "status": "success", "client_id": req.client_id, "client_cert": cert_pem, "client_key": key_pem, "ca_cert": ca_cert_pem, "server_fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), "server_public_key": SERVER_STATE.get("config", {}).get("public_key", "") } @app.get("/api/client/export-profile") def export_client_profile( server_host: Optional[str] = None, server_port: Optional[int] = None, client_id: Optional[str] = None, request: Request = None ): """Exports standalone client profile JSON for instant client deployment.""" rotate_enrollment_secret() cfg = SERVER_STATE.get("config", {}) host = server_host if not host: host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or cfg.get("tcp_host", "127.0.0.1") if host in ("0.0.0.0", "localhost", ""): host = socket.gethostname() port = server_port or cfg.get("tcp_port", 9443) profile = generate_client_profile_dict(server_host=host, server_port=port, client_id=client_id, config_dict=cfg) return JSONResponse( content=profile, headers={"Content-Disposition": f'attachment; filename="logar_client_{host}_{port}.json"'} ) @app.post("/api/server/reset-enrollment-secret") def reset_server_enrollment_secret(auth_user: str = Depends(require_admin_auth)): """Rotates and generates a fresh server enrollment secret on demand.""" new_secret = rotate_enrollment_secret() return { "status": "success", "message": "Server enrollment secret successfully rotated and updated.", "enrollment_secret": new_secret } # ===================================================================== # Update & Distribution Endpoints # ===================================================================== @app.get("/api/update/status") @app.get("/api/server/update/status") def get_update_status(user: str = Depends(get_current_user_optional)): """Returns current server update and client package distribution repository status.""" mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER return mgr.get_status() @app.post("/api/update/check") @app.post("/api/server/update/check") def check_server_updates(user: str = Depends(get_current_user_optional)): """Triggers an immediate upstream release check.""" mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER return mgr.check_upstream_updates(force=True) @app.get("/api/client/update/check") def check_client_update( client_id: str, os: Optional[str] = "windows", os_name: Optional[str] = None, current_version: Optional[str] = None, arch: str = "x86_64" ): """ 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" mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER # Record check timestamp in database db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if target_os: now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") try: conn = sqlite3.connect(db_path) conn.execute(""" UPDATE clients SET last_update_check = ?, client_version = COALESCE(NULLIF(?, ''), client_version) WHERE client_id = ? """, (now_sql, current_version or "", client_id)) conn.commit() conn.close() except Exception: pass return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) @app.get("/api/client/update/download") def download_client_update_package( request: Request, os: Optional[str] = "windows", os_name: Optional[str] = None ): """ Streams the latest client binary or installer package for the specified OS. Supports HTTP ETag and conditional 304 Not Modified requests to minimize network bandwidth. """ import os as _os import email.utils target_os = os_name or os or "windows" mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER pkg_tuple = mgr.get_client_package_path(os_type=target_os) if not pkg_tuple: raise HTTPException(status_code=404, detail=f"No client update package available for OS: {target_os}") file_path, filename, sha256 = pkg_tuple if not _os.path.exists(file_path): raise HTTPException(status_code=404, detail="Client package file missing on server disk.") etag = f'"{sha256}"' if request is not None: if_none_match = request.headers.get("if-none-match") if if_none_match and (if_none_match == etag or if_none_match.strip('"') == sha256): return Response( status_code=304, headers={ "ETag": etag, "Cache-Control": "public, max-age=300, must-revalidate", "X-Checksum-SHA256": sha256 } ) try: mtime = _os.path.getmtime(file_path) last_modified = email.utils.formatdate(mtime, usegmt=True) except Exception: last_modified = None if_modified_since = request.headers.get("if-modified-since") if if_modified_since and last_modified and if_modified_since == last_modified: return Response( status_code=304, headers={ "ETag": etag, "Cache-Control": "public, max-age=300, must-revalidate", "X-Checksum-SHA256": sha256 } ) headers = { "ETag": etag, "Cache-Control": "public, max-age=300, must-revalidate", "X-Checksum-SHA256": sha256, "Content-Disposition": f'attachment; filename="{filename}"' } if last_modified: headers["Last-Modified"] = last_modified return FileResponse( path=file_path, filename=filename, media_type="application/octet-stream", headers=headers ) @app.post("/api/server/client-package/upload") async def upload_client_package( request: Request, auth_user: str = Depends(require_admin_auth) ): """ Uploads a new client executable/installer into the server's update distribution repository. Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." } or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'. """ import base64 import os as _os mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER content_type = request.headers.get("content-type", "") os_type = "windows" version = None filename = "client_update.bin" file_bytes = b"" if "application/json" in content_type: body = await request.json() os_type = body.get("os", "windows") version = body.get("version") raw_name = body.get("filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") filename = _os.path.basename(str(raw_name)) b64_data = body.get("data_base64", "") file_bytes = base64.b64decode(b64_data) else: os_type = request.headers.get("X-Package-OS", "windows") version = request.headers.get("X-Package-Version") raw_name = request.headers.get("X-Package-Filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") filename = _os.path.basename(str(raw_name)) file_bytes = await request.body() if not file_bytes: raise HTTPException(status_code=400, detail="Empty package payload.") temp_dir = _os.path.join(mgr.update_dir, "temp") _os.makedirs(temp_dir, exist_ok=True) temp_path = _os.path.join(temp_dir, filename) with open(temp_path, "wb") as f: f.write(file_bytes) try: manifest = mgr.register_client_package( os_type=os_type, file_path=temp_path, version=version, filename=filename ) if os.path.exists(temp_path): try: os.remove(temp_path) except Exception: pass return {"status": "success", "manifest": manifest, "sha256": manifest.get("sha256")} except Exception as e: if os.path.exists(temp_path): try: os.remove(temp_path) except Exception: pass raise HTTPException(status_code=400, detail=f"Package upload registration failed: {e}") @app.get("/api/clients") def list_clients(auth_user: str = Depends(require_admin_auth)): """Returns all registered clients, seat usage, and activity timestamps.""" db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT max_seats FROM license_config WHERE id = 1") lic_row = c.fetchone() max_seats = lic_row[0] if lic_row else 10 c.execute(""" SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, metrics_json, cpu_percent, memory_percent, disk_percent_free FROM clients ORDER BY last_seen DESC """) rows = c.fetchall() conn.close() clients = [] for r in rows: cid, host, ost, fp, st, first, last, mjson, cpu, mem, disk = r m_dict = {} if mjson: try: m_dict = json.loads(mjson) except Exception: pass clients.append({ "client_id": cid, "hostname": host, "os_type": ost, "cert_fingerprint": fp, "status": st, "first_seen": first, "last_seen": last, "last_activity_relative": format_relative_time(last), "metrics": m_dict, "cpu_percent": cpu or 0.0, "memory_percent": mem or 0.0, "disk_percent_free": disk or 0.0 }) active_count = sum(1 for cl in clients if cl["status"] == "active" and cl.get("cert_fingerprint") != "LOCAL-HOST-BUILTIN") return { "active_seats": active_count, "max_seats": max_seats, "clients": clients } @app.post("/api/client/approve") @app.post("/api/client/reactivate") def approve_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): """Approves / reactivates a client certificate if within seat quota.""" db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT max_seats FROM license_config WHERE id = 1") row = c.fetchone() max_seats = row[0] if row else 10 c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") active_count = c.fetchone()[0] if active_count >= max_seats: conn.close() raise HTTPException(status_code=403, detail=f"Cannot activate seat: maximum quota ({max_seats}) reached.") c.execute("UPDATE clients SET status = 'active' WHERE client_id = ?", (action.client_id,)) affected = c.rowcount conn.commit() conn.close() if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[+] Approved/Reactivated client seat: {action.client_id}") trigger_async_seat_report() return {"status": "active", "client_id": action.client_id} reactivate_client = approve_client @app.post("/api/client/revoke") def revoke_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): """Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes.""" if action.client_id == get_machine_identifier(): raise HTTPException(status_code=400, detail="Cannot revoke integrated local hub collector") db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("UPDATE clients SET status = 'revoked' WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) affected = c.rowcount conn.commit() conn.close() if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[!] Revoked client license seat: {action.client_id}") trigger_async_seat_report() return {"status": "revoked", "client_id": action.client_id} @app.post("/api/client/delete") @app.post("/api/clients/delete") @app.post("/api/clients/remove") def delete_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): """Permanently removes a client forwarder record from SQLite, immediately freeing its seat.""" if action.client_id == get_machine_identifier(): raise HTTPException(status_code=400, detail="Cannot remove integrated local hub collector") db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("DELETE FROM clients WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) affected = c.rowcount conn.commit() conn.close() if affected == 0: raise HTTPException(status_code=404, detail="Client ID not found") print(f"[+] Removed client forwarder record: {action.client_id}") trigger_async_seat_report() return {"status": "deleted", "client_id": action.client_id} @app.get("/api/hermes/tokens") def api_list_hermes_tokens(auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) return list_hermes_tokens(db_path=db_path) @app.post("/api/hermes/tokens/create") def api_create_hermes_token(req: HermesTokenCreateRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not req.name or not req.name.strip(): raise HTTPException(status_code=400, detail="Token name is required") return create_hermes_token(req.name.strip(), db_path=db_path) @app.post("/api/hermes/tokens/revoke") def api_revoke_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not revoke_hermes_token(req.token, db_path=db_path): raise HTTPException(status_code=404, detail="Token not found") return {"status": "success", "message": "Token revoked"} @app.post("/api/hermes/tokens/delete") def api_delete_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not delete_hermes_token(req.token, db_path=db_path): raise HTTPException(status_code=404, detail="Token not found") return {"status": "success", "message": "Token deleted"} @app.get("/api/hermes/report") def get_verified_anomalies_for_hermes(token_ctx: str = Depends(require_hermes_or_admin_auth)): """ Ingestion endpoint for Hermes agentic workflows. Returns verified anomalies within the active 12-hour evaluation window, system resource context, and silent/offline forwarder alerts for Hermes AI reasoning. """ db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(""" SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC """) rows = cursor.fetchall() now = datetime.now(timezone.utc) try: cursor.execute("SELECT client_id, hostname, os_type, last_seen, metrics_json, cpu_percent, memory_percent, disk_percent_free FROM clients WHERE status = 'active'") client_rows = cursor.fetchall() except Exception: client_rows = [] conn.close() window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12) report = [] client_metrics_map = {} for cl in client_rows: cid, chost, cost, clast, cmetrics_raw, ccpu, cmem, cdisk = cl try: m_dict = json.loads(cmetrics_raw) if cmetrics_raw else {} except Exception: m_dict = {} if not m_dict: m_dict = { "cpu_percent": ccpu or 0.0, "memory_percent": cmem or 0.0, "disk_percent_free": cdisk or 0.0 } client_metrics_map[cid] = m_dict if chost: client_metrics_map[chost] = m_dict if clast: try: clast_clean = str(clast).replace("T", " ").split("+")[0].split("Z")[0].strip() last_dt = datetime.fromisoformat(clast_clean).replace(tzinfo=timezone.utc) if (now - last_dt) > timedelta(minutes=15): offline_mins = int((now - last_dt).total_seconds() // 60) report.append({ "fingerprint": f"offline-node-{cid}", "site": "default", "server": chost or cid, "signature": "LOGAR/NodeSilent", "severity": "CRITICAL", "message": f"Forwarder node '{chost or cid}' has gone silent! Last heartbeat was {offline_mins} minutes ago ({clast}). Potential node crash, network partition, or power failure.", "os_type": cost or "unknown", "first_seen": clast, "last_seen": clast, "consecutive_runs": 1, "evaluation_window": f"{window_hours}h", "verified": True, "status": "NODE_OFFLINE", "metrics": m_dict }) except Exception: pass for r in rows: srv_name = r[2] report.append({ "fingerprint": r[0], "site": r[1], "server": srv_name, "signature": r[3], "severity": r[4], "message": decrypt_field_from_storage(r[5]), "os_type": r[6], "first_seen": r[7], "last_seen": r[8], "consecutive_runs": r[9], "evaluation_window": f"{window_hours}h", "verified": True, "status": r[10], "metrics": client_metrics_map.get(srv_name, {}) }) return report @app.get("/api/issues") def get_all_issues(auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) c = conn.cursor() c.execute("SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues ORDER BY last_seen DESC") issues = [ { "fingerprint": r[0], "site_name": r[1], "server": r[2], "signature": r[3], "severity": r[4], "message": decrypt_field_from_storage(r[5]), "os_type": r[6], "first_seen": r[7], "last_seen": r[8], "run_count": r[9], "status": r[10] } for r in c.fetchall() ] conn.close() return issues @app.get("/api/license/status") def api_license_status(): return get_license_summary() @app.get("/api/license/export-lic") def export_lic_file(auth_user: str = Depends(require_admin_auth)): config = SERVER_STATE.get("config", {}) lic_dict, lic_json = export_license_request_lic(config) hostname = socket.gethostname() return Response( content=lic_json, media_type="application/json", headers={"Content-Disposition": f'attachment; filename="logar_license_request_{hostname}.lic"'} ) @app.get("/api/license/ca-csr") def get_ca_csr(auth_user: str = Depends(require_admin_auth)): cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") csr_path = os.path.join(cert_dir, "ca.csr") if not os.path.exists(csr_path): enrollment.generate_ca_csr(cert_dir=cert_dir) with open(csr_path, "r", encoding="utf-8") as f: csr_content = f.read() return Response( content=csr_content, media_type="application/x-pem-file", headers={"Content-Disposition": 'attachment; filename="ca.csr"'} ) @app.post("/api/license/import") def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(require_admin_auth)): cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") ca_file = os.path.join(cert_dir, "ca.crt") upstream_file = os.path.join(cert_dir, "upstream_ca.crt") upstream_pem = req.upstream_ca.strip() if req.upstream_ca else None if not upstream_pem and os.path.exists(upstream_file): try: with open(upstream_file, "r", encoding="utf-8") as f: upstream_pem = f.read().strip() except Exception: upstream_pem = None if upstream_pem: if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem): raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.") try: info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem) except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}") with open(ca_file, "w", encoding="utf-8") as f: f.write(req.ca_cert.strip() + "\n") if req.upstream_ca: with open(upstream_file, "w", encoding="utf-8") as f: f.write(req.upstream_ca.strip() + "\n") new_seats = info.get("seats") license_id = info.get("license_id") expires_at = info.get("not_valid_after") issued_at = info.get("not_valid_before") features = info.get("features", []) config = SERVER_STATE.get("config", {}) if new_seats: config["max_seats"] = new_seats if license_id: config["license_id"] = license_id db_path = config.get("db_path", DEFAULT_DB_FILE) 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 or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) conn.commit() conn.close() 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}") trigger_async_seat_report() return { "status": "success", "message": "Signed Subordinate CA certificate successfully activated!", "license_id": license_id, "seats": new_seats, "expires_at": expires_at, "issued_at": issued_at, "features": features } @app.get("/api/db/stats") def api_db_stats(auth_user: str = Depends(require_admin_auth)): return { "status": "success", "queue": INGEST_QUEUE.get_stats(), "queue_stats": INGEST_QUEUE.get_stats(), "storage": get_storage_engine_info(), "storage_info": get_storage_engine_info(), "enterprise_db_licensed": is_feature_licensed("enterprise_db"), "license_summary": get_license_summary() } @app.post("/api/db/flush-buffer") def api_db_flush_buffer(auth_user: str = Depends(require_admin_auth)): config = SERVER_STATE.get("config", {}) db_path = config.get("db_path", DEFAULT_DB_FILE) window_hours = config.get("evaluation_window_hours", 12) min_runs = config.get("min_persistence_runs", 1) res = INGEST_QUEUE.flush( db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs ) return { "status": "success", "message": f"Successfully flushed {res.get('processed_logs', 0)} events across {res.get('flushed_runs', 0)} batch runs ({res.get('flush_latency_ms', 0)}ms).", "details": res } @app.post("/api/db/test-connection") def api_db_test_connection(req: DbConnectionTestRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if not is_feature_licensed("enterprise_db", db_path): raise HTTPException(status_code=403, detail="Enterprise database support requires an 'enterprise_db' license capability.") ok, msg = init_postgres_db(req.dict()) if not ok: raise HTTPException(status_code=400, detail=f"Database connection failed: {msg}") return {"status": "success", "message": msg} @app.post("/api/db/switch-backend") def api_db_switch_backend(req: DbSwitchBackendRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) config = SERVER_STATE.get("config", {}) if req.db_type.lower() in ("postgres", "postgresql"): if not is_feature_licensed("enterprise_db", db_path): raise HTTPException(status_code=403, detail="PostgreSQL enterprise storage requires an 'enterprise_db' license capability.") pg_conf = { "host": req.host, "port": req.port, "database": req.database, "user": req.user, "password": req.password, "sslmode": req.sslmode, "connect_timeout": req.connect_timeout, "pool_size": req.pool_size } ok, msg = init_postgres_db(pg_conf) if not ok: raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}") config["storage_engine"] = "postgres" config["db_type"] = "postgres" config["postgres"] = pg_conf else: config["storage_engine"] = "sqlite" config["db_type"] = "sqlite" 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 on backend switch: {e}") return { "status": "success", "message": f"Storage backend switched to {config['storage_engine']}.", "storage_info": get_storage_engine_info() } @app.post("/api/db/update-config") def api_db_update_config(req: DbFullConfigRequest, auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) config = SERVER_STATE.get("config", {}) is_licensed = is_feature_licensed("enterprise_db", db_path) if (req.storage_engine.lower() in ("postgres", "postgresql") or req.cache_mode == "buffered" or req.encrypt_at_rest) and not is_licensed: raise HTTPException( status_code=403, detail="Advanced database features (PostgreSQL, in-memory buffering, encryption at rest) require an 'enterprise_db' license capability." ) if req.storage_engine.lower() in ("postgres", "postgresql"): if req.pg_config: pg_conf = req.pg_config.dict() ok, msg = init_postgres_db(pg_conf) if not ok: raise HTTPException(status_code=400, detail=f"PostgreSQL initialization failed: {msg}") config["postgres"] = pg_conf config["storage_engine"] = "postgres" config["db_type"] = "postgres" else: config["storage_engine"] = "sqlite" config["db_type"] = "sqlite" config["cache_mode"] = req.cache_mode config["encrypt_at_rest"] = req.encrypt_at_rest config["buffer_flush_interval_sec"] = req.flush_interval config["buffer_batch_size"] = req.batch_size config["buffer_max_queue"] = req.max_queue INGEST_QUEUE.flush_interval = req.flush_interval INGEST_QUEUE.batch_size = req.batch_size INGEST_QUEUE.max_queue = req.max_queue 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}") return { "status": "success", "message": "Storage & Ingestion parameters updated successfully.", "storage_info": get_storage_engine_info() } @app.post("/api/db/migrate-to-postgres") def api_db_migrate_to_postgres(auth_user: str = Depends(require_admin_auth)): db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) config = SERVER_STATE.get("config", {}) if not is_feature_licensed("enterprise_db", db_path): raise HTTPException(status_code=403, detail="Database migration requires an 'enterprise_db' license capability.") pg_config = config.get("postgres") if not pg_config: raise HTTPException(status_code=400, detail="PostgreSQL configuration not found. Please configure PostgreSQL parameters first.") ok, msg, counts = migrate_sqlite_to_postgres(db_path, pg_config) if not ok: raise HTTPException(status_code=500, detail=f"Migration failed: {msg}") return { "status": "success", "message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.", "counts": counts } @app.get("/", response_class=HTMLResponse) @app.get("/webui", response_class=HTMLResponse) def serve_webui(request: Request = None): """Renders the comprehensive LOGAR Server Hub & Fleet Licensing WebUI.""" auth_user = None if request is not None: auth_user = get_current_user_optional(request) if not auth_user: return RedirectResponse(url="/login", status_code=303) else: auth_user = DEFAULT_ADMIN_USER html_content = WebUIRenderer.render_dashboard_html( auth_user=auth_user, request=request, server_state=SERVER_STATE, ingest_queue=INGEST_QUEUE ) return HTMLResponse(content=html_content) @app.get("/health") def health_check(): return { "status": "healthy", "server_name": SERVER_STATE.get("config", {}).get("server_name", "LOGAR-Cloud-Hub"), "fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), "tcp_port": SERVER_STATE.get("config", {}).get("tcp_port", 9443), "hermes_port": SERVER_STATE.get("config", {}).get("hermes_port", 8443), "tls_enabled": SERVER_STATE.get("tls_enabled", False) } async def run_server(): """Runs the mTLS TCP socket listener and the Hermes REST API concurrently.""" config = SERVER_STATE["config"] tcp_host = config["tcp_host"] tcp_port = int(config["tcp_port"]) hermes_host = config["hermes_host"] hermes_port = int(config["hermes_port"]) ssl_ctx = SERVER_STATE.get("ssl_ctx") tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx) mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP" print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}") uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning") uv_server = uvicorn.Server(uv_config) print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report") print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll") watchdog_task = asyncio.create_task(cert_validity_watchdog()) collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30)) buffer_task = asyncio.create_task(ingest_buffer_worker_loop()) try: await asyncio.gather( tcp_server.serve_forever(), uv_server.serve(), watchdog_task, collector_task, buffer_task ) finally: watchdog_task.cancel() collector_task.cancel() buffer_task.cancel() for t in [watchdog_task, collector_task, buffer_task]: try: await t except asyncio.CancelledError: pass try: db_p = config.get("db_path", DEFAULT_DB_FILE) INGEST_QUEUE.flush(db_path=db_p, process_callback=process_ingested_logs) except Exception: pass def main(): parser = argparse.ArgumentParser(description="LOGAR Cloud Hub & TCP Socket Ingestion Server") parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to server_config.json") parser.add_argument("--create-client-config", action="store_true", help="Generate a client config with encryption-only fingerprint and server address") parser.add_argument("--client-out", default="client_config.json", help="Output file path for generated client config") parser.add_argument("--server-host", default="127.0.0.1", help="Server address to embed in client config") parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config") parser.add_argument("--export-lic", type=str, help="Export a .lic license request file containing CA CSR and coordinates") parser.add_argument("--import-ca", type=str, help="Import and activate a signed CA certificate or activation .lic file") parser.add_argument("--import-lic", type=str, help="Import and activate an activated .lic license file") parser.add_argument("--sync-license", action="store_true", help="Sync license with master authority and check for seat increases via mTLS") args = parser.parse_args() config = load_or_init_config(args.config) config["db_path"] = init_db( config["db_path"], enrollment_secret=config.get("enrollment_secret"), max_seats=config.get("max_seats", 10) ) cert_dir = config.get("cert_dir", "certs") # Standalone CLI license export if args.export_lic: lic_dict, lic_json = export_license_request_lic(config) with open(args.export_lic, "w", encoding="utf-8") as f: f.write(lic_json) print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}") sys.exit(0) # Standalone CLI CA / .lic import lic_file = args.import_lic or args.import_ca if lic_file: try: res = enrollment.import_license_package( lic_file, cert_dir=cert_dir, server_fingerprint=config.get("server_fingerprint", ""), hostname=config.get("server_name", "") ) except Exception as e: print(f"[-] Invalid license file {lic_file}: {e}") sys.exit(1) info = res.get("license_info", {}) new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10) config["max_seats"] = new_seats conn = sqlite3.connect(config["db_path"]) conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, res.get("license_id") or info.get("license_id"))) conn.commit() conn.close() ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) 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 ) print("[+] Successfully imported and activated signed license!") print(f" - Granted Seats: {new_seats}") print(f" - License ID: {res.get('license_id') or info.get('license_id')}") print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}") sys.exit(0) # Standalone CLI license sync / update against master server if args.sync_license: lic_server = config.get("license_server_url", "wss://logar.eibl.tech/ws/license") print(f"[*] Connecting to master authority at {lic_server} via WebSocket to sync license...") ca_info = {} ca_path = os.path.join(cert_dir, "ca.crt") if os.path.exists(ca_path): with open(ca_path, "r", encoding="utf-8") as f: ca_info = enrollment.extract_license_info_from_cert(f.read()) 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(config.get("db_path", "server_logs.db")): try: conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db")) row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone() if row: active_seats = row[0] conn_tmp.close() except Exception: pass success, msg, res = enrollment.enroll_and_sync_license_ws( cert_dir=cert_dir, license_id=ca_info.get("license_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: print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...") 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: print(f"[-] License sync failed: {msg}") sys.exit(1) info = enrollment.extract_license_info_from_cert(new_pem) new_seats = info.get("seats") or config.get("max_seats", 10) else: info = res.get("license_info", {}) new_seats = res.get("seats", config.get("max_seats", 10)) config["max_seats"] = new_seats conn = sqlite3.connect(config["db_path"]) conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id"))) conn.commit() conn.close() ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) 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 ) print("[+] License successfully synchronized with master server!") print(f" - Active Seat Quota: {new_seats}") print(f" - License ID: {info.get('license_id')}") print(f" - Expires: {info.get('not_valid_after')}") sys.exit(0) # Initialize dynamic PKI ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir) ca_info = enrollment.extract_license_info_from_cert(ca_pem) if ca_info.get("seats"): config["max_seats"] = ca_info["seats"] conn = sqlite3.connect(config["db_path"]) conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (ca_info["seats"],)) conn.commit() conn.close() csr_path = os.path.join(cert_dir, "ca.csr") if not os.path.exists(csr_path): enrollment.generate_ca_csr(cert_dir=cert_dir) srv_cert, srv_key, srv_pem, srv_key_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 ) ssl_ctx = None if config.get("tls_enabled", True): ssl_ctx = init_mtls_server_context(cert_dir=cert_dir) priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"]) SERVER_STATE["config"] = config SERVER_STATE["private_key_obj"] = priv_key_obj SERVER_STATE["ca_cert"] = ca_cert SERVER_STATE["ca_key"] = ca_key SERVER_STATE["ca_cert_pem"] = ca_pem SERVER_STATE["ssl_ctx"] = ssl_ctx SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True) if args.create_client_config: port = args.server_port or config["tcp_port"] create_client_config( server_host=args.server_host, server_port=port, output_path=args.client_out, config_path=args.config ) sys.exit(0) hermes_host = config.get("hermes_host", "0.0.0.0") hermes_port = config.get("hermes_port", 8443) display_host = "localhost" if hermes_host in ("0.0.0.0", "127.0.0.1") else hermes_host print("=" * 64) print(f" LOGAR Server Hub: {config.get('server_name', 'LOGAR Enterprise Hub')}") print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}") print(f" License Quota: {config.get('max_seats', 10)} Active Seats ({ca_info.get('license_id') or 'Local Sub-CA'})") print(f" Upstream Authority: {config.get('license_server_url', 'https://logar.eibl.tech')}") print(f" Server Encryption Fingerprint: {config.get('server_fingerprint', 'N/A')}") print(f" WebUI & Fleet Dashboard: http://{display_host}:{hermes_port}/webui") print(f" Hermes Reporting API: http://{display_host}:{hermes_port}/api/hermes/report") print("=" * 64) try: asyncio.run(run_server()) except KeyboardInterrupt: print("\n[!] Server shutting down.") if __name__ == "__main__": main()