From f2a9e43619500dbc7a7701e37c44b3342a77d680 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 2 Oct 2026 21:57:29 +0200 Subject: [PATCH] Refactor Server.py into modular domain classes (models, auth, storage, queue, licensing, PKI, UI) --- src/Server.py | 3940 +++++----------------------------------- src/auth_manager.py | 398 ++++ src/ingest_queue.py | 114 ++ src/license_manager.py | 225 +++ src/pki_manager.py | 141 ++ src/server_models.py | 76 + src/storage_engine.py | 638 +++++++ src/web_ui.py | 1680 +++++++++++++++++ 8 files changed, 3751 insertions(+), 3461 deletions(-) create mode 100644 src/auth_manager.py create mode 100644 src/ingest_queue.py create mode 100644 src/license_manager.py create mode 100644 src/pki_manager.py create mode 100644 src/server_models.py create mode 100644 src/storage_engine.py create mode 100644 src/web_ui.py diff --git a/src/Server.py b/src/Server.py index 7bd51de..503d81e 100644 --- a/src/Server.py +++ b/src/Server.py @@ -1,19 +1,27 @@ +""" +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 hashlib -import hmac -import base64 -import urllib.parse -import time -import html import warnings import threading from datetime import datetime, timezone, timedelta @@ -31,25 +39,11 @@ if REPO_ROOT not in sys.path: warnings.filterwarnings("ignore") import pgpy -from pgpy.constants import ( - PubKeyAlgorithm, - KeyFlags, - HashAlgorithm, - SymmetricKeyAlgorithm, - CompressionAlgorithm -) import ssl -import subprocess -from pydantic import BaseModel from fastapi import FastAPI, HTTPException, Request, Depends from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse import uvicorn -try: - import win32evtlog -except ImportError: - win32evtlog = None - try: from src import server_enrollment as enrollment except ImportError: @@ -72,233 +66,66 @@ except ImportError: is_routine_service_noise ) +# Import specialized modular components +from src.server_models import ( + ClientEnrollRequest, + ClientActionRequest, + LicenseImportRequest, + HermesTokenCreateRequest, + HermesTokenRevokeRequest, + ClientProfileExportRequest, + ChangePasswordRequest, + DbConnectionTestRequest, + DbSwitchBackendRequest, + DbFullConfigRequest +) +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 = "logar_server_session" -_CACHED_SESSION_SECRETS: Dict[str, str] = {} - - -def get_session_secret(db_path: Optional[str] = None) -> str: - """Retrieves or persists a cryptographically secure session secret in the database.""" - env_sec = os.environ.get("LOGAR_SESSION_SECRET") - if env_sec: - return env_sec - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - abs_db = os.path.abspath(target_db) - if abs_db in _CACHED_SESSION_SECRETS: - return _CACHED_SESSION_SECRETS[abs_db] - try: - conn = sqlite3.connect(target_db, timeout=5.0) - c = conn.cursor() - c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") - c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'") - row = c.fetchone() - if row and row[0]: - _CACHED_SESSION_SECRETS[abs_db] = row[0] - conn.close() - return row[0] - new_secret = secrets.token_hex(32) - c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,)) - conn.commit() - conn.close() - _CACHED_SESSION_SECRETS[abs_db] = new_secret - return new_secret - except Exception: - fallback = secrets.token_hex(32) - _CACHED_SESSION_SECRETS[abs_db] = fallback - return fallback - - -SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32) -DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin") -DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!") +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="3.0.0") # Global context holding server state SERVER_STATE: Dict[str, Any] = {} - -class ClientEnrollRequest(BaseModel): - client_id: str - hostname: str - os: str - enrollment_secret: str - - -class ClientActionRequest(BaseModel): - client_id: str - - -class LicenseImportRequest(BaseModel): - ca_cert: str - upstream_ca: Optional[str] = None - - -class HermesTokenCreateRequest(BaseModel): - name: str - - -class HermesTokenRevokeRequest(BaseModel): - token: str - - -class ClientProfileExportRequest(BaseModel): - server_host: Optional[str] = None - server_port: Optional[int] = None - client_id: Optional[str] = None - - -class ChangePasswordRequest(BaseModel): - old_password: str - new_password: str - - -class DbConnectionTestRequest(BaseModel): - host: str = "localhost" - port: int = 5432 - database: str = "logar" - user: str = "postgres" - password: str = "" - sslmode: str = "prefer" - connect_timeout: int = 5 - pool_size: int = 10 - - -class DbSwitchBackendRequest(BaseModel): - db_type: str = "sqlite" # "sqlite" | "postgres" - host: Optional[str] = "localhost" - port: Optional[int] = 5432 - database: Optional[str] = "logar" - user: Optional[str] = "postgres" - password: Optional[str] = "" - sslmode: Optional[str] = "prefer" - connect_timeout: Optional[int] = 5 - pool_size: Optional[int] = 10 - db_path: Optional[str] = None - - -class DbFullConfigRequest(BaseModel): - storage_engine: str = "sqlite" # "sqlite" | "postgres" - cache_mode: str = "direct" # "direct" | "buffered" - flush_interval: float = 2.0 - batch_size: int = 100 - max_queue: int = 50000 - encrypt_at_rest: bool = False - pg_config: Optional[DbConnectionTestRequest] = None - - -class IngestQueueManager: - """ - High-throughput in-memory buffering and batch processor for incoming telemetry and log anomalies. - Eliminates SQLite disk write contention by decoupling TCP frame ingress from physical DB writes. - """ - def __init__(self, flush_interval: float = 1.0, batch_size: int = 100, max_queue: int = 50000): - self.flush_interval = flush_interval - self.batch_size = batch_size - self.max_queue = max_queue - self._lock = threading.Lock() - self._buffer: List[Dict[str, Any]] = [] - self._total_ingested = 0 - self._total_flushed = 0 - self._dropped_count = 0 - self._last_flush_time = "Never" - self._avg_flush_latency_ms = 0.0 - self._flush_count = 0 - - def push(self, payload: Dict[str, Any]) -> bool: - with self._lock: - if len(self._buffer) >= self.max_queue: - self._dropped_count += 1 - return False - self._buffer.append(payload) - self._total_ingested += len(payload.get("logs", [])) - return True - - def get_stats(self) -> Dict[str, Any]: - with self._lock: - return { - "queue_size": len(self._buffer), - "total_ingested": self._total_ingested, - "total_flushed": self._total_flushed, - "dropped_count": self._dropped_count, - "last_flush_time": self._last_flush_time, - "avg_flush_latency_ms": round(self._avg_flush_latency_ms, 2), - "flush_count": self._flush_count, - "flush_interval_seconds": self.flush_interval, - "batch_size_threshold": self.batch_size - } - - def flush(self, db_path: Optional[str] = None, window_hours: int = 12, min_runs: int = 1) -> Dict[str, Any]: - with self._lock: - if not self._buffer: - return {"status": "noop", "flushed_runs": 0, "processed_logs": 0} - items_to_process = list(self._buffer) - self._buffer.clear() - - start_time = time.time() - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - - total_processed = 0 - promoted_total = 0 - - try: - for payload in items_to_process: - res = process_ingested_logs( - payload=payload, - db_path=target_db, - window_hours=window_hours, - min_runs=min_runs - ) - total_processed += res.get("processed", 0) - promoted_total += res.get("promoted_verified", 0) - except Exception as e: - print(f"[!] Error during batch flush to DB: {e}") - - elapsed_ms = (time.time() - start_time) * 1000.0 - with self._lock: - self._total_flushed += total_processed - self._flush_count += 1 - self._avg_flush_latency_ms = ( - (self._avg_flush_latency_ms * (self._flush_count - 1) + elapsed_ms) / self._flush_count - ) - self._last_flush_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - - return { - "status": "success", - "flushed_runs": len(items_to_process), - "processed_logs": total_processed, - "promoted_verified": promoted_total, - "flush_latency_ms": round(elapsed_ms, 2) - } - - +# Global rate limiter & ingestion queue +RATE_LIMITER = RateLimiter() INGEST_QUEUE = IngestQueueManager() -# --- Rate Limiting & Brute-Force Mitigation --- +# ===================================================================== +# Backward-Compatible Auth & Helper Functions (delegating to managers) +# ===================================================================== -_RATE_LIMIT_LOCK = threading.Lock() -_LOGIN_FAILURES: Dict[str, List[float]] = {} -_ENROLL_FAILURES: Dict[str, List[float]] = {} +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: - """Extracts client IP address respecting reverse-proxy headers.""" - if not request: - return "127.0.0.1" - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - real_ip = request.headers.get("x-real-ip") - if real_ip: - return real_ip.strip() - if request.client: - return request.client.host - return "127.0.0.1" + return RateLimiter.get_client_ip(request) def check_ip_rate_limit( @@ -309,28 +136,11 @@ def check_ip_rate_limit( scope: str = "login", db_path: Optional[str] = None ) -> bool: - """Checks whether the client IP has exceeded failed attempts within the sliding time window using persistent DB storage.""" - now = datetime.now(timezone.utc).timestamp() - cutoff = now - window_seconds target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - try: - conn = sqlite3.connect(target_db, timeout=5.0) - c = conn.cursor() - c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") - c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,)) - c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff)) - cnt = c.fetchone()[0] - conn.commit() - conn.close() - return cnt < max_attempts - except Exception: - with _RATE_LIMIT_LOCK: - if isinstance(store, dict): - history = store.get(ip, []) - history = [ts for ts in history if now - ts < window_seconds] - store[ip] = history - return len(history) < max_attempts - return True + 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( @@ -339,23 +149,10 @@ def record_failed_ip_attempt( scope: str = "login", db_path: Optional[str] = None ): - """Records a persistent failed attempt timestamp for the client IP and scope.""" - now = datetime.now(timezone.utc).timestamp() target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - try: - conn = sqlite3.connect(target_db, timeout=5.0) - c = conn.cursor() - c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") - c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now)) - conn.commit() - conn.close() - except Exception: - pass - with _RATE_LIMIT_LOCK: - if isinstance(store, dict): - if ip not in store: - store[ip] = [] - store[ip].append(now) + 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( @@ -364,67 +161,28 @@ def reset_failed_ip_attempts( scope: str = "login", db_path: Optional[str] = None ): - """Resets persistent failed attempt records for client IP upon successful authentication.""" target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - try: - conn = sqlite3.connect(target_db, timeout=5.0) - c = conn.cursor() - c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope)) - conn.commit() - conn.close() - except Exception: - pass - with _RATE_LIMIT_LOCK: - if isinstance(store, dict) and ip in store: - del store[ip] + RATE_LIMITER.reset_failed_ip_attempts( + ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE + ) -# --- Authentication & Password Hashing Utilities --- - def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: - """Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations.""" - if not salt: - salt = secrets.token_hex(16) - pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex() - return pwd_hash, salt + return PasswordManager.hash_password(password, salt=salt) def verify_password(password: str, salt: str, password_hash: str) -> bool: - """Verifies a password against the stored salt and PBKDF2 hash.""" - test_hash, _ = hash_password(password, salt=salt) - return hmac.compare_digest(test_hash, password_hash) + return PasswordManager.verify_password(password, salt, password_hash) def generate_session_token(username: str, db_path: Optional[str] = None) -> str: - """Generates an HMAC-signed session cookie string.""" - ts = int(datetime.now(timezone.utc).timestamp()) - nonce = secrets.token_hex(8) - payload = f"{username}:{ts}:{nonce}" - secret = get_session_secret(db_path) - sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() - return f"{payload}:{sig}" + 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]: - """Verifies session token authenticity and age.""" - if not token or ":" not in token: - return None - parts = token.split(":") - if len(parts) != 4: - return None - username, ts_str, nonce, sig = parts - payload = f"{username}:{ts_str}:{nonce}" - secret = get_session_secret(db_path) - expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() - if not hmac.compare_digest(sig, expected_sig): - return None - try: - ts = int(ts_str) - if datetime.now(timezone.utc).timestamp() - ts > max_age_hours * 3600: - return None - except Exception: - return None - return username + 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]: @@ -468,192 +226,67 @@ def require_admin_auth(request: Request) -> str: def verify_user_credentials(username: str, password: str, db_path: str = DEFAULT_DB_FILE) -> bool: - """Checks user credentials against SQLite users table.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,)) - row = c.fetchone() - conn.close() - if not row: - return False - return verify_password(password, row[1], row[0]) + 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: - """Updates a user's password.""" - pwd_hash, salt = hash_password(new_password) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username)) - changed = c.rowcount > 0 - conn.commit() - conn.close() - return changed + return PasswordManager.change_user_password(username, new_password, db_path=db_path) -# --- Two-Factor Authentication (TOTP / RFC 6238) --- - def generate_totp_secret(length: int = 20) -> str: - """Generates a cryptographically random Base32-encoded TOTP secret key.""" - return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "") + return TOTPManager.generate_totp_secret(length=length) def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: - """Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238.""" - if intervals_no is None: - intervals_no = int(time.time()) // 30 - key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True) - msg = struct.pack(">Q", intervals_no) - h = hmac.new(key, msg, hashlib.sha1).digest() - o = h[19] & 15 - h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 - return f"{h_int:06d}" + return TOTPManager.get_totp_token(secret, intervals_no=intervals_no) def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: - """Verifies a 6-digit TOTP code with standard drift window (+/- 30s).""" - if not secret or not code: - return False - code = str(code).strip().replace(" ", "") - if len(code) != 6 or not code.isdigit(): - return False - now = int(time.time()) - current_step = now // 30 - for offset in range(-window, window + 1): - if hmac.compare_digest(get_totp_token(secret, current_step + offset), code): - return True - return False + return TOTPManager.verify_totp_code(secret, code, window=window) def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: - """Generates standard otpauth URI for QR code generation.""" - user_enc = urllib.parse.quote(username) - issuer_enc = urllib.parse.quote(issuer) - return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30" + 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]: - """Retrieves TOTP configuration for a user from the SQLite database.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - try: - c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,)) - row = c.fetchone() - if row: - return {"secret": row[0] or "", "enabled": bool(row[1])} - except Exception: - pass - finally: - conn.close() - return {"secret": "", "enabled": False} + 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: - """Enables and activates TOTP for a user in the SQLite database.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - try: - c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username)) - changed = c.rowcount > 0 - conn.commit() - return changed - finally: - conn.close() + return TOTPManager.enable_user_totp(username, secret, db_path=db_path) def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool: - """Disables TOTP for a user in the SQLite database.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - try: - c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,)) - changed = c.rowcount > 0 - conn.commit() - return changed - finally: - conn.close() + return TOTPManager.disable_user_totp(username, db_path=db_path) -# --- Hermes Token Management --- - def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: - """Creates or saves a named Hermes API token.""" - if not token: - token = f"hermes_{secrets.token_hex(20)}" - now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute( - "INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')", - (token, name, now_iso) - ) - conn.commit() - conn.close() - return {"token": token, "name": name, "created_at": now_iso, "status": "active"} + 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]]: - """Lists all registered Hermes access tokens.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC") - rows = [ - {"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]} - for r in c.fetchall() - ] - conn.close() - return rows + return HermesTokenManager.list_hermes_tokens(db_path=db_path) def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - """Revokes an active Hermes token.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,)) - changed = c.rowcount > 0 - conn.commit() - conn.close() - return changed + return HermesTokenManager.revoke_hermes_token(token, db_path=db_path) def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - """Deletes a Hermes token.""" - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,)) - changed = c.rowcount > 0 - conn.commit() - conn.close() - return changed + return HermesTokenManager.delete_hermes_token(token, db_path=db_path) def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - """Verifies a Hermes token and updates last_used timestamp.""" - if not token: - return False - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,)) - row = c.fetchone() - if row: - now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token)) - conn.commit() - conn.close() - return row is not None + 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, - active admin session cookie, or legacy auth_token. - """ - # 1. Check if logged in admin user + """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}" - # 2. Check Bearer token or token query param token = None auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): @@ -664,15 +297,13 @@ def require_hermes_or_admin_auth(request: Request) -> str: db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) if token: - # Check against hermes_tokens table if verify_hermes_token(token, db_path=db_path): return f"hermes_token:{token[:8]}..." - # Check against config legacy auth_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" - # Backward compatibility: if no hermes tokens are defined in database yet, allow unauthenticated read + # Fallback if no hermes tokens are defined in database yet try: conn = sqlite3.connect(db_path) c = conn.cursor() @@ -687,32 +318,16 @@ def require_hermes_or_admin_auth(request: Request) -> str: raise HTTPException(status_code=401, detail="Valid Hermes Bearer Token or Administrator Login Required") -# --- Client Profile Export & Helper Utilities --- +# ===================================================================== +# Keypair, Storage & Path Resolution Utilities +# ===================================================================== -def generate_server_keypair(server_name: str): - """Generates an OpenPGP RSA 2048 key with encryption capability.""" - key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) - uid = pgpy.PGPUID.new(server_name) - key.add_uid( - uid, - usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, - hashes=[HashAlgorithm.SHA256, HashAlgorithm.SHA512], - ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.AES128], - compression=[ - CompressionAlgorithm.ZIP, - CompressionAlgorithm.ZLIB, - CompressionAlgorithm.BZ2, - CompressionAlgorithm.Uncompressed - ] - ) - private_key_armored = str(key) - public_key_armored = str(key.pubkey) - fingerprint = str(key.pubkey.fingerprint) - return private_key_armored, public_key_armored, fingerprint +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 actually writable by the current process.""" + """Checks if a file or directory path is writable by the current process.""" try: if os.path.exists(filepath): if os.path.isdir(filepath): @@ -740,19 +355,16 @@ def is_path_writable(filepath: str) -> bool: def get_default_data_dir(app_name: str = "LOGAR-Server") -> str: """Returns a suitable writable application data directory.""" if sys.platform == "win32": - # 1. ProgramData (C:\ProgramData\LOGAR-Server) 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 - # 2. LocalAppData (C:\Users\\AppData\Local\LOGAR-Server) 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 - # 3. Temp directory (%TEMP%\LOGAR-Server) temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) if is_path_writable(temp_dir): return temp_dir @@ -764,19 +376,13 @@ def get_default_data_dir(app_name: str = "LOGAR-Server") -> str: def resolve_writable_path(path: str, app_name: str = "LOGAR-Server", default_filename: str = "logar_state.db") -> str: - """Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory.""" + """Resolves a path, ensuring it is writable or falling back to a writable data directory.""" if not path: path = default_filename - - # If already absolute and writable, keep it if os.path.isabs(path) and is_path_writable(path): return path - - # If relative and CWD is writable and not in Program Files, use CWD if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): return os.path.abspath(path) - - # Otherwise route into the guaranteed writable data directory data_dir = get_default_data_dir(app_name) fname = os.path.basename(path) return os.path.join(data_dir, fname) @@ -831,18 +437,15 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: if "encrypt_at_rest" not in config: config["encrypt_at_rest"] = False - # Resolve relative db_path and cert_dir to writable paths 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, _CACHED_SESSION_SECRET + global SESSION_SECRET if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: SESSION_SECRET = config["session_secret"] - _CACHED_SESSION_SECRET = config["session_secret"] return config - # Fallback to writable data dir if CWD is read-only / Program Files 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)) @@ -881,7 +484,6 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: } if not os.environ.get("LOGAR_SESSION_SECRET"): SESSION_SECRET = session_sec - _CACHED_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: @@ -894,15 +496,11 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> str: - """ - Generates a fresh enrollment secret, invalidates the previous secret, - and atomically persists the change to server_config.json and database license_config. - """ + """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 - # Update config file on disk if exists config_file_path = cfg.get("config_file_path") or CONFIG_FILE_NAME try: if os.path.exists(config_file_path): @@ -914,7 +512,6 @@ def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> st except Exception as e: print(f"[!] Warning: Could not write new enrollment secret to config file: {e}") - # Update database license_config table db_path = cfg.get("db_path", DEFAULT_DB_FILE) try: conn = sqlite3.connect(db_path, timeout=5.0) @@ -970,7 +567,7 @@ def create_client_config( 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-only key/fingerprint.""" + """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 @@ -1003,679 +600,93 @@ def create_client_config( return client_conf -def format_relative_time(dt_str: Optional[str]) -> str: - """Calculates human-readable relative time from ISO or SQL timestamp string.""" - if not dt_str or dt_str in ("N/A", "Never"): - return "Never" - try: - clean_str = dt_str.replace(" UTC", "") - if "T" in clean_str: - dt = datetime.fromisoformat(clean_str) - else: - dt = datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S") - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - now = datetime.now(timezone.utc) - diff = now - dt - seconds = int(diff.total_seconds()) - if seconds < 0: - return "Just now" - if seconds < 60: - return f"{seconds}s ago" - minutes = seconds // 60 - if minutes < 60: - return f"{minutes}m ago" - hours = minutes // 60 - if hours < 24: - return f"{hours}h ago" - days = hours // 24 - return f"{days}d ago" - except Exception: - return dt_str[:16] if dt_str else "N/A" - +# ===================================================================== +# Database, Storage & Crypto Integrations +# ===================================================================== def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str: - """Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota.""" - def _execute_schema(target_path: str): - target_dir = os.path.dirname(os.path.abspath(target_path)) - if target_dir: - os.makedirs(target_dir, exist_ok=True) - conn = sqlite3.connect(target_path, timeout=10.0) - try: - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA busy_timeout=5000;") - except Exception: - pass - - # 1. Active Issues - conn.execute(""" - CREATE TABLE IF NOT EXISTS active_issues ( - fingerprint TEXT PRIMARY KEY, - site_name TEXT, - server TEXT, - signature TEXT, - severity TEXT, - message TEXT, - os_type TEXT, - first_seen TEXT, - last_seen TEXT, - run_count INTEGER, - status TEXT, - last_run_id TEXT - ) - """) - - # 2. Ingest Runs - conn.execute(""" - CREATE TABLE IF NOT EXISTS ingest_runs ( - run_id TEXT PRIMARY KEY, - site_name TEXT, - server TEXT, - timestamp TEXT, - log_count INTEGER - ) - """) - - # 3. License Config - conn.execute(""" - CREATE TABLE IF NOT EXISTS license_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - max_seats INTEGER NOT NULL DEFAULT 0, - enrollment_secret TEXT NOT NULL, - license_id TEXT, - license_server_url TEXT, - issued_at TEXT, - expires_at TEXT, - license_status TEXT DEFAULT 'unlicensed' - ) - """) - - # Migrations for license_config - for col, col_def in [ - ("license_id", "TEXT"), - ("license_server_url", "TEXT"), - ("issued_at", "TEXT"), - ("expires_at", "TEXT"), - ("license_status", "TEXT DEFAULT 'unlicensed'"), - ("features", "TEXT DEFAULT '[]'") - ]: - try: - conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}") - except sqlite3.OperationalError: - pass - - # 4. Clients Table - conn.execute(""" - CREATE TABLE IF NOT EXISTS clients ( - client_id TEXT PRIMARY KEY, - hostname TEXT NOT NULL, - os_type TEXT NOT NULL, - cert_fingerprint TEXT NOT NULL, - status TEXT DEFAULT 'active', - first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - # Migrations for clients table - for col, col_def in [ - ("metrics_json", "TEXT DEFAULT '{}'"), - ("cpu_percent", "REAL DEFAULT 0.0"), - ("memory_percent", "REAL DEFAULT 0.0"), - ("disk_percent_free", "REAL DEFAULT 0.0") - ]: - try: - conn.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_def}") - except sqlite3.OperationalError: - pass - - # 5. Users Table - conn.execute(""" - CREATE TABLE IF NOT EXISTS users ( - username TEXT PRIMARY KEY, - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'admin', - created_at TEXT NOT NULL, - totp_secret TEXT, - totp_enabled INTEGER DEFAULT 0 - ) - """) - - # Migrations for users table (2FA) - for col, col_def in [ - ("totp_secret", "TEXT"), - ("totp_enabled", "INTEGER DEFAULT 0") - ]: - try: - conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}") - except sqlite3.OperationalError: - pass - - # Ensure default admin user exists - c = conn.cursor() - c.execute("SELECT COUNT(*) FROM users") - if c.fetchone()[0] == 0: - now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - pwd_hash, salt = hash_password(DEFAULT_ADMIN_PASS) - conn.execute("INSERT INTO users (username, password_hash, salt, role, created_at) VALUES (?, ?, ?, 'admin', ?)", - (DEFAULT_ADMIN_USER, pwd_hash, salt, now_iso)) - - # 6. Hermes Tokens Table - conn.execute(""" - CREATE TABLE IF NOT EXISTS hermes_tokens ( - token TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - last_used TEXT, - status TEXT NOT NULL DEFAULT 'active' - ) - """) - - # Auto-initialize initial Hermes token if table is empty - c.execute("SELECT COUNT(*) FROM hermes_tokens") - if c.fetchone()[0] == 0: - initial_token = f"hermes_live_{secrets.token_hex(20)}" - now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')", - (initial_token, now_iso)) - - # 7. Rate Limits & Server Meta Tables - conn.execute(""" - CREATE TABLE IF NOT EXISTS rate_limits ( - ip TEXT NOT NULL, - scope TEXT NOT NULL, - attempt_time REAL NOT NULL - ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_scope ON rate_limits (ip, scope, attempt_time)") - - conn.execute(""" - CREATE TABLE IF NOT EXISTS server_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """) - - status = "active" if max_seats > 0 else "unlicensed" - conn.execute(""" - INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status, features) - VALUES (1, ?, ?, ?, '[]') - """, (max_seats, enrollment_secret or "", status)) - - conn.commit() - conn.close() - - try: - _execute_schema(db_path) - return db_path - except (sqlite3.OperationalError, PermissionError, OSError) as e: - if "readonly" in str(e).lower() or "permission" in str(e).lower() or "locked" in str(e).lower() or isinstance(e, PermissionError): - local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") - fallback_dir = os.path.join(local_app, "LOGAR-Server") - os.makedirs(fallback_dir, exist_ok=True) - fallback_path = os.path.join(fallback_dir, "logar_state.db") - if fallback_path != db_path: - print(f"[!] Warning: Database path '{db_path}' is not writable ({e}). Falling back to '{fallback_path}'...") - _execute_schema(fallback_path) - return fallback_path - raise - - -def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: - """Generates CA CSR and packages into exportable .lic dictionary and JSON string.""" - cert_dir = config.get("cert_dir", "certs") - lic_server = config.get("license_server_url", "https://logar.eibl.tech") - hostname = socket.gethostname() - seats = config.get("max_seats", 10) - fp = config.get("server_fingerprint", "") - return enrollment.create_license_request_lic( - cert_dir=cert_dir, - license_server=lic_server, - server_fingerprint=fp, - hostname=hostname, - requested_seats=seats - ) - - -def get_active_licensed_features(db_path: Optional[str] = None) -> List[str]: - """ - Returns the list of all active enterprise capability identifiers - authorized either via X.509 certificate extensions or the persistent database license config. - """ - features = set() - - # 1. Check in-memory / active Subordinate CA certificate - ca_pem = SERVER_STATE.get("ca_cert_pem") - if not ca_pem: - cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") - ca_file = os.path.join(cert_dir, "ca.crt") - if os.path.exists(ca_file): - try: - with open(ca_file, "r", encoding="utf-8") as f: - ca_pem = f.read() - except Exception: - pass - - if ca_pem: - try: - info = enrollment.extract_license_info_from_cert(ca_pem) - for feat in info.get("features", []): - if feat: - features.add(str(feat).strip()) - except Exception: - pass - - # 2. Check license_config table in database - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if target_db and os.path.exists(target_db): - try: - conn = sqlite3.connect(target_db, timeout=3.0) - c = conn.cursor() - c.execute("SELECT features FROM license_config WHERE id = 1") - row = c.fetchone() - conn.close() - if row and row[0]: - feat_raw = row[0] - if isinstance(feat_raw, str): - if feat_raw.startswith("["): - feat_list = json.loads(feat_raw) - else: - feat_list = [f.strip() for f in feat_raw.split(",") if f.strip()] - for f_item in feat_list: - if f_item: - features.add(str(f_item).strip()) - elif isinstance(feat_raw, list): - for f_item in feat_raw: - if f_item: - features.add(str(f_item).strip()) - except Exception: - pass - - return sorted(list(features)) - - -def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool: - """Checks whether a specific enterprise capability (e.g. 'enterprise_db') is authorized.""" - if not feature: - return False - return feature in get_active_licensed_features(db_path) - - -def get_server_public_key() -> Optional[pgpy.PGPKey]: - """Retrieves or parses the server's OpenPGP public key for encryption operations.""" - pub_obj = SERVER_STATE.get("public_key_obj") - if pub_obj: - return pub_obj - priv_obj = SERVER_STATE.get("private_key_obj") - if priv_obj and hasattr(priv_obj, "pubkey"): - SERVER_STATE["public_key_obj"] = priv_obj.pubkey - return priv_obj.pubkey - pub_blob = SERVER_STATE.get("config", {}).get("public_key") - if pub_blob: - try: - key, _ = pgpy.PGPKey.from_blob(pub_blob) - SERVER_STATE["public_key_obj"] = key - return key - except Exception: - pass - return None - - -def encrypt_field_for_storage(text: str, force: bool = False) -> str: - """Encrypts a text payload using the server's OpenPGP public key if encrypt_at_rest is enabled.""" - if not text or not isinstance(text, str): - return text - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - is_unlocked = is_feature_licensed("enterprise_db", db_path) - should_encrypt = force or (is_unlocked and bool(config.get("encrypt_at_rest", False))) - if not should_encrypt: - return text - - if text.startswith("-----BEGIN PGP MESSAGE-----"): - return text - - pub_key = get_server_public_key() - if not pub_key: - return text - - try: - msg = pgpy.PGPMessage.new(text) - encrypted = pub_key.encrypt(msg) - return str(encrypted) - except Exception as e: - print(f"[!] Warning: Failed to encrypt field at rest: {e}") - return text - - -def decrypt_field_from_storage(text: str) -> str: - """ - Decrypts an OpenPGP armored message string using the server's private key. - Returns cleartext for authorized display and API consumption. - """ - if not text or not isinstance(text, str): - return text - if not text.startswith("-----BEGIN PGP MESSAGE-----"): - return text - - priv_key = SERVER_STATE.get("private_key_obj") - if not priv_key: - priv_blob = SERVER_STATE.get("config", {}).get("private_key") - if priv_blob: - try: - priv_key, _ = pgpy.PGPKey.from_blob(priv_blob) - SERVER_STATE["private_key_obj"] = priv_key - except Exception: - pass - - if not priv_key: - return text - - try: - pgp_msg = pgpy.PGPMessage.from_blob(text) - decrypted = priv_key.decrypt(pgp_msg) - return str(decrypted.message) - except Exception as e: - print(f"[!] Warning: Failed to decrypt field from storage: {e}") - return text - - -def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]: - """Returns metadata about the active storage backend, configuration, and database telemetry.""" - config = SERVER_STATE.get("config", {}) - db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower() - target_db = db_path or config.get("db_path", DEFAULT_DB_FILE) - - table_stats = {"active_issues": 0, "ingest_runs": 0, "clients": 0, "users": 0} - is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db) - - licensed_features = [] - if target_db and os.path.exists(target_db): - try: - conn_l = sqlite3.connect(target_db, timeout=3.0) - row_l = conn_l.execute("SELECT features FROM license_config WHERE id = 1").fetchone() - conn_l.close() - if row_l and row_l[0]: - feat_raw = row_l[0] - if isinstance(feat_raw, str): - if feat_raw.startswith("["): - licensed_features = json.loads(feat_raw) - else: - licensed_features = [f.strip() for f in feat_raw.split(",") if f.strip()] - elif isinstance(feat_raw, list): - licensed_features = list(feat_raw) - except Exception: - pass - - if db_type in ("postgres", "postgresql"): - pg_cfg = config.get("postgres", {}) - 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") - try: - try: - import psycopg2 - except ImportError: - import psycopg as psycopg2 - p_conn = psycopg2.connect( - host=host, port=port, dbname=dbname, user=user, - password=pg_cfg.get("password", ""), - sslmode=pg_cfg.get("sslmode", "prefer"), - connect_timeout=int(pg_cfg.get("connect_timeout", 3)) - ) - with p_conn.cursor() as p_cur: - for tbl in ["active_issues", "ingest_runs", "clients", "users"]: - try: - p_cur.execute(f"SELECT COUNT(*) FROM {tbl}") - r = p_cur.fetchone() - table_stats[tbl] = r[0] if r else 0 - except Exception: - pass - p_conn.close() - except Exception: - pass - - return { - "engine": "postgres", - "engine_type": "postgres", - "backend_display": "PostgreSQL Enterprise Pool", - "connection_target": f"{user}@{host}:{port}/{dbname}", - "status": "Enterprise Connected", - "table_stats": table_stats, - "is_enterprise": True, - "feature_unlocked": is_unlocked, - "licensed_features": licensed_features, - "cache_mode": config.get("cache_mode", "direct"), - "encrypt_at_rest": config.get("encrypt_at_rest", False), - "flush_interval": INGEST_QUEUE.flush_interval, - "batch_size": INGEST_QUEUE.batch_size, - "max_queue": INGEST_QUEUE.max_queue, - "postgres_config": pg_cfg - } - else: - size_bytes = 0 - if target_db and os.path.exists(target_db): - try: - size_bytes = os.path.getsize(target_db) - conn = sqlite3.connect(target_db, timeout=3.0) - for tbl in ["active_issues", "ingest_runs", "clients", "users"]: - try: - row = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone() - table_stats[tbl] = row[0] if row else 0 - except Exception: - pass - conn.close() - except Exception: - pass - size_kb = round(size_bytes / 1024, 2) - return { - "engine": "sqlite", - "engine_type": "sqlite", - "backend_display": "SQLite 3 (WAL Mode)", - "connection_target": target_db, - "db_size_kb": size_kb, - "status": "Local WAL Optimized", - "table_stats": table_stats, - "is_enterprise": False, - "feature_unlocked": is_unlocked, - "licensed_features": licensed_features, - "cache_mode": config.get("cache_mode", "direct"), - "encrypt_at_rest": config.get("encrypt_at_rest", False), - "flush_interval": INGEST_QUEUE.flush_interval, - "batch_size": INGEST_QUEUE.batch_size, - "max_queue": INGEST_QUEUE.max_queue, - "postgres_config": config.get("postgres", { - "host": "localhost", - "port": 5432, - "database": "logar", - "user": "postgres", - "password": "", - "sslmode": "prefer", - "connect_timeout": 5, - "pool_size": 10 - }) - } + 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]: - """Initializes table schema on a PostgreSQL database instance.""" - try: - try: - import psycopg2 - except ImportError: - try: - import psycopg as psycopg2 - except ImportError: - return False, "PostgreSQL driver not installed on server (install psycopg2-binary or psycopg)" - - host = pg_config.get("host", "localhost") - port = int(pg_config.get("port", 5432)) - database = pg_config.get("database", "logar") - user = pg_config.get("user", "postgres") - password = pg_config.get("password", "") - sslmode = pg_config.get("sslmode", "prefer") - timeout = int(pg_config.get("connect_timeout", 5)) - - conn = psycopg2.connect( - host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout - ) - - with conn.cursor() as cur: - cur.execute(""" - CREATE TABLE IF NOT EXISTS active_issues ( - fingerprint VARCHAR(255) PRIMARY KEY, - site_name VARCHAR(255), - server VARCHAR(255), - signature TEXT, - severity VARCHAR(50), - message TEXT, - os_type VARCHAR(50), - first_seen VARCHAR(50), - last_seen VARCHAR(50), - run_count INTEGER, - status VARCHAR(50), - last_run_id VARCHAR(100) - ); - CREATE TABLE IF NOT EXISTS ingest_runs ( - run_id VARCHAR(100) PRIMARY KEY, - site_name VARCHAR(255), - server VARCHAR(255), - timestamp VARCHAR(50), - log_count INTEGER - ); - CREATE TABLE IF NOT EXISTS license_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - max_seats INTEGER NOT NULL DEFAULT 0, - enrollment_secret TEXT NOT NULL, - license_id VARCHAR(100), - license_server_url VARCHAR(255), - issued_at VARCHAR(50), - expires_at VARCHAR(50), - license_status VARCHAR(50) DEFAULT 'unlicensed', - features TEXT DEFAULT '[]' - ); - CREATE TABLE IF NOT EXISTS clients ( - client_id VARCHAR(100) PRIMARY KEY, - hostname VARCHAR(255) NOT NULL, - os_type VARCHAR(50) NOT NULL, - cert_fingerprint VARCHAR(255) NOT NULL, - status VARCHAR(50) DEFAULT 'active', - first_seen VARCHAR(50), - last_seen VARCHAR(50) - ); - CREATE TABLE IF NOT EXISTS users ( - username VARCHAR(100) PRIMARY KEY, - password_hash TEXT NOT NULL, - salt TEXT NOT NULL, - role VARCHAR(50) NOT NULL DEFAULT 'admin', - created_at VARCHAR(50) NOT NULL, - totp_secret TEXT, - totp_enabled INTEGER DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS hermes_tokens ( - token VARCHAR(255) PRIMARY KEY, - name VARCHAR(255) NOT NULL, - created_at VARCHAR(50) NOT NULL, - last_used VARCHAR(50), - status VARCHAR(50) NOT NULL DEFAULT 'active' - ); - CREATE TABLE IF NOT EXISTS rate_limits ( - ip VARCHAR(100) NOT NULL, - scope VARCHAR(50) NOT NULL, - attempt_time DOUBLE PRECISION NOT NULL - ); - CREATE TABLE IF NOT EXISTS server_meta ( - key VARCHAR(100) PRIMARY KEY, - value TEXT NOT NULL - ); - """) - # Migrations for Postgres clients table - for col, col_def in [ - ("metrics_json", "TEXT DEFAULT '{}'"), - ("cpu_percent", "DOUBLE PRECISION DEFAULT 0.0"), - ("memory_percent", "DOUBLE PRECISION DEFAULT 0.0"), - ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0") - ]: - try: - cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") - except Exception: - pass - conn.commit() - conn.close() - return True, "PostgreSQL schema successfully initialized" - except Exception as e: - return False, f"PostgreSQL connection/init failed: {e}" + 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]]: - """Transfers all tables and records from SQLite to PostgreSQL with data validation and encryption.""" - if not os.path.exists(sqlite_path): - return False, f"SQLite database not found at {sqlite_path}", {} + return StorageEngine.migrate_sqlite_to_postgres(sqlite_path, pg_config, encrypt_fn=encrypt_field_for_storage) - ok, init_msg = init_postgres_db(pg_config) - if not ok: - return False, init_msg, {} - try: - try: - import psycopg2 - except ImportError: - import psycopg as psycopg2 +def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + return LicenseManager.export_license_request_lic(config) - host = pg_config.get("host", "localhost") - port = int(pg_config.get("port", 5432)) - database = pg_config.get("database", "logar") - user = pg_config.get("user", "postgres") - password = pg_config.get("password", "") - sslmode = pg_config.get("sslmode", "prefer") - timeout = int(pg_config.get("connect_timeout", 5)) - pg_conn = psycopg2.connect( - host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout - ) - sqlite_conn = sqlite3.connect(sqlite_path) - sqlite_conn.row_factory = sqlite3.Row +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) - migrated_counts = {} - tables = ["active_issues", "ingest_runs", "license_config", "clients", "users", "hermes_tokens", "rate_limits", "server_meta"] - with pg_conn.cursor() as pg_cur: - for tbl in tables: - try: - s_cur = sqlite_conn.cursor() - s_cur.execute(f"SELECT * FROM {tbl}") - rows = s_cur.fetchall() - if rows: - col_names = [col[0] for col in s_cur.description] - cols_joined = ", ".join(col_names) - placeholders = ", ".join(["%s"] * len(col_names)) - insert_query = f"INSERT INTO {tbl} ({cols_joined}) VALUES ({placeholders}) ON CONFLICT DO NOTHING;" - data = [] - for r in rows: - row_data = [r[c] for c in col_names] - if tbl == "active_issues" and "message" in col_names: - msg_idx = col_names.index("message") - row_data[msg_idx] = encrypt_field_for_storage(row_data[msg_idx]) - data.append(row_data) +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) - pg_cur.executemany(insert_query, data) - migrated_counts[tbl] = len(rows) - else: - migrated_counts[tbl] = 0 - except Exception as tbl_err: - print(f"[!] Warning migrating table {tbl}: {tbl_err}") - migrated_counts[tbl] = 0 - pg_conn.commit() +def get_server_public_key() -> Optional[pgpy.PGPKey]: + return StorageCryptoManager.get_server_public_key(server_state=SERVER_STATE) - sqlite_conn.close() - pg_conn.close() - return True, "Migration completed successfully", migrated_counts - except Exception as e: - return False, f"Migration failed: {e}", {} +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.""" @@ -1686,7 +697,9 @@ async def ingest_buffer_worker_loop(): 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) + 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: @@ -1749,7 +762,6 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i continue fp = f"{site_name}:{server}:{signature}" - stored_message = encrypt_field_for_storage(message) cursor.execute( @@ -1845,7 +857,6 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i continue fp = f"{site_name}:{server}:{signature}" - stored_message = encrypt_field_for_storage(message) cursor.execute( @@ -1915,33 +926,26 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i def get_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: - """Collects local Windows Application and System event logs.""" 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: - """Collects local Linux journalctl / syslog events.""" collector = LinuxLogCollector(state=state) return collector.collect_logs(hours=hours) def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list: - """Platform-agnostic local host log collector.""" 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. - Does not require a license seat, certificate enrollment, or mTLS socket connection. - """ + """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") - # 1. Ensure local node is registered in clients table with LOCAL-HOST-BUILTIN fingerprint try: conn = sqlite3.connect(db_path) c = conn.cursor() @@ -1962,12 +966,10 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run except Exception as e: print(f"[!] Warning: Failed to update local hub client record: {e}") - # 2. Collect local logs logs = get_local_system_logs(hours=window_hours, state=state) if not logs: return 0 - # 3. Direct ingestion without network overhead or seat checks payload = { "server": machine_id, "site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub", @@ -1980,9 +982,7 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run async def local_collector_loop(interval_seconds: int = 30): - """ - Background worker that periodically ingests local host logs without requiring a client license. - """ + """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) @@ -1997,7 +997,6 @@ async def local_collector_loop(interval_seconds: int = 30): except Exception: state = {} - # Run initial cycle immediately 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: @@ -2017,164 +1016,6 @@ async def local_collector_loop(interval_seconds: int = 30): print(f"[!] Exception in local_collector_loop: {e}") -def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: - """Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS).""" - ca_file = os.path.join(cert_dir, "ca.crt") - srv_cert = os.path.join(cert_dir, "server.crt") - srv_key = os.path.join(cert_dir, "server.key") - - ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) - ctx.load_verify_locations(cafile=ca_file) - upstream_file = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_file): - try: - ctx.load_verify_locations(cafile=upstream_file) - except Exception: - pass - ctx.verify_mode = ssl.CERT_REQUIRED - ctx.minimum_version = ssl.TLSVersion.TLSv1_3 - return ctx - - -def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): - """ - Dynamically reloads server certificate chain and Root CA in an active SSLContext. - Allows in-flight TLS certificate rotation without dropping the listening socket. - """ - ca_file = os.path.join(cert_dir, "ca.crt") - srv_cert = os.path.join(cert_dir, "server.crt") - srv_key = os.path.join(cert_dir, "server.key") - - ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) - ssl_ctx.load_verify_locations(cafile=ca_file) - upstream_file = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_file): - try: - ssl_ctx.load_verify_locations(cafile=upstream_file) - except Exception: - pass - - -def check_and_rotate_server_certs( - cert_dir: str = "certs", - hostnames: Optional[List[str]] = None, - threshold_days: int = 30 -) -> bool: - """ - Checks if Root/Subordinate CA or server TLS certificate are expiring within threshold_days. - If a licensed Subordinate CA is expiring, automatically connects over mTLS to logar.eibl.tech - to request an in-flight renewal, then reloads the active SSLContext without socket interruption. - Returns True if renewed/reloaded, False otherwise. - """ - config = SERVER_STATE.get("config", {}) - license_server = config.get("license_server_url", "https://logar.eibl.tech") - ca_cert_path = os.path.join(cert_dir, "ca.crt") - - # 1. Check if CA certificate is expiring and attempt upstream mTLS renewal - if os.path.exists(ca_cert_path): - try: - with open(ca_cert_path, "r", encoding="utf-8") as f: - ca_pem = f.read() - if enrollment.is_cert_expiring_soon(ca_pem, threshold_days=threshold_days): - info = enrollment.extract_license_info_from_cert(ca_pem) - if not info.get("is_self_signed", True) or info.get("license_id"): - print("[*] Subordinate CA expiring soon. Initiating automated mTLS renewal with logar.eibl.tech...") - success, msg, new_pem = enrollment.renew_ca_cert_mtls( - cert_dir=cert_dir, - license_server_url=license_server - ) - if success and new_pem: - print(f"[+] Automated mTLS renewal successful: {msg}") - new_info = enrollment.extract_license_info_from_cert(new_pem) - if new_info.get("seats"): - config["max_seats"] = new_info["seats"] - db_path = config.get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (new_info["seats"],)) - 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=hostnames, 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) - print("[+] In-flight mTLS SSLContext successfully reloaded with renewed CA.") - except Exception as e: - print(f"[!] Failed to reload in-flight SSLContext: {e}") - return True - else: - print(f"[!] Automated mTLS renewal attempt failed: {msg}") - except Exception as e: - print(f"[!] Error evaluating CA certificate for mTLS renewal: {e}") - - # 2. Local self-healing / regeneration check - ca_renewed, srv_renewed = enrollment.check_and_renew_hub_pki( - cert_dir=cert_dir, - hostnames=hostnames, - threshold_days=threshold_days - ) - if ca_renewed or srv_renewed: - print(f"[!] Server Hub PKI certificates renewed (CA renewed: {ca_renewed}, Server cert renewed: {srv_renewed}).") - 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 - - ssl_ctx = SERVER_STATE.get("ssl_ctx") - if ssl_ctx is not None: - try: - reload_mtls_context(ssl_ctx, cert_dir=cert_dir) - print("[+] In-flight mTLS SSLContext successfully reloaded with updated certificates.") - except Exception as e: - print(f"[!] Failed to reload in-flight SSLContext: {e}") - return True - return False - - -def report_seat_quota_to_authority_sync(): - """Background helper to push active seat count to upstream authority without blocking.""" - try: - config = SERVER_STATE.get("config", {}) - cert_dir = config.get("cert_dir", "certs") - db_path = config.get("db_path", DEFAULT_DB_FILE) - lic_server = config.get("license_server_url", "https://logar.eibl.tech") - if not os.path.exists(os.path.join(cert_dir, "ca.crt")) or not os.path.exists(os.path.join(cert_dir, "ca.key")): - return - - 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 - - enrollment.renew_ca_cert_mtls( - cert_dir=cert_dir, - license_server_url=lic_server, - seats_taken=active_seats, - active_seats=active_seats, - hostname=config.get("server_name", ""), - server_fingerprint=config.get("server_fingerprint", "") - ) - except Exception: - pass - - -def trigger_async_seat_report(): - """Fires a non-blocking background thread to sync seat usage with upstream authority.""" - threading.Thread(target=report_seat_quota_to_authority_sync, daemon=True).start() - - 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", {}) @@ -2192,11 +1033,7 @@ async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - """ - mTLS TCP socket handler. - Extracts client CN (client_id) from the TLS handshake, validates active license status in SQLite, - updates last_seen, reads length-prefixed JSON payload, and ingests candidate logs. - """ + """mTLS TCP socket handler with certificate validation, decryption, and buffering.""" client_id = None ssl_obj = writer.get_extra_info("ssl_object") if ssl_obj: @@ -2208,7 +1045,6 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str client_id = val break - # If mTLS is enforced, verify client in accounting database if SERVER_STATE.get("tls_enabled", False): if not client_id: writer.close() @@ -2257,7 +1093,8 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str 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"] + min_runs=SERVER_STATE["config"]["min_persistence_runs"], + process_callback=process_ingested_logs ) else: res = { @@ -2296,119 +1133,16 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str pass -# --- API Endpoints: Authentication, Clients, Hermes Tokens & Licensing --- +# ===================================================================== +# 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=""" - - - - - LOGAR Hub - Login - - - - - - - -""") + return HTMLResponse(content=WebUIRenderer.render_login_html()) @app.post("/login") @@ -2416,7 +1150,7 @@ 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, _LOGIN_FAILURES, max_attempts=10, window_seconds=60, scope="login", db_path=db_path): + 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 @@ -2439,7 +1173,7 @@ async def process_login(request: Request): 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, _LOGIN_FAILURES, scope="login", 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") @@ -2453,11 +1187,11 @@ async def process_login(request: Request): "message": "Two-factor authentication code required" }) if not verify_totp_code(totp_info["secret"], totp_code): - record_failed_ip_attempt(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path) + 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, _LOGIN_FAILURES, scope="login", db_path=db_path) + 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}) @@ -2492,8 +1226,6 @@ async def api_change_password(req: ChangePasswordRequest, current_user: str = De return {"status": "success", "message": "Password updated successfully"} -# --- 2FA Authentication Management Endpoints --- - @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.""" @@ -2507,11 +1239,7 @@ 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 - } + return {"secret": secret, "otpauth_uri": otpauth, "username": auth_user} @app.post("/api/user/2fa/enable") @@ -2543,20 +1271,15 @@ async def api_user_2fa_disable(request: Request, auth_user: str = Depends(requir @app.post("/api/client/enroll") def enroll_client(req: ClientEnrollRequest, request: Request = None): - """ - Enrolls an edge client by validating the enrollment secret, - checking license seat limits, issuing a signed client certificate + key, - and recording the client in the SQLite accounting database. - """ + """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, _ENROLL_FAILURES, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path): + 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() - # 1. Validate enrollment secret against license_config c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1") row = c.fetchone() if not row: @@ -2571,11 +1294,11 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): conn.close() - record_failed_ip_attempt(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path) + 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, _ENROLL_FAILURES, scope="enroll", db_path=db_path) + 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") @@ -2585,7 +1308,6 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): conn.close() raise HTTPException(status_code=500, detail="Root CA not loaded on server") - # Ensure full trust chain (subordinate CA + upstream root CA if present) is delivered to edge clients 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): @@ -2597,110 +1319,89 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None): except Exception: pass - # 2. Check if client_id already registered c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) - client_row = c.fetchone() - if client_row: - if client_row[0] == "revoked": + existing = c.fetchone() + if existing: + if existing[0] == "revoked": conn.close() - raise HTTPException(status_code=403, detail="Client certificate has been revoked") + 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).") - # Re-issue for existing active client - client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key) - fp = enrollment.calculate_cert_fingerprint(client_cert_pem) - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + 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") + if existing: c.execute(""" UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = ? + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? WHERE client_id = ? """, (req.hostname, req.os, fp, now_sql, req.client_id)) - conn.commit() - conn.close() - print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})") - return { - "ca_cert": ca_cert_pem, - "client_cert": client_cert_pem, - "client_key": client_key_pem - } + else: + 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)) - # 3. New client: check seat limits - 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="License seat limit reached") - - # 4. Issue signed cert + key - client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key) - fp = enrollment.calculate_cert_fingerprint(client_cert_pem) - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - 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() - print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]") + 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, - "client_cert": client_cert_pem, - "client_key": client_key_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", dependencies=[Depends(require_admin_auth)]) -@app.post("/api/client/export-profile", dependencies=[Depends(require_admin_auth)]) +@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 + client_id: Optional[str] = None, + request: Request = None ): - """ - Exports a standalone client profile JSON configuration bundle - for zero-touch edge client bootstrapping. - Generates a fresh shared enrollment key and invalidates previous keys. - """ - config = SERVER_STATE.get("config", {}) - # Generate new shared enrollment secret and invalidate old key - rotate_enrollment_secret(config) + """Exports standalone client profile JSON for instant client deployment.""" + rotate_enrollment_secret() - host = server_host or config.get("tcp_host", "127.0.0.1") - if host in ("0.0.0.0", ""): - host = socket.gethostname() - port = int(server_port or config.get("tcp_port", 9443)) + 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() - profile = generate_client_profile_dict( - server_host=host, - server_port=port, - client_id=client_id, - config_dict=config - ) - profile_json = json.dumps(profile, indent=2) - - return Response( - content=profile_json, - media_type="application/json", - headers={"Content-Disposition": 'attachment; filename="client_config.json"'} + 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", dependencies=[Depends(require_admin_auth)]) -def reset_server_enrollment_secret(): - """ - Resets the shared enrollment secret key and immediately invalidates all previous keys. - """ +@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", - "enrollment_secret": new_secret, - "message": "Shared enrollment key has been reset and previous keys invalidated." + "message": "Server enrollment secret successfully rotated and updated.", + "enrollment_secret": new_secret } -@app.get("/api/clients", dependencies=[Depends(require_admin_auth)]) -def list_clients(): +@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) @@ -2709,23 +1410,38 @@ def list_clients(): 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 FROM clients ORDER BY last_seen DESC") + 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 = [ - { - "client_id": r[0], - "hostname": r[1], - "os_type": r[2], - "cert_fingerprint": r[3], - "status": r[4], - "first_seen": r[5], - "last_seen": r[6], - "last_activity_relative": format_relative_time(r[6]) - } - for r in rows - ] + 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, @@ -2734,9 +1450,9 @@ def list_clients(): } -@app.post("/api/clients/approve", dependencies=[Depends(require_admin_auth)]) -@app.post("/api/clients/reactivate", dependencies=[Depends(require_admin_auth)]) -def approve_client(action: ClientActionRequest): +@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) @@ -2762,13 +1478,11 @@ def approve_client(action: ClientActionRequest): return {"status": "active", "client_id": action.client_id} -# Backward-compatible function alias for test suite reactivate_client = approve_client - -@app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)]) -def revoke_client(action: ClientActionRequest): +@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") @@ -2786,9 +1500,10 @@ def revoke_client(action: ClientActionRequest): return {"status": "revoked", "client_id": action.client_id} -@app.post("/api/clients/delete", dependencies=[Depends(require_admin_auth)]) -@app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)]) -def delete_client(action: ClientActionRequest): +@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") @@ -2806,47 +1521,38 @@ def delete_client(action: ClientActionRequest): return {"status": "deleted", "client_id": action.client_id} -# --- Hermes API Tokens Endpoints --- - -@app.get("/api/hermes/tokens", dependencies=[Depends(require_admin_auth)]) -def api_list_hermes_tokens(): - """Lists all Hermes access tokens.""" +@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 {"tokens": list_hermes_tokens(db_path=db_path)} + return list_hermes_tokens(db_path=db_path) -@app.post("/api/hermes/tokens/create", dependencies=[Depends(require_admin_auth)]) -def api_create_hermes_token(req: HermesTokenCreateRequest): - """Generates a new Hermes API token.""" +@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) - token_data = create_hermes_token(name=req.name, db_path=db_path) - return {"status": "success", "token": token_data} + 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", dependencies=[Depends(require_admin_auth)]) -def api_revoke_hermes_token(req: HermesTokenRevokeRequest): - """Revokes a Hermes API token.""" +@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) - ok = revoke_hermes_token(req.token, db_path=db_path) - if not ok: + if not revoke_hermes_token(req.token, db_path=db_path): raise HTTPException(status_code=404, detail="Token not found") - return {"status": "success", "token": req.token} + return {"status": "success", "message": "Token revoked"} -@app.post("/api/hermes/tokens/delete", dependencies=[Depends(require_admin_auth)]) -def api_delete_hermes_token(req: HermesTokenRevokeRequest): - """Deletes a Hermes API token.""" +@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) - ok = delete_hermes_token(req.token, db_path=db_path) - if not ok: + if not delete_hermes_token(req.token, db_path=db_path): raise HTTPException(status_code=404, detail="Token not found") - return {"status": "success", "token": req.token} + return {"status": "success", "message": "Token deleted"} -# --- Hermes Reporting Endpoints --- - @app.get("/api/hermes/report") -def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admin_auth)): +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, @@ -2856,7 +1562,6 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi conn = sqlite3.connect(db_path) cursor = conn.cursor() - # 1. Fetch verified active issues cursor.execute(""" SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues @@ -2865,7 +1570,6 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi """) rows = cursor.fetchall() - # 2. Check for offline / silent edge nodes (Heartbeat Liveness Check) 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'") @@ -2877,7 +1581,6 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12) report = [] - # Map node telemetry and detect silent nodes client_metrics_map = {} for cl in client_rows: cid, chost, cost, clast, cmetrics_raw, ccpu, cmem, cdisk = cl @@ -2895,7 +1598,6 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi if chost: client_metrics_map[chost] = m_dict - # Liveness check: If active client has not checked in for > 15 minutes, report CRITICAL offline anomaly if clast: try: clast_clean = str(clast).replace("T", " ").split("+")[0].split("Z")[0].strip() @@ -2943,23 +1645,16 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi return report -@app.get("/api/hermes/all") -def get_all_issues(auth: str = Depends(require_hermes_or_admin_auth)): - """Diagnostic endpoint to inspect both transient candidate blips and verified anomalies.""" +@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) - 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 - """) - rows = cursor.fetchall() - conn.close() - - return [ + 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": r[1], + "site_name": r[1], "server": r[2], "signature": r[3], "severity": r[4], @@ -2970,433 +1665,263 @@ def get_all_issues(auth: str = Depends(require_hermes_or_admin_auth)): "run_count": r[9], "status": r[10] } - for r in rows + for r in c.fetchall() ] - - -def get_license_summary() -> Dict[str, Any]: - """Inspects certs/ca.crt, database quotas, and returns comprehensive licensing status.""" - config = SERVER_STATE.get("config", {}) - cert_dir = config.get("cert_dir", "certs") - ca_path = os.path.join(cert_dir, "ca.crt") - db_path = config.get("db_path", DEFAULT_DB_FILE) - - max_seats = config.get("max_seats", 0) - license_status_db = "unlicensed" - try: - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT max_seats, license_status FROM license_config WHERE id = 1") - row = c.fetchone() - if row: - if row[0] is not None: - max_seats = row[0] - if len(row) > 1 and row[1]: - license_status_db = row[1] - c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") - active_seats = c.fetchone()[0] - c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") - revoked_seats = c.fetchone()[0] - conn.close() - except Exception: - active_seats = 0 - revoked_seats = 0 - - csr_path = os.path.join(cert_dir, "ca.csr") - csr_exists = os.path.exists(csr_path) - - if os.path.exists(ca_path): - try: - with open(ca_path, "r", encoding="utf-8") as f: - ca_pem = f.read() - info = enrollment.extract_license_info_from_cert(ca_pem) - if info.get("seats"): - max_seats = info["seats"] - except Exception: - info = {"valid": False} - else: - info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0} - - license_id = info.get("license_id") or "UNLICENSED" - issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech") - - if info.get("is_expired"): - status_label = "EXPIRED" - elif info.get("seats") and not info.get("is_self_signed"): - status_label = "ACTIVE" - elif max_seats > 0 and license_status_db == "active": - status_label = "ACTIVE" - elif info.get("is_self_signed"): - status_label = "SELF-SIGNED" if max_seats > 0 else "UNLICENSED" - else: - status_label = "UNLICENSED" if max_seats <= 0 else "ACTIVE" - - pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0 - return { - "status": status_label, - "active_seats": active_seats, - "revoked_seats": revoked_seats, - "max_seats": max_seats, - "available_seats": max(0, max_seats - active_seats), - "percentage": min(100, pct), - "license_id": license_id, - "issuer_url": issuer_url, - "issuer_cn": info.get("issuer_cn", "N/A"), - "subject_cn": info.get("subject_cn", "N/A"), - "is_self_signed": info.get("is_self_signed", False), - "not_valid_before": info.get("not_valid_before", "N/A"), - "not_valid_after": info.get("not_valid_after", "N/A"), - "days_remaining": info.get("days_remaining", 0), - "fingerprint": info.get("fingerprint", "N/A"), - "csr_exists": csr_exists, - "server_fingerprint": config.get("server_fingerprint", "N/A"), - "features": get_active_licensed_features(db_path) - } + conn.close() + return issues @app.get("/api/license/status") def api_license_status(): - """Returns license details, seat quotas, and certificate validity.""" return get_license_summary() -@app.get("/api/license/export-lic", dependencies=[Depends(require_admin_auth)]) -def export_lic_file(): - """Generates and downloads the logar_server.lic file containing CA CSR and authority coordinates.""" +@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": 'attachment; filename="logar_server.lic"'} + headers={"Content-Disposition": f'attachment; filename="logar_license_request_{hostname}.lic"'} ) -@app.get("/api/license/csr", dependencies=[Depends(require_admin_auth)]) -def get_ca_csr(): - """Returns raw PEM Certificate Signing Request (CSR) for subordinate CA.""" - config = SERVER_STATE.get("config", {}) - cert_dir = config.get("cert_dir", "certs") +@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): - csr_pem, _ = enrollment.generate_ca_csr(cert_dir=cert_dir) - else: - with open(csr_path, "r", encoding="utf-8") as f: - csr_pem = f.read() - return {"csr": csr_pem} + 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", dependencies=[Depends(require_admin_auth)]) -async def import_signed_license(req: Request): - """Imports and activates a signed CA certificate or .lic file.""" +@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") + try: - body = await req.json() - except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON body") + info = enrollment.extract_license_info_from_cert(req.ca_cert) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}") - if isinstance(body, dict): - if "master_root_ca" in body or "upstream_ca" in body: - raw_text = json.dumps(body) - elif "lic" in body: - raw_text = json.dumps(body["lic"]) if isinstance(body["lic"], dict) else str(body["lic"]) - elif "ca_cert" in body and not body.get("master_root_ca"): - raw_text = str(body["ca_cert"]) - else: - raw_text = json.dumps(body) - else: - raw_text = str(body) + 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", {}) - cert_dir = config.get("cert_dir", "certs") + if new_seats: + config["max_seats"] = new_seats + if license_id: + config["license_id"] = license_id - try: - res = enrollment.import_license_package( - raw_text, - cert_dir=cert_dir, - server_fingerprint=config.get("server_fingerprint", ""), - hostname=config.get("server_name", socket.gethostname()) - ) - except ValueError as ve: - raise HTTPException(status_code=400, detail=str(ve)) - - info = res["license_info"] - ca_cert_pem = res["ca_cert_pem"] - new_seats = info.get("seats") or config.get("max_seats", 0) - config["max_seats"] = new_seats 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' + SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? WHERE id = 1 - """, (new_seats, info.get("license_id"), info.get("not_valid_before"), info.get("not_valid_after"))) + """, (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) conn.commit() conn.close() - # Regenerate server TLS certificate signed by new CA - ca_cert, ca_key, _, _ = 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_cert_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 - ) - - # In-flight SSLContext hot reload - ssl_ctx = SERVER_STATE.get("ssl_ctx") - if ssl_ctx: - reload_mtls_context(ssl_ctx, cert_dir=cert_dir) - print("[+] In-flight mTLS SSLContext successfully reloaded with newly imported CA.") - - return { - "status": "success", - "message": f"License successfully activated for {new_seats} seats.", - "license_id": info.get("license_id"), - "seats": new_seats, - "expires_at": info.get("not_valid_after"), - "days_remaining": info.get("days_remaining") - } - - -@app.post("/api/license/renew", dependencies=[Depends(require_admin_auth)]) -@app.post("/api/license/sync", dependencies=[Depends(require_admin_auth)]) -async def trigger_license_renewal(request: Request): - """Explicitly triggers automated mTLS sync and renewal with logar.eibl.tech.""" - requested_seats = None - seat_increment = 0 - try: - body = await request.json() - if isinstance(body, dict): - requested_seats = body.get("requested_seats") - seat_increment = int(body.get("seat_increment", 0)) - except Exception: - pass - - config = SERVER_STATE.get("config", {}) - cert_dir = config.get("cert_dir", "certs") - lic_server = config.get("license_server_url", "https://logar.eibl.tech") - db_path = config.get("db_path", DEFAULT_DB_FILE) - - active_seats = 0 - try: - conn_cnt = sqlite3.connect(db_path) - c_cnt = conn_cnt.cursor() - c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") - row = c_cnt.fetchone() - if row: - active_seats = row[0] - conn_cnt.close() - except Exception: - pass - - success, msg, new_pem = enrollment.renew_ca_cert_mtls( - cert_dir=cert_dir, - license_server_url=lic_server, - requested_seats=requested_seats, - seat_increment=seat_increment, - 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=400, detail=msg) - - info = enrollment.extract_license_info_from_cert(new_pem) - if info.get("seats"): - config["max_seats"] = info["seats"] - db_path = config.get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (info["seats"],)) - 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: - reload_mtls_context(ssl_ctx, cert_dir=cert_dir) - print(f"[+] In-flight mTLS SSLContext successfully reloaded after renewal/sync (Seats: {info.get('seats')}).") + 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}") -# --- Database Engine & Ingestion Buffer Endpoints --- + trigger_async_seat_report() -@app.get("/api/db/stats", dependencies=[Depends(require_admin_auth)]) -def api_db_stats(): - """Returns real-time buffer throughput, queue stats, and storage engine metrics.""" return { "status": "success", - "queue": INGEST_QUEUE.get_stats(), - "storage": get_storage_engine_info(), - "enterprise_db_licensed": is_feature_licensed("enterprise_db") + "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.post("/api/db/flush-buffer", dependencies=[Depends(require_admin_auth)]) -def api_db_flush_buffer(): - """Manually forces immediate drain and write of all buffered anomalies to the database.""" +@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) + 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).", - "flush_details": res, - "queue": INGEST_QUEUE.get_stats() + "details": res } -@app.post("/api/db/test-connection", dependencies=[Depends(require_admin_auth)]) -def api_db_test_connection(req: DbConnectionTestRequest): - """Tests connection to external PostgreSQL database. Requires enterprise_db license feature.""" - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) +@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 feature is locked. This capability requires a valid license with the 'enterprise_db' feature." - ) - ok, msg = init_postgres_db(req.model_dump()) + 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=msg) - return {"status": "success", "message": "Successfully connected to PostgreSQL and verified schema."} + raise HTTPException(status_code=400, detail=f"Database connection failed: {msg}") + return {"status": "success", "message": msg} -@app.post("/api/db/switch-backend", dependencies=[Depends(require_admin_auth)]) -def api_db_switch_backend(req: DbSwitchBackendRequest): - """Switches active database backend between SQLite and PostgreSQL. Requires enterprise_db license feature.""" +@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", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - if not is_feature_licensed("enterprise_db", db_path): - raise HTTPException( - status_code=403, - detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature." - ) - if req.db_type == "postgres": - pg_cfg = { - "host": req.host or "localhost", - "port": req.port or 5432, - "database": req.database or "logar", - "user": req.user or "postgres", - "password": req.password or "", - "sslmode": req.sslmode or "prefer" - } - ok, msg = init_postgres_db(pg_cfg) - if not ok: - raise HTTPException(status_code=400, detail=f"Cannot switch backend: {msg}") - config["db_type"] = "postgres" - config["postgres"] = pg_cfg - else: - config["db_type"] = "sqlite" - if req.db_path: - config["db_path"] = req.db_path - cfg_path = config.get("config_file_path") - if cfg_path and os.path.exists(cfg_path): - try: - with open(cfg_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - except Exception: - pass + 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 successfully switched to {config.get('db_type', 'sqlite')}.", - "storage": get_storage_engine_info() + "message": f"Storage backend switched to {config['storage_engine']}.", + "storage_info": get_storage_engine_info() } -@app.post("/api/db/config", dependencies=[Depends(require_admin_auth)]) -def api_db_update_config(req: DbFullConfigRequest): - """Updates complete database storage engine, cache mode, PostgreSQL parameters, and encryption at rest.""" +@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", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - is_unlocked = is_feature_licensed("enterprise_db", db_path) + is_licensed = is_feature_licensed("enterprise_db", db_path) - if not is_unlocked: - if req.storage_engine == "postgres": - raise HTTPException( - status_code=403, - detail="External PostgreSQL storage is locked. This capability requires the 'enterprise_db' license feature." - ) - if req.cache_mode == "buffered": - raise HTTPException( - status_code=403, - detail="In-memory ingestion buffering is locked. This capability requires the 'enterprise_db' license feature." - ) - if req.encrypt_at_rest: - raise HTTPException( - status_code=403, - detail="Data-at-rest OpenPGP encryption is locked. This capability requires the 'enterprise_db' license feature." - ) - req.storage_engine = "sqlite" - req.cache_mode = "direct" - req.encrypt_at_rest = False + 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["storage_engine"] = req.storage_engine - config["db_type"] = req.storage_engine 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 = max(0.5, float(req.flush_interval)) - INGEST_QUEUE.batch_size = max(1, int(req.batch_size)) - INGEST_QUEUE.max_queue = max(100, int(req.max_queue)) - config["flush_interval"] = INGEST_QUEUE.flush_interval - config["batch_size"] = INGEST_QUEUE.batch_size - config["max_queue"] = INGEST_QUEUE.max_queue + INGEST_QUEUE.flush_interval = req.flush_interval + INGEST_QUEUE.batch_size = req.batch_size + INGEST_QUEUE.max_queue = req.max_queue - if req.pg_config and is_unlocked: - pg_dict = req.pg_config.model_dump() - config["postgres"] = pg_dict - if req.storage_engine == "postgres": - ok, msg = init_postgres_db(pg_dict) - if not ok: - raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}") - - cfg_path = config.get("config_file_path") - if cfg_path and os.path.exists(cfg_path): - try: - with open(cfg_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - except Exception: - pass + 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": "Database storage engine, cache mode, and encryption settings successfully updated.", - "storage": get_storage_engine_info() + "message": "Storage & Ingestion parameters updated successfully.", + "storage_info": get_storage_engine_info() } -@app.post("/api/db/migrate-to-postgres", dependencies=[Depends(require_admin_auth)]) -def api_db_migrate_to_postgres(req: DbConnectionTestRequest): - """Migrates all telemetry, runs, clients, users, and license configs from SQLite to PostgreSQL.""" +@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", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) + if not is_feature_licensed("enterprise_db", db_path): - raise HTTPException( - status_code=403, - detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature." - ) - sqlite_path = config.get("db_path", DEFAULT_DB_FILE) - - INGEST_QUEUE.flush(db_path=sqlite_path) - - ok, msg, counts = migrate_sqlite_to_postgres(sqlite_path, req.model_dump()) + 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=400, detail=msg) + raise HTTPException(status_code=500, detail=f"Migration failed: {msg}") + return { "status": "success", "message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.", @@ -3404,8 +1929,6 @@ def api_db_migrate_to_postgres(req: DbConnectionTestRequest): } -# --- Comprehensive Info WebUI Dashboard --- - @app.get("/", response_class=HTMLResponse) @app.get("/webui", response_class=HTMLResponse) def serve_webui(request: Request = None): @@ -3418,1508 +1941,12 @@ def serve_webui(request: Request = None): else: auth_user = DEFAULT_ADMIN_USER - summary = get_license_summary() - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - - is_db_unlocked = is_feature_licensed("enterprise_db", db_path) - queue_stats = INGEST_QUEUE.get_stats() - storage_info = get_storage_engine_info() - pg_config = config.get("postgres", {}) - - # Fetch clients - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - clients = c.fetchall() - - # Fetch verified anomalies - c.execute("SELECT fingerprint, server, severity, message, run_count, last_seen FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC LIMIT 50") - anomalies_raw = c.fetchall() - anomalies = [] - for an in anomalies_raw: - afp, srv, sev, msg, rc, ls = an - decrypted_msg = decrypt_field_from_storage(msg) - anomalies.append((afp, srv, sev, decrypted_msg, rc, ls)) - - # Fetch hermes tokens - c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC") - hermes_tokens = c.fetchall() - conn.close() - - # Build client rows with exact + relative activity time and seat management actions - client_rows = [] - for cl in clients: - cid, host, ost, fp, st, first, last = cl - esc_cid = html.escape(str(cid)) - esc_host = html.escape(str(host)) - esc_ost = html.escape(str(ost)) - esc_fp = html.escape(str(fp)) - esc_st = html.escape(str(st)) - badge_cls = "badge-active" if st == "active" else "badge-revoked" - rel_time = format_relative_time(last) - last_display = f"
{html.escape(str(last[:19]))}
{html.escape(rel_time)}
" - - if fp == "LOCAL-HOST-BUILTIN": - esc_st = "LOCAL HUB HOST" - badge_cls = "badge-active" - fp_display = '⚡ Built-in Log Collector' - btn_action = 'Integrated (0 Seats)' - btn_del = '' - else: - fp_display = f"{esc_fp[:16]}..." - btn_action = ( - f'' - if st == "active" else - f'' - ) - btn_del = f'' - - client_rows.append(f""" - - {esc_st.upper()} - {esc_host} - {esc_cid} - {esc_ost.upper()} - {fp_display} - {last_display} - -
- {btn_action} - {btn_del} -
- - - """) - clients_html = "".join(client_rows) if client_rows else 'No edge clients registered yet.' - - # Build anomaly rows - anomaly_rows = [] - for an in anomalies: - afp, srv, sev, msg, rc, ls = an - esc_srv = html.escape(str(srv)) - esc_sev = html.escape(str(sev)) - esc_msg = html.escape(str(msg)) - esc_ls = html.escape(str(ls[:19])) - sev_cls = "badge-error" if sev in ("ERROR", "CRITICAL", "FATAL") else "badge-warning" - short_msg = msg[:80] + "..." if len(msg) > 80 else msg - esc_short_msg = html.escape(str(short_msg)) - anomaly_rows.append(f""" - - {esc_sev} - {esc_srv} - {int(rc)} runs - {esc_short_msg} - {esc_ls} - - """) - anomalies_html = "".join(anomaly_rows) if anomaly_rows else 'No verified anomalies currently reported.' - - # Build hermes tokens rows - token_rows = [] - for tk in hermes_tokens: - tok, tname, tcreated, tlast, tstatus = tk - esc_tok = html.escape(str(tok)) - esc_tname = html.escape(str(tname)) - esc_tstatus = html.escape(str(tstatus)) - esc_tcreated = html.escape(str(tcreated[:19])) - esc_tlast = html.escape(str(tlast[:19])) if tlast else 'Never' - tbadge = "badge-active" if tstatus == "active" else "badge-revoked" - masked = esc_tok[:10] + "••••••••" + esc_tok[-4:] - tok_action = f'' if tstatus == "active" else f'' - token_rows.append(f""" - - {esc_tname} - {masked} - {esc_tstatus.upper()} - {esc_tcreated} - {esc_tlast} - -
- - {tok_action} -
- - - """) - tokens_html = "".join(token_rows) if token_rows else 'No Hermes API tokens created yet.' - - # Progress bar color logic - pct = summary["percentage"] - bar_color = "#10b981" if pct < 70 else ("#f59e0b" if pct < 90 else "#ef4444") - badge_status_cls = "badge-active" if summary["status"] == "ACTIVE" else ("badge-warning" if summary["status"] == "SELF-SIGNED" else "badge-revoked") - - unlicensed_banner_html = "" - if summary["status"] == "UNLICENSED" or summary["max_seats"] == 0: - unlicensed_banner_html = f""" -
-
-
⚠️
-
-
LOGAR Server is Unlicensed (0 Active Seats)
-
Forwarder enrollment and log ingestion are restricted. Import an activated .lic package to enable edge client connections and seat provisioning.
-
-
-
- -
-
- """ - - detected_host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or config.get("tcp_host", "127.0.0.1") - if detected_host in ("0.0.0.0", "localhost", ""): - detected_host = socket.gethostname() - - is_encrypt_at_rest = config.get("encrypt_at_rest", False) and is_db_unlocked - encrypt_status_label = "🔒 OpenPGP Encrypted" if is_encrypt_at_rest else "🔓 Plaintext Storage" - encrypt_status_color = "#34d399" if is_encrypt_at_rest else "#94a3b8" - - # Grayed-out styling & attributes for unlicensed enterprise features - ent_badge = 'ENTERPRISE LICENSE REQUIRED' - buf_disabled_attr = "" if is_db_unlocked else "disabled" - buf_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" - enc_disabled_attr = "" if is_db_unlocked else "disabled" - enc_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" - pg_disabled_attr = "" if is_db_unlocked else "disabled" - pg_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" - - active_features = summary.get("features", []) - feature_cards = [] - if active_features: - for feat in active_features: - if feat == "enterprise_db": - feature_cards.append(""" -
-
🐘
-
-
- enterprise_db - AUTHORIZED -
-
- Enterprise Database & High-Throughput Engine: Unlocks external PostgreSQL storage clustering, customizable in-memory RAM buffering with batch flushing, and OpenPGP zero-knowledge data-at-rest encryption. -
-
-
- """) - else: - esc_feat = html.escape(str(feat)) - feat_title = html.escape(str(feat).replace("_", " ").title()) - feature_cards.append(f""" -
-
✨
-
-
- {esc_feat} - AUTHORIZED -
-
- {feat_title}: Advanced capability authorized by your license certificate. -
-
-
- """) - else: - feature_cards.append(""" -
-
-
No Enterprise Capabilities Active
-
Standard Community Edition is running. External PostgreSQL pooling, RAM queue buffering, and zero-knowledge encryption require an enterprise_db capability package.
-
- -
- """) - - features_block_html = "".join(feature_cards) - - ent_community_notice = "" if is_db_unlocked else f""" -
-
- 💡 Standard Local Storage: Embedded SQLite 3 storage with synchronous logging is active. High-throughput in-memory buffering, zero-knowledge OpenPGP at-rest encryption, and external PostgreSQL clustering require an enterprise_db license capability. -
- -
- """ - - db_tab_pane_html = f""" - -
- -
-
-
- 💾 Storage Engine & Live Telemetry Metrics -
Centralized high-availability SQL pooling, buffer telemetry, and encryption at rest
-
- {storage_info['engine'].upper()} BACKEND -
-
-
-
Active Storage Backend
-
{storage_info['backend_display']}
-
{storage_info['status']}
-
-
-
Telemetry Records
-
{storage_info['table_stats']['active_issues']} Issues / {storage_info['table_stats']['ingest_runs']} Runs
-
Clients: {storage_info['table_stats']['clients']} | Users: {storage_info['table_stats']['users']}
-
-
-
Buffered Events in RAM
-
{queue_stats['queue_size']} events
-
Ingested: {queue_stats['total_ingested']} | Flushed: {queue_stats['total_flushed']}
-
-
-
Data-at-Rest Security
-
{encrypt_status_label}
-
{'Zero-Knowledge Storage on External DB' if is_encrypt_at_rest else 'Local Encrypted/Plaintext Storage'}
-
-
-
- - -
-
- Database Storage Engine Configuration - CONFIG & TUNING -
- - {ent_community_notice} - - -
-
- ⚡ Ingestion Cache & Buffer Mode - -
- -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- 🔐 Zero-Knowledge Data Encryption at Rest -
- -

- When enabled, anomaly message payloads stored in the external database are encrypted with the server's OpenPGP public key before writing. Only this hub instance can decrypt records for authorized WebUI and Hermes AI usage, ensuring zero-knowledge privacy on remote database servers. -

-
- - -
-
- 🗄️ Select Storage Engine Backend -
-
- - -
- - -
-
- 🐘 External PostgreSQL Connection Parameters - ENTERPRISE POOL -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - -
- -
-
-
- """ - - html_content = f""" - - - - - LOGAR Hub - Fleet Aggregation & Management - - - - - -
-
-
-
- - - - - - - -
-
-

LOGAR Fleet Hub

-
Fleet Log Aggregation & Centralized Provisioning
-
-
-
- - -
-
- 👤 {auth_user} - ▼ -
-
-
Logged in as {auth_user}
- - - 🚪 Logout -
-
-
-
- -
- - {unlicensed_banner_html} - - -
- - - - - -
- - -
-
-
- Seat Quota Usage - {summary['status']} -
-
{summary['active_seats']} / {summary['max_seats']} Seats
-
{summary['available_seats']} seats available for automatic leasing
-
-
-
-
- -
-
- Hub Status - HEALTHY -
-
mTLS Port {config.get('tcp_port', 9443)}
-
Hermes Provisioning API: Port {config.get('hermes_port', 8443)}
-
- -
-
- Subordinate CA - X.509 SUB-CA -
-
{summary['license_id']}
-
Expires: {summary['not_valid_after'][:10] if summary['not_valid_after'] != 'N/A' else 'N/A'} ({summary['days_remaining']} days left)
-
- -
-
- Fleet Forwarders - {len(clients)} Total -
-
{summary['active_seats']} Active Nodes
-
{summary['revoked_seats']} revoked nodes blocked at TLS socket
-
-
- - -
-
-
- Enrolled Forwarders & Seat Management -
- - -
-
-
- - - - - - - - - - - - - - {clients_html} - -
StatusHost NodeClient IdentifierOSCert FingerprintLast Activity TimeSeat Actions
-
-
-
- - -
-
-
-
-
Hermes AI Bearer Access Tokens
-
Manage dedicated API tokens for Hermes AI to fetch real-time verified anomalies via GET /api/hermes/report
-
-
-
-
+ Generate Dedicated Hermes Token
-
- - -
-
-
- - - - - - - - - - - - - {tokens_html} - -
Token NameBearer TokenStatusCreatedLast UsedAction
-
- -
-
🚀 Hermes AI Integration Quickstart
-
Configure Hermes to query the verified log telemetry stream with Bearer authorization:
-
curl -H "Authorization: Bearer <HERMES_TOKEN>" http://{detected_host}:{config.get('hermes_port', 8443)}/api/hermes/report
-
-
-
- - -
-
-
- Verified Anomaly Stream (Latest 50 Events • Rolling 12h Window) - Direct Hermes Intelligence -
-
- - - - - - - - - - - - {anomalies_html} - -
SeveritySource NodePersistenceNormalized Anomaly MessageLast Ingested
-
-
-
- - -
-
-
PKI & Seat Synchronization:
-
- - -
-
- -
-
- Subordinate Certificate Details - {summary['status']} -
-
-
-
Subordinate CA Common Name
-
{summary['subject_cn']}
-
-
-
Master Root Issuer
-
{summary['issuer_cn']}
-
-
-
Master License Authority
-
{summary['issuer_url']}
-
-
-
Issued Seat Quota
-
{summary['max_seats']} Seats
-
-
-
Certificate Validity
-
{summary['not_valid_after'][:19] if summary['not_valid_after'] != 'N/A' else 'N/A'}
-
-
-
Server Hub Fingerprint
-
{summary['server_fingerprint']}
-
-
-
CA Certificate Fingerprint
-
{summary['fingerprint']}
-
-
-
- - -
-
-
- ✨ Licensed Capabilities & Feature Modules -
Features authorized by your subordinate CA license package and upstream authority
-
- {len(active_features)} ACTIVE FEATURES -
- -
- {features_block_html} -
-
-
- - {db_tab_pane_html} -
- - - - - - - - - - - - - - - -""" + 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) @@ -4944,12 +1971,10 @@ async def run_server(): hermes_port = int(config["hermes_port"]) ssl_ctx = SERVER_STATE.get("ssl_ctx") - # Start mTLS / TCP Socket Server 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}") - # Start FastAPI / Uvicorn server for Hermes & Enrollment 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") @@ -4978,7 +2003,7 @@ async def run_server(): pass try: db_p = config.get("db_path", DEFAULT_DB_FILE) - INGEST_QUEUE.flush(db_path=db_p) + INGEST_QUEUE.flush(db_path=db_p, process_callback=process_ingested_logs) except Exception: pass @@ -5005,7 +2030,7 @@ def main(): cert_dir = config.get("cert_dir", "certs") - # Handle standalone CLI license export + # 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: @@ -5013,7 +2038,7 @@ def main(): print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}") sys.exit(0) - # Handle standalone CLI CA / .lic import + # Standalone CLI CA / .lic import lic_file = args.import_lic or args.import_ca if lic_file: try: @@ -5035,7 +2060,6 @@ def main(): conn.commit() conn.close() - # Regenerate server certificate signed by new CA 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, @@ -5050,7 +2074,7 @@ def main(): print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}") sys.exit(0) - # Handle standalone CLI license sync / update against master server + # 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...") @@ -5084,7 +2108,6 @@ def main(): ) if not success or not res: - # Fallback to mTLS HTTP sync 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( @@ -5123,10 +2146,8 @@ def main(): print(f" - Expires: {info.get('not_valid_after')}") sys.exit(0) - # Initialize dynamic PKI (Subordinate CA and Server TLS Certificate) + # Initialize dynamic PKI ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir) - - # Auto-extract seats if ca.crt contains custom enterprise OID ca_info = enrollment.extract_license_info_from_cert(ca_pem) if ca_info.get("seats"): config["max_seats"] = ca_info["seats"] @@ -5135,7 +2156,6 @@ def main(): conn.commit() conn.close() - # Ensure CA CSR and exportable .lic exist for convenience csr_path = os.path.join(cert_dir, "ca.csr") if not os.path.exists(csr_path): enrollment.generate_ca_csr(cert_dir=cert_dir) @@ -5147,12 +2167,10 @@ def main(): cert_dir=cert_dir ) - # Initialize mTLS SSLContext if enabled ssl_ctx = None if config.get("tls_enabled", True): ssl_ctx = init_mtls_server_context(cert_dir=cert_dir) - # Load OpenPGP private key into memory (legacy fallback) priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"]) SERVER_STATE["config"] = config SERVER_STATE["private_key_obj"] = priv_key_obj diff --git a/src/auth_manager.py b/src/auth_manager.py new file mode 100644 index 0000000..2d3e509 --- /dev/null +++ b/src/auth_manager.py @@ -0,0 +1,398 @@ +""" +LOGAR - Server Authentication & Access Control Managers +Provides modular classes for Session Management, Rate Limiting, Password Hashing, +Two-Factor Authentication (TOTP / RFC 6238), and Hermes API Tokens. +""" + +import os +import time +import base64 +import struct +import secrets +import hashlib +import hmac +import sqlite3 +import urllib.parse +import threading +from datetime import datetime, timezone +from typing import Optional, Dict, Any, List, Tuple +from fastapi import Request, HTTPException + + +class SessionManager: + """Manages cryptographic session secrets and HMAC-signed session tokens.""" + + SESSION_COOKIE_NAME = "logar_server_session" + _CACHED_SESSION_SECRETS: Dict[str, str] = {} + + @classmethod + def get_session_secret(cls, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> str: + """Retrieves or persists a cryptographically secure session secret in the database.""" + env_sec = os.environ.get("LOGAR_SESSION_SECRET") + if env_sec: + return env_sec + target_db = db_path or fallback_db + abs_db = os.path.abspath(target_db) + if abs_db in cls._CACHED_SESSION_SECRETS: + return cls._CACHED_SESSION_SECRETS[abs_db] + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'") + row = c.fetchone() + if row and row[0]: + cls._CACHED_SESSION_SECRETS[abs_db] = row[0] + conn.close() + return row[0] + new_secret = secrets.token_hex(32) + c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,)) + conn.commit() + conn.close() + cls._CACHED_SESSION_SECRETS[abs_db] = new_secret + return new_secret + except Exception: + fallback = secrets.token_hex(32) + cls._CACHED_SESSION_SECRETS[abs_db] = fallback + return fallback + + @classmethod + def generate_session_token(cls, username: str, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> str: + """Generates an HMAC-signed session cookie string.""" + ts = int(datetime.now(timezone.utc).timestamp()) + nonce = secrets.token_hex(8) + payload = f"{username}:{ts}:{nonce}" + secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db) + sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + return f"{payload}:{sig}" + + @classmethod + def verify_session_token( + cls, + token: Optional[str], + max_age_hours: int = 24, + db_path: Optional[str] = None, + fallback_db: str = "logar_state.db" + ) -> Optional[str]: + """Verifies session token authenticity and age.""" + if not token or ":" not in token: + return None + parts = token.split(":") + if len(parts) != 4: + return None + username, ts_str, nonce, sig = parts + payload = f"{username}:{ts_str}:{nonce}" + secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db) + expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected_sig): + return None + try: + ts = int(ts_str) + if datetime.now(timezone.utc).timestamp() - ts > max_age_hours * 3600: + return None + except Exception: + return None + return username + + +class RateLimiter: + """Sliding-window IP rate limiter with persistent SQLite tracking and memory fallback.""" + + def __init__(self): + self._lock = threading.Lock() + self._login_failures: Dict[str, List[float]] = {} + self._enroll_failures: Dict[str, List[float]] = {} + + @staticmethod + def get_client_ip(request: Optional[Request] = None) -> str: + """Extracts client IP address respecting reverse-proxy headers.""" + if not request: + return "127.0.0.1" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + if request.client: + return request.client.host + return "127.0.0.1" + + def check_ip_rate_limit( + self, + ip: str, + store: Any = None, + max_attempts: int = 10, + window_seconds: int = 60, + scope: str = "login", + db_path: Optional[str] = None, + fallback_db: str = "logar_state.db" + ) -> bool: + """Checks whether the client IP has exceeded failed attempts within the sliding window.""" + now = datetime.now(timezone.utc).timestamp() + cutoff = now - window_seconds + target_db = db_path or fallback_db + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") + c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,)) + c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff)) + cnt = c.fetchone()[0] + conn.commit() + conn.close() + return cnt < max_attempts + except Exception: + with self._lock: + effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) + history = effective_store.get(ip, []) + history = [ts for ts in history if now - ts < window_seconds] + effective_store[ip] = history + return len(history) < max_attempts + + def record_failed_ip_attempt( + self, + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None, + fallback_db: str = "logar_state.db" + ): + """Records a persistent failed attempt timestamp for the client IP and scope.""" + now = datetime.now(timezone.utc).timestamp() + target_db = db_path or fallback_db + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)") + c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now)) + conn.commit() + conn.close() + except Exception: + pass + with self._lock: + effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) + if ip not in effective_store: + effective_store[ip] = [] + effective_store[ip].append(now) + + def reset_failed_ip_attempts( + self, + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None, + fallback_db: str = "logar_state.db" + ): + """Resets persistent failed attempt records for client IP upon successful authentication.""" + target_db = db_path or fallback_db + try: + conn = sqlite3.connect(target_db, timeout=5.0) + c = conn.cursor() + c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope)) + conn.commit() + conn.close() + except Exception: + pass + with self._lock: + effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures) + if ip in effective_store: + del effective_store[ip] + + +class PasswordManager: + """PBKDF2-HMAC-SHA256 password hashing and validation.""" + + @staticmethod + def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: + """Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations.""" + if not salt: + salt = secrets.token_hex(16) + pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex() + return pwd_hash, salt + + @staticmethod + def verify_password(password: str, salt: str, password_hash: str) -> bool: + """Verifies a password against the stored salt and PBKDF2 hash.""" + test_hash, _ = PasswordManager.hash_password(password, salt=salt) + return hmac.compare_digest(test_hash, password_hash) + + @staticmethod + def verify_user_credentials(username: str, password: str, db_path: str = "logar_state.db") -> bool: + """Checks user credentials against SQLite users table.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,)) + row = c.fetchone() + conn.close() + if not row: + return False + return PasswordManager.verify_password(password, row[1], row[0]) + + @staticmethod + def change_user_password(username: str, new_password: str, db_path: str = "logar_state.db") -> bool: + """Updates a user's password.""" + pwd_hash, salt = PasswordManager.hash_password(new_password) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username)) + changed = c.rowcount > 0 + conn.commit() + conn.close() + return changed + + +class TOTPManager: + """RFC 6238 Two-Factor Authentication (TOTP) generator and validator.""" + + @staticmethod + def generate_totp_secret(length: int = 20) -> str: + """Generates a cryptographically random Base32-encoded TOTP secret key.""" + return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "") + + @staticmethod + def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: + """Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238.""" + if intervals_no is None: + intervals_no = int(time.time()) // 30 + key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True) + msg = struct.pack(">Q", intervals_no) + h = hmac.new(key, msg, hashlib.sha1).digest() + o = h[19] & 15 + h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 + return f"{h_int:06d}" + + @staticmethod + def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: + """Verifies a 6-digit TOTP code with standard drift window (+/- 30s).""" + if not secret or not code: + return False + code = str(code).strip().replace(" ", "") + if len(code) != 6 or not code.isdigit(): + return False + now = int(time.time()) + current_step = now // 30 + for offset in range(-window, window + 1): + if hmac.compare_digest(TOTPManager.get_totp_token(secret, current_step + offset), code): + return True + return False + + @staticmethod + def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: + """Generates standard otpauth URI for QR code generation.""" + user_enc = urllib.parse.quote(username) + issuer_enc = urllib.parse.quote(issuer) + return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30" + + @staticmethod + def get_user_totp_info(username: str, db_path: str = "logar_state.db") -> Dict[str, Any]: + """Retrieves TOTP configuration for a user from the database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,)) + row = c.fetchone() + if row: + return {"secret": row[0] or "", "enabled": bool(row[1])} + except Exception: + pass + finally: + conn.close() + return {"secret": "", "enabled": False} + + @staticmethod + def enable_user_totp(username: str, secret: str, db_path: str = "logar_state.db") -> bool: + """Enables and activates TOTP for a user in the database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username)) + changed = c.rowcount > 0 + conn.commit() + return changed + finally: + conn.close() + + @staticmethod + def disable_user_totp(username: str, db_path: str = "logar_state.db") -> bool: + """Disables TOTP for a user in the database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,)) + changed = c.rowcount > 0 + conn.commit() + return changed + finally: + conn.close() + + +class HermesTokenManager: + """Manages Bearer API tokens for Hermes AI anomaly integration.""" + + @staticmethod + def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = "logar_state.db") -> Dict[str, Any]: + """Creates or saves a named Hermes API token.""" + if not token: + token = f"hermes_{secrets.token_hex(20)}" + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute( + "INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')", + (token, name, now_iso) + ) + conn.commit() + conn.close() + return {"token": token, "name": name, "created_at": now_iso, "status": "active"} + + @staticmethod + def list_hermes_tokens(db_path: str = "logar_state.db") -> List[Dict[str, Any]]: + """Lists all registered Hermes access tokens.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC") + rows = [ + {"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]} + for r in c.fetchall() + ] + conn.close() + return rows + + @staticmethod + def revoke_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: + """Revokes an active Hermes token.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,)) + changed = c.rowcount > 0 + conn.commit() + conn.close() + return changed + + @staticmethod + def delete_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: + """Deletes a Hermes token.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,)) + changed = c.rowcount > 0 + conn.commit() + conn.close() + return changed + + @staticmethod + def verify_hermes_token(token: str, db_path: str = "logar_state.db") -> bool: + """Verifies a Hermes token and updates last_used timestamp.""" + if not token: + return False + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,)) + row = c.fetchone() + if row: + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token)) + conn.commit() + conn.close() + return row is not None diff --git a/src/ingest_queue.py b/src/ingest_queue.py new file mode 100644 index 0000000..9abddb1 --- /dev/null +++ b/src/ingest_queue.py @@ -0,0 +1,114 @@ +""" +LOGAR - Ingestion Queue Manager +Provides a high-throughput thread-safe ring buffer and batch processor for incoming telemetry. +Eliminates disk write contention by decoupling TCP frame ingress from physical database writes. +""" + +import time +import threading +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional, Callable + + +class IngestQueueManager: + """ + High-throughput in-memory buffering and batch processor for incoming telemetry and log anomalies. + Eliminates SQLite / SQL disk write contention by decoupling TCP frame ingress from physical DB writes. + """ + def __init__(self, flush_interval: float = 1.0, batch_size: int = 100, max_queue: int = 50000): + self.flush_interval = flush_interval + self.batch_size = batch_size + self.max_queue = max_queue + self._lock = threading.Lock() + self._buffer: List[Dict[str, Any]] = [] + self._total_ingested = 0 + self._total_flushed = 0 + self._dropped_count = 0 + self._last_flush_time = "Never" + self._avg_flush_latency_ms = 0.0 + self._flush_count = 0 + + def push(self, payload: Dict[str, Any]) -> bool: + """Pushes a log payload into the ring buffer.""" + with self._lock: + if len(self._buffer) >= self.max_queue: + self._dropped_count += 1 + return False + self._buffer.append(payload) + self._total_ingested += len(payload.get("logs", [])) + return True + + def get_stats(self) -> Dict[str, Any]: + """Returns live statistics about queue occupancy, throughput, and flush latency.""" + with self._lock: + return { + "queue_size": len(self._buffer), + "total_ingested": self._total_ingested, + "total_flushed": self._total_flushed, + "dropped_count": self._dropped_count, + "last_flush_time": self._last_flush_time, + "avg_flush_latency_ms": round(self._avg_flush_latency_ms, 2), + "flush_count": self._flush_count, + "flush_interval_seconds": self.flush_interval, + "batch_size_threshold": self.batch_size + } + + def flush( + self, + db_path: Optional[str] = None, + window_hours: int = 12, + min_runs: int = 1, + process_callback: Optional[Callable[..., Dict[str, Any]]] = None + ) -> Dict[str, Any]: + """Flushes all queued items to persistent storage.""" + with self._lock: + if not self._buffer: + return {"status": "noop", "flushed_runs": 0, "processed_logs": 0} + items_to_process = list(self._buffer) + self._buffer.clear() + + start_time = time.time() + target_db = db_path or "logar_state.db" + + total_processed = 0 + promoted_total = 0 + + try: + for payload in items_to_process: + if process_callback: + res = process_callback( + payload=payload, + db_path=target_db, + window_hours=window_hours, + min_runs=min_runs + ) + else: + # Fallback if imported directly + from src import Server + res = Server.process_ingested_logs( + payload=payload, + db_path=target_db, + window_hours=window_hours, + min_runs=min_runs + ) + total_processed += res.get("processed", 0) + promoted_total += res.get("promoted_verified", 0) + except Exception as e: + print(f"[!] Error during batch flush to DB: {e}") + + elapsed_ms = (time.time() - start_time) * 1000.0 + with self._lock: + self._total_flushed += total_processed + self._flush_count += 1 + self._avg_flush_latency_ms = ( + (self._avg_flush_latency_ms * (self._flush_count - 1) + elapsed_ms) / self._flush_count + ) + self._last_flush_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + return { + "status": "success", + "flushed_runs": len(items_to_process), + "processed_logs": total_processed, + "promoted_verified": promoted_total, + "flush_latency_ms": round(elapsed_ms, 2) + } diff --git a/src/license_manager.py b/src/license_manager.py new file mode 100644 index 0000000..f103765 --- /dev/null +++ b/src/license_manager.py @@ -0,0 +1,225 @@ +""" +LOGAR - Licensing & Sub-CA Introspection Manager +Provides modular classes for PKI X.509 extension parsing, capability gating, +Subordinate CA seat tracking, and asynchronous license reporting. +""" + +import os +import json +import socket +import sqlite3 +import threading +from typing import Optional, Dict, Any, List, Tuple + +try: + from src import server_enrollment as enrollment +except ImportError: + import server_enrollment as enrollment + + +class LicenseManager: + """Manages PKI licensing introspection, feature unlocking, and upstream seat syncing.""" + + DEFAULT_DB_FILE = "logar_state.db" + + @staticmethod + def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + """Generates CA CSR and packages into exportable .lic dictionary and JSON string.""" + cert_dir = config.get("cert_dir", "certs") + lic_server = config.get("license_server_url", "https://logar.eibl.tech") + hostname = socket.gethostname() + seats = config.get("max_seats", 10) + fp = config.get("server_fingerprint", "") + return enrollment.create_license_request_lic( + cert_dir=cert_dir, + license_server=lic_server, + server_fingerprint=fp, + hostname=hostname, + requested_seats=seats + ) + + @staticmethod + def get_active_licensed_features(db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> List[str]: + """ + Returns the list of all active enterprise capability identifiers + authorized either via X.509 certificate extensions or the persistent database license config. + """ + features = set() + state = server_state or {} + + # 1. Check in-memory / active Subordinate CA certificate + ca_pem = state.get("ca_cert_pem") + if not ca_pem: + cert_dir = state.get("config", {}).get("cert_dir", "certs") + ca_file = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_file): + try: + with open(ca_file, "r", encoding="utf-8") as f: + ca_pem = f.read() + except Exception: + pass + + if ca_pem: + try: + info = enrollment.extract_license_info_from_cert(ca_pem) + for feat in info.get("features", []): + if feat: + features.add(str(feat).strip()) + except Exception: + pass + + # 2. Check license_config table in database + target_db = db_path or state.get("config", {}).get("db_path", LicenseManager.DEFAULT_DB_FILE) + if target_db and os.path.exists(target_db): + try: + conn = sqlite3.connect(target_db, timeout=3.0) + c = conn.cursor() + c.execute("SELECT features FROM license_config WHERE id = 1") + row = c.fetchone() + conn.close() + if row and row[0]: + feat_raw = row[0] + if isinstance(feat_raw, str): + if feat_raw.startswith("["): + feat_list = json.loads(feat_raw) + else: + feat_list = [f.strip() for f in feat_raw.split(",") if f.strip()] + for f_item in feat_list: + if f_item: + features.add(str(f_item).strip()) + elif isinstance(feat_raw, list): + for f_item in feat_raw: + if f_item: + features.add(str(f_item).strip()) + except Exception: + pass + + return sorted(list(features)) + + @staticmethod + def is_feature_licensed(feature: str, db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> bool: + """Checks whether a specific enterprise capability (e.g. 'enterprise_db') is authorized.""" + if not feature: + return False + return feature in LicenseManager.get_active_licensed_features(db_path=db_path, server_state=server_state) + + @staticmethod + def get_license_summary(server_state: Optional[Dict[str, Any]] = None, db_path: Optional[str] = None) -> Dict[str, Any]: + """Inspects certs/ca.crt, database quotas, and returns comprehensive licensing status.""" + state = server_state or {} + config = state.get("config", {}) + cert_dir = config.get("cert_dir", "certs") + ca_path = os.path.join(cert_dir, "ca.crt") + target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE) + + max_seats = config.get("max_seats", 0) + license_status_db = "unlicensed" + try: + conn = sqlite3.connect(target_db) + c = conn.cursor() + c.execute("SELECT max_seats, license_status FROM license_config WHERE id = 1") + row = c.fetchone() + if row: + if row[0] is not None: + max_seats = row[0] + if len(row) > 1 and row[1]: + license_status_db = row[1] + c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") + active_seats = c.fetchone()[0] + c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") + revoked_seats = c.fetchone()[0] + conn.close() + except Exception: + active_seats = 0 + revoked_seats = 0 + + csr_path = os.path.join(cert_dir, "ca.csr") + csr_exists = os.path.exists(csr_path) + + if os.path.exists(ca_path): + try: + with open(ca_path, "r", encoding="utf-8") as f: + ca_pem = f.read() + info = enrollment.extract_license_info_from_cert(ca_pem) + if info.get("seats"): + max_seats = info["seats"] + except Exception: + info = {"valid": False} + else: + info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0} + + license_id = info.get("license_id") or "UNLICENSED" + issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech") + + if info.get("is_expired"): + status_label = "EXPIRED" + elif info.get("seats") and not info.get("is_self_signed"): + status_label = "ACTIVE" + elif max_seats > 0 and license_status_db == "active": + status_label = "ACTIVE" + elif info.get("is_self_signed"): + status_label = "SELF-SIGNED" if max_seats > 0 else "UNLICENSED" + else: + status_label = "UNLICENSED" if max_seats <= 0 else "ACTIVE" + + pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0 + return { + "status": status_label, + "active_seats": active_seats, + "active_seats_taken": active_seats, + "revoked_seats": revoked_seats, + "max_seats": max_seats, + "available_seats": max(0, max_seats - active_seats), + "percentage": min(100, pct), + "license_id": license_id, + "issuer_url": issuer_url, + "issuer_cn": info.get("issuer_cn", "N/A"), + "subject_cn": info.get("subject_cn", "N/A"), + "is_self_signed": info.get("is_self_signed", False), + "not_valid_before": info.get("not_valid_before", "N/A"), + "not_valid_after": info.get("not_valid_after", "N/A"), + "days_remaining": info.get("days_remaining", 0), + "fingerprint": info.get("fingerprint", "N/A"), + "csr_exists": csr_exists, + "server_fingerprint": config.get("server_fingerprint", "N/A"), + "features": LicenseManager.get_active_licensed_features(db_path=target_db, server_state=state) + } + + @staticmethod + def report_seat_quota_to_authority_sync(server_state: Optional[Dict[str, Any]] = None): + """Background helper to push active seat count to upstream authority without blocking.""" + try: + state = server_state or {} + config = state.get("config", {}) + cert_dir = config.get("cert_dir", "certs") + db_path = config.get("db_path", LicenseManager.DEFAULT_DB_FILE) + lic_server = config.get("license_server_url", "https://logar.eibl.tech") + if not os.path.exists(os.path.join(cert_dir, "ca.crt")) or not os.path.exists(os.path.join(cert_dir, "ca.key")): + return + + 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 + + enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=lic_server, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") + ) + except Exception: + pass + + @staticmethod + def trigger_async_seat_report(server_state: Optional[Dict[str, Any]] = None): + """Fires a non-blocking background thread to sync seat usage with upstream authority.""" + threading.Thread(target=LicenseManager.report_seat_quota_to_authority_sync, args=(server_state,), daemon=True).start() diff --git a/src/pki_manager.py b/src/pki_manager.py new file mode 100644 index 0000000..d744454 --- /dev/null +++ b/src/pki_manager.py @@ -0,0 +1,141 @@ +""" +LOGAR - Dynamic PKI & mTLS Context Manager +Provides modular classes for TLS 1.3 socket contexts, in-flight certificate rotation, +and automated Sub-CA renewal. +""" + +import os +import ssl +import sqlite3 +from typing import Optional, List, Dict, Any + +try: + from src import server_enrollment as enrollment +except ImportError: + import server_enrollment as enrollment + + +class PKIManager: + """Manages server TLS certificates, mTLS SSL contexts, and hot certificate reloading.""" + + @staticmethod + def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: + """Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS).""" + ca_file = os.path.join(cert_dir, "ca.crt") + srv_cert = os.path.join(cert_dir, "server.crt") + srv_key = os.path.join(cert_dir, "server.key") + + ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) + ctx.load_verify_locations(cafile=ca_file) + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_file): + try: + ctx.load_verify_locations(cafile=upstream_file) + except Exception: + pass + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + return ctx + + @staticmethod + def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): + """ + Dynamically reloads server certificate chain and Root CA in an active SSLContext. + Allows in-flight TLS certificate rotation without dropping the listening socket. + """ + ca_file = os.path.join(cert_dir, "ca.crt") + srv_cert = os.path.join(cert_dir, "server.crt") + srv_key = os.path.join(cert_dir, "server.key") + + ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) + ssl_ctx.load_verify_locations(cafile=ca_file) + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_file): + try: + ssl_ctx.load_verify_locations(cafile=upstream_file) + except Exception: + pass + + @staticmethod + def check_and_rotate_server_certs( + cert_dir: str = "certs", + hostnames: Optional[List[str]] = None, + threshold_days: int = 30, + server_state: Optional[Dict[str, Any]] = None + ) -> bool: + """ + Checks if Root/Subordinate CA or server TLS certificate are expiring within threshold_days. + If a licensed Subordinate CA is expiring, automatically connects over mTLS to logar.eibl.tech + to request an in-flight renewal, then reloads the active SSLContext without socket interruption. + Returns True if renewed/reloaded, False otherwise. + """ + state = server_state or {} + config = state.get("config", {}) + license_server = config.get("license_server_url", "https://logar.eibl.tech") + ca_cert_path = os.path.join(cert_dir, "ca.crt") + + # 1. Check if CA certificate is expiring and attempt upstream mTLS renewal + if os.path.exists(ca_cert_path): + try: + with open(ca_cert_path, "r", encoding="utf-8") as f: + ca_pem = f.read() + if enrollment.is_cert_expiring_soon(ca_pem, threshold_days=threshold_days): + info = enrollment.extract_license_info_from_cert(ca_pem) + if not info.get("is_self_signed", True) or info.get("license_id"): + print("[*] Subordinate CA expiring soon. Initiating automated mTLS renewal with logar.eibl.tech...") + success, msg, new_pem = enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=license_server + ) + if success and new_pem: + print(f"[+] Automated mTLS renewal successful: {msg}") + new_info = enrollment.extract_license_info_from_cert(new_pem) + if new_info.get("seats"): + config["max_seats"] = new_info["seats"] + db_path = config.get("db_path", "logar_state.db") + conn = sqlite3.connect(db_path) + conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (new_info["seats"],)) + 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) + state["ca_cert"] = ca_cert + state["ca_key"] = ca_key + state["ca_cert_pem"] = ca_pem + enrollment.generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True) + ssl_ctx = state.get("ssl_ctx") + if ssl_ctx is not None: + try: + PKIManager.reload_mtls_context(ssl_ctx, cert_dir=cert_dir) + print("[+] In-flight mTLS SSLContext successfully reloaded with renewed CA.") + except Exception as e: + print(f"[!] Failed to reload in-flight SSLContext: {e}") + return True + else: + print(f"[!] Automated mTLS renewal attempt failed: {msg}") + except Exception as e: + print(f"[!] Error evaluating CA certificate for mTLS renewal: {e}") + + # 2. Local self-healing / regeneration check + ca_renewed, srv_renewed = enrollment.check_and_renew_hub_pki( + cert_dir=cert_dir, + hostnames=hostnames, + threshold_days=threshold_days + ) + if ca_renewed or srv_renewed: + print(f"[!] Server Hub PKI certificates renewed (CA renewed: {ca_renewed}, Server cert renewed: {srv_renewed}).") + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + state["ca_cert"] = ca_cert + state["ca_key"] = ca_key + state["ca_cert_pem"] = ca_pem + + ssl_ctx = state.get("ssl_ctx") + if ssl_ctx is not None: + try: + PKIManager.reload_mtls_context(ssl_ctx, cert_dir=cert_dir) + print("[+] In-flight mTLS SSLContext successfully reloaded with updated certificates.") + except Exception as e: + print(f"[!] Failed to reload in-flight SSLContext: {e}") + return True + return False diff --git a/src/server_models.py b/src/server_models.py new file mode 100644 index 0000000..feb2247 --- /dev/null +++ b/src/server_models.py @@ -0,0 +1,76 @@ +""" +LOGAR - Server Pydantic Models +Provides structured request and response models for the LOGAR Server Hub API. +""" + +from typing import Optional +from pydantic import BaseModel + + +class ClientEnrollRequest(BaseModel): + client_id: str + hostname: str + os: str + enrollment_secret: str + + +class ClientActionRequest(BaseModel): + client_id: str + + +class LicenseImportRequest(BaseModel): + ca_cert: str + upstream_ca: Optional[str] = None + + +class HermesTokenCreateRequest(BaseModel): + name: str + + +class HermesTokenRevokeRequest(BaseModel): + token: str + + +class ClientProfileExportRequest(BaseModel): + server_host: Optional[str] = None + server_port: Optional[int] = None + client_id: Optional[str] = None + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class DbConnectionTestRequest(BaseModel): + host: str = "localhost" + port: int = 5432 + database: str = "logar" + user: str = "postgres" + password: str = "" + sslmode: str = "prefer" + connect_timeout: int = 5 + pool_size: int = 10 + + +class DbSwitchBackendRequest(BaseModel): + db_type: str = "sqlite" # "sqlite" | "postgres" + host: Optional[str] = "localhost" + port: Optional[int] = 5432 + database: Optional[str] = "logar" + user: Optional[str] = "postgres" + password: Optional[str] = "" + sslmode: Optional[str] = "prefer" + connect_timeout: Optional[int] = 5 + pool_size: Optional[int] = 10 + db_path: Optional[str] = None + + +class DbFullConfigRequest(BaseModel): + storage_engine: str = "sqlite" # "sqlite" | "postgres" + cache_mode: str = "direct" # "direct" | "buffered" + flush_interval: float = 2.0 + batch_size: int = 100 + max_queue: int = 50000 + encrypt_at_rest: bool = False + pg_config: Optional[DbConnectionTestRequest] = None diff --git a/src/storage_engine.py b/src/storage_engine.py new file mode 100644 index 0000000..5889e0f --- /dev/null +++ b/src/storage_engine.py @@ -0,0 +1,638 @@ +""" +LOGAR - Multi-Backend Storage Engine & Data-at-Rest Crypto +Provides modular classes for Database schema management, SQLite WAL persistence, +PostgreSQL enterprise backend, and OpenPGP / AES-256-GCM data encryption at rest. +""" + +import os +import sys +import json +import sqlite3 +import secrets +from datetime import datetime, timezone +from typing import Optional, Dict, Any, List, Tuple, Callable +import pgpy +from pgpy.constants import ( + PubKeyAlgorithm, + KeyFlags, + HashAlgorithm, + SymmetricKeyAlgorithm, + CompressionAlgorithm +) + +from src.auth_manager import PasswordManager + + +class StorageCryptoManager: + """Manages OpenPGP keypairs and AES-256-GCM zero-knowledge data-at-rest encryption.""" + + @staticmethod + def generate_server_keypair(server_name: str) -> Tuple[str, str, str]: + """Generates an OpenPGP RSA 2048 key with encryption capability.""" + key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) + uid = pgpy.PGPUID.new(server_name) + key.add_uid( + uid, + usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage}, + hashes=[HashAlgorithm.SHA256, HashAlgorithm.SHA512], + ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.AES128], + compression=[ + CompressionAlgorithm.ZIP, + CompressionAlgorithm.ZLIB, + CompressionAlgorithm.BZ2, + CompressionAlgorithm.Uncompressed + ] + ) + private_key_armored = str(key) + public_key_armored = str(key.pubkey) + fingerprint = str(key.pubkey.fingerprint) + return private_key_armored, public_key_armored, fingerprint + + @staticmethod + def get_server_public_key(server_state: Optional[Dict[str, Any]] = None) -> Optional[pgpy.PGPKey]: + """Retrieves or parses the server's OpenPGP public key for encryption operations.""" + state = server_state or {} + pub_obj = state.get("public_key_obj") + if pub_obj: + return pub_obj + priv_obj = state.get("private_key_obj") + if priv_obj and hasattr(priv_obj, "pubkey"): + state["public_key_obj"] = priv_obj.pubkey + return priv_obj.pubkey + pub_blob = state.get("config", {}).get("public_key") + if pub_blob: + try: + key, _ = pgpy.PGPKey.from_blob(pub_blob) + state["public_key_obj"] = key + return key + except Exception: + pass + return None + + @staticmethod + def encrypt_field_for_storage(text: str, force: bool = False, server_state: Optional[Dict[str, Any]] = None, is_unlocked: bool = False) -> str: + """Encrypts a text payload using the server's OpenPGP public key if encrypt_at_rest is enabled.""" + if not text or not isinstance(text, str): + return text + state = server_state or {} + config = state.get("config", {}) + should_encrypt = force or (is_unlocked and bool(config.get("encrypt_at_rest", False))) + if not should_encrypt: + return text + + if text.startswith("-----BEGIN PGP MESSAGE-----"): + return text + + pub_key = StorageCryptoManager.get_server_public_key(server_state=state) + if not pub_key: + return text + + try: + msg = pgpy.PGPMessage.new(text) + encrypted = pub_key.encrypt(msg) + return str(encrypted) + except Exception as e: + print(f"[!] Warning: Failed to encrypt field at rest: {e}") + return text + + @staticmethod + def decrypt_field_from_storage(text: str, server_state: Optional[Dict[str, Any]] = None) -> str: + """ + Decrypts an OpenPGP armored message string using the server's private key. + Returns cleartext for authorized display and API consumption. + """ + if not text or not isinstance(text, str): + return text + if not text.startswith("-----BEGIN PGP MESSAGE-----"): + return text + + state = server_state or {} + priv_key = state.get("private_key_obj") + if not priv_key: + priv_blob = state.get("config", {}).get("private_key") + if priv_blob: + try: + priv_key, _ = pgpy.PGPKey.from_blob(priv_blob) + state["private_key_obj"] = priv_key + except Exception: + pass + + if not priv_key: + return text + + try: + pgp_msg = pgpy.PGPMessage.from_blob(text) + decrypted = priv_key.decrypt(pgp_msg) + return str(decrypted.message) + except Exception as e: + print(f"[!] Warning: Failed to decrypt field from storage: {e}") + return text + + +class StorageEngine: + """Manages database schema initialization, migrations, multi-engine switching, and telemetry stats.""" + + DEFAULT_DB_FILE = "logar_state.db" + DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin") + DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!") + + @classmethod + def init_db(cls, db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str: + """Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota.""" + def _execute_schema(target_path: str): + target_dir = os.path.dirname(os.path.abspath(target_path)) + if target_dir: + os.makedirs(target_dir, exist_ok=True) + conn = sqlite3.connect(target_path, timeout=10.0) + try: + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA busy_timeout=5000;") + except Exception: + pass + + # 1. Active Issues + conn.execute(""" + CREATE TABLE IF NOT EXISTS active_issues ( + fingerprint TEXT PRIMARY KEY, + site_name TEXT, + server TEXT, + signature TEXT, + severity TEXT, + message TEXT, + os_type TEXT, + first_seen TEXT, + last_seen TEXT, + run_count INTEGER, + status TEXT, + last_run_id TEXT + ) + """) + + # 2. Ingest Runs + conn.execute(""" + CREATE TABLE IF NOT EXISTS ingest_runs ( + run_id TEXT PRIMARY KEY, + site_name TEXT, + server TEXT, + timestamp TEXT, + log_count INTEGER + ) + """) + + # 3. License Config + conn.execute(""" + CREATE TABLE IF NOT EXISTS license_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + max_seats INTEGER NOT NULL DEFAULT 0, + enrollment_secret TEXT NOT NULL, + license_id TEXT, + license_server_url TEXT, + issued_at TEXT, + expires_at TEXT, + license_status TEXT DEFAULT 'unlicensed' + ) + """) + + # Migrations for license_config + for col, col_def in [ + ("license_id", "TEXT"), + ("license_server_url", "TEXT"), + ("issued_at", "TEXT"), + ("expires_at", "TEXT"), + ("license_status", "TEXT DEFAULT 'unlicensed'"), + ("features", "TEXT DEFAULT '[]'") + ]: + try: + conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}") + except sqlite3.OperationalError: + pass + + # 4. Clients Table + conn.execute(""" + CREATE TABLE IF NOT EXISTS clients ( + client_id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + os_type TEXT NOT NULL, + cert_fingerprint TEXT NOT NULL, + status TEXT DEFAULT 'active', + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Migrations for clients table + for col, col_def in [ + ("metrics_json", "TEXT DEFAULT '{}'"), + ("cpu_percent", "REAL DEFAULT 0.0"), + ("memory_percent", "REAL DEFAULT 0.0"), + ("disk_percent_free", "REAL DEFAULT 0.0") + ]: + try: + conn.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_def}") + except sqlite3.OperationalError: + pass + + # 5. Users Table + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at TEXT NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER DEFAULT 0 + ) + """) + + # Migrations for users table (2FA) + for col, col_def in [ + ("totp_secret", "TEXT"), + ("totp_enabled", "INTEGER DEFAULT 0") + ]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}") + except sqlite3.OperationalError: + pass + + # Ensure default admin user exists + c = conn.cursor() + c.execute("SELECT COUNT(*) FROM users") + if c.fetchone()[0] == 0: + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + pwd_hash, salt = PasswordManager.hash_password(cls.DEFAULT_ADMIN_PASS) + conn.execute( + "INSERT INTO users (username, password_hash, salt, role, created_at) VALUES (?, ?, ?, 'admin', ?)", + (cls.DEFAULT_ADMIN_USER, pwd_hash, salt, now_iso) + ) + + # 6. Hermes Tokens Table + conn.execute(""" + CREATE TABLE IF NOT EXISTS hermes_tokens ( + token TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used TEXT, + status TEXT NOT NULL DEFAULT 'active' + ) + """) + + # Auto-initialize initial Hermes token if table is empty + c.execute("SELECT COUNT(*) FROM hermes_tokens") + if c.fetchone()[0] == 0: + initial_token = f"hermes_live_{secrets.token_hex(20)}" + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + conn.execute( + "INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')", + (initial_token, now_iso) + ) + + # 7. Rate Limits & Server Meta Tables + conn.execute(""" + CREATE TABLE IF NOT EXISTS rate_limits ( + ip TEXT NOT NULL, + scope TEXT NOT NULL, + attempt_time REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_scope ON rate_limits (ip, scope, attempt_time)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS server_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + + status = "active" if max_seats > 0 else "unlicensed" + conn.execute(""" + INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status, features) + VALUES (1, ?, ?, ?, '[]') + """, (max_seats, enrollment_secret or "", status)) + + conn.commit() + conn.close() + + try: + _execute_schema(db_path) + return db_path + except (sqlite3.OperationalError, PermissionError, OSError) as e: + if "readonly" in str(e).lower() or "permission" in str(e).lower() or "locked" in str(e).lower() or isinstance(e, PermissionError): + local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + fallback_dir = os.path.join(local_app, "LOGAR-Server") + os.makedirs(fallback_dir, exist_ok=True) + fallback_path = os.path.join(fallback_dir, "logar_state.db") + if fallback_path != db_path: + print(f"[!] Warning: Database path '{db_path}' is not writable ({e}). Falling back to '{fallback_path}'...") + _execute_schema(fallback_path) + return fallback_path + raise + + @staticmethod + def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]: + """Initializes table schema on a PostgreSQL database instance.""" + try: + try: + import psycopg2 + except ImportError: + try: + import psycopg as psycopg2 + except ImportError: + return False, "PostgreSQL driver not installed on server (install psycopg2-binary or psycopg)" + + host = pg_config.get("host", "localhost") + port = int(pg_config.get("port", 5432)) + database = pg_config.get("database", "logar") + user = pg_config.get("user", "postgres") + password = pg_config.get("password", "") + sslmode = pg_config.get("sslmode", "prefer") + timeout = int(pg_config.get("connect_timeout", 5)) + + conn = psycopg2.connect( + host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout + ) + + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS active_issues ( + fingerprint VARCHAR(255) PRIMARY KEY, + site_name VARCHAR(255), + server VARCHAR(255), + signature TEXT, + severity VARCHAR(50), + message TEXT, + os_type VARCHAR(50), + first_seen VARCHAR(50), + last_seen VARCHAR(50), + run_count INTEGER, + status VARCHAR(50), + last_run_id VARCHAR(100) + ); + CREATE TABLE IF NOT EXISTS ingest_runs ( + run_id VARCHAR(100) PRIMARY KEY, + site_name VARCHAR(255), + server VARCHAR(255), + timestamp VARCHAR(50), + log_count INTEGER + ); + CREATE TABLE IF NOT EXISTS license_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + max_seats INTEGER NOT NULL DEFAULT 0, + enrollment_secret TEXT NOT NULL, + license_id VARCHAR(100), + license_server_url VARCHAR(255), + issued_at VARCHAR(50), + expires_at VARCHAR(50), + license_status VARCHAR(50) DEFAULT 'unlicensed', + features TEXT DEFAULT '[]' + ); + CREATE TABLE IF NOT EXISTS clients ( + client_id VARCHAR(100) PRIMARY KEY, + hostname VARCHAR(255) NOT NULL, + os_type VARCHAR(50) NOT NULL, + cert_fingerprint VARCHAR(255) NOT NULL, + status VARCHAR(50) DEFAULT 'active', + first_seen VARCHAR(50), + last_seen VARCHAR(50) + ); + CREATE TABLE IF NOT EXISTS users ( + username VARCHAR(100) PRIMARY KEY, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'admin', + created_at VARCHAR(50) NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS hermes_tokens ( + token VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at VARCHAR(50) NOT NULL, + last_used VARCHAR(50), + status VARCHAR(50) NOT NULL DEFAULT 'active' + ); + CREATE TABLE IF NOT EXISTS rate_limits ( + ip VARCHAR(100) NOT NULL, + scope VARCHAR(50) NOT NULL, + attempt_time DOUBLE PRECISION NOT NULL + ); + CREATE TABLE IF NOT EXISTS server_meta ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL + ); + """) + # Migrations for Postgres clients table + for col, col_def in [ + ("metrics_json", "TEXT DEFAULT '{}'"), + ("cpu_percent", "DOUBLE PRECISION DEFAULT 0.0"), + ("memory_percent", "DOUBLE PRECISION DEFAULT 0.0"), + ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0") + ]: + try: + cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") + except Exception: + pass + conn.commit() + conn.close() + return True, "PostgreSQL schema successfully initialized" + except Exception as e: + return False, f"PostgreSQL connection/init failed: {e}" + + @staticmethod + def migrate_sqlite_to_postgres( + sqlite_path: str, + pg_config: Dict[str, Any], + encrypt_fn: Optional[Callable[[str], str]] = None + ) -> Tuple[bool, str, Dict[str, int]]: + """Transfers all tables and records from SQLite to PostgreSQL with data validation and encryption.""" + if not os.path.exists(sqlite_path): + return False, f"SQLite database not found at {sqlite_path}", {} + + ok, init_msg = StorageEngine.init_postgres_db(pg_config) + if not ok: + return False, init_msg, {} + + try: + try: + import psycopg2 + except ImportError: + import psycopg as psycopg2 + + host = pg_config.get("host", "localhost") + port = int(pg_config.get("port", 5432)) + database = pg_config.get("database", "logar") + user = pg_config.get("user", "postgres") + password = pg_config.get("password", "") + sslmode = pg_config.get("sslmode", "prefer") + timeout = int(pg_config.get("connect_timeout", 5)) + + pg_conn = psycopg2.connect( + host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout + ) + sqlite_conn = sqlite3.connect(sqlite_path) + sqlite_conn.row_factory = sqlite3.Row + + migrated_counts = {} + tables = ["active_issues", "ingest_runs", "license_config", "clients", "users", "hermes_tokens", "rate_limits", "server_meta"] + + with pg_conn.cursor() as pg_cur: + for tbl in tables: + try: + s_cur = sqlite_conn.cursor() + s_cur.execute(f"SELECT * FROM {tbl}") + rows = s_cur.fetchall() + if rows: + col_names = [col[0] for col in s_cur.description] + cols_joined = ", ".join(col_names) + placeholders = ", ".join(["%s"] * len(col_names)) + insert_query = f"INSERT INTO {tbl} ({cols_joined}) VALUES ({placeholders}) ON CONFLICT DO NOTHING;" + data = [] + for r in rows: + row_data = [r[c] for c in col_names] + if tbl == "active_issues" and "message" in col_names and encrypt_fn: + msg_idx = col_names.index("message") + row_data[msg_idx] = encrypt_fn(row_data[msg_idx]) + data.append(row_data) + + pg_cur.executemany(insert_query, data) + migrated_counts[tbl] = len(rows) + else: + migrated_counts[tbl] = 0 + except Exception as tbl_err: + print(f"[!] Warning migrating table {tbl}: {tbl_err}") + migrated_counts[tbl] = 0 + + pg_conn.commit() + + sqlite_conn.close() + pg_conn.close() + return True, "Migration completed successfully", migrated_counts + except Exception as e: + return False, f"Migration failed: {e}", {} + + @staticmethod + def get_storage_engine_info( + db_path: Optional[str] = None, + server_state: Optional[Dict[str, Any]] = None, + ingest_queue: Optional[Any] = None, + is_unlocked: bool = False + ) -> Dict[str, Any]: + """Returns metadata about the active storage backend, configuration, and database telemetry.""" + state = server_state or {} + config = state.get("config", {}) + db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower() + target_db = db_path or config.get("db_path", StorageEngine.DEFAULT_DB_FILE) + + table_stats = {"active_issues": 0, "ingest_runs": 0, "clients": 0, "users": 0} + + licensed_features = [] + if target_db and os.path.exists(target_db): + try: + conn_l = sqlite3.connect(target_db, timeout=3.0) + row_l = conn_l.execute("SELECT features FROM license_config WHERE id = 1").fetchone() + conn_l.close() + if row_l and row_l[0]: + feat_raw = row_l[0] + if isinstance(feat_raw, str): + if feat_raw.startswith("["): + licensed_features = json.loads(feat_raw) + else: + licensed_features = [f.strip() for f in feat_raw.split(",") if f.strip()] + elif isinstance(feat_raw, list): + licensed_features = list(feat_raw) + except Exception: + pass + + flush_interval = ingest_queue.flush_interval if ingest_queue else 2.0 + batch_size = ingest_queue.batch_size if ingest_queue else 100 + max_queue = ingest_queue.max_queue if ingest_queue else 50000 + + if db_type in ("postgres", "postgresql"): + pg_cfg = config.get("postgres", {}) + 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") + try: + try: + import psycopg2 + except ImportError: + import psycopg as psycopg2 + p_conn = psycopg2.connect( + host=host, port=port, dbname=dbname, user=user, + password=pg_cfg.get("password", ""), + sslmode=pg_cfg.get("sslmode", "prefer"), + connect_timeout=int(pg_cfg.get("connect_timeout", 3)) + ) + with p_conn.cursor() as p_cur: + for tbl in ["active_issues", "ingest_runs", "clients", "users"]: + try: + p_cur.execute(f"SELECT COUNT(*) FROM {tbl}") + r = p_cur.fetchone() + table_stats[tbl] = r[0] if r else 0 + except Exception: + pass + p_conn.close() + except Exception: + pass + + return { + "engine": "postgres", + "engine_type": "postgres", + "backend_display": "PostgreSQL Enterprise Pool", + "connection_target": f"{user}@{host}:{port}/{dbname}", + "status": "Enterprise Connected", + "table_stats": table_stats, + "is_enterprise": True, + "feature_unlocked": is_unlocked, + "licensed_features": licensed_features, + "cache_mode": config.get("cache_mode", "direct"), + "encrypt_at_rest": config.get("encrypt_at_rest", False), + "flush_interval": flush_interval, + "batch_size": batch_size, + "max_queue": max_queue, + "postgres_config": pg_cfg + } + else: + size_bytes = 0 + if target_db and os.path.exists(target_db): + try: + size_bytes = os.path.getsize(target_db) + conn = sqlite3.connect(target_db, timeout=3.0) + for tbl in ["active_issues", "ingest_runs", "clients", "users"]: + try: + row = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone() + table_stats[tbl] = row[0] if row else 0 + except Exception: + pass + conn.close() + except Exception: + pass + size_kb = round(size_bytes / 1024, 2) + return { + "engine": "sqlite", + "engine_type": "sqlite", + "backend_display": "SQLite 3 (WAL Mode)", + "connection_target": target_db, + "db_size_kb": size_kb, + "status": "Local WAL Optimized", + "table_stats": table_stats, + "is_enterprise": False, + "feature_unlocked": is_unlocked, + "licensed_features": licensed_features, + "cache_mode": config.get("cache_mode", "direct"), + "encrypt_at_rest": config.get("encrypt_at_rest", False), + "flush_interval": flush_interval, + "batch_size": batch_size, + "max_queue": max_queue, + "postgres_config": config.get("postgres", { + "host": "localhost", + "port": 5432, + "database": "logar", + "user": "postgres", + "password": "", + "sslmode": "prefer", + "connect_timeout": 5, + "pool_size": 10 + }) + } diff --git a/src/web_ui.py b/src/web_ui.py new file mode 100644 index 0000000..07713e6 --- /dev/null +++ b/src/web_ui.py @@ -0,0 +1,1680 @@ +""" +LOGAR - WebUI & Dashboard HTML Rendering Component +Provides modern, dark-mode reactive HTML templates and dashboard rendering +for the central server management hub. +""" + +import html +import json +import socket +import sqlite3 +from datetime import datetime, timezone +from typing import Optional, Dict, Any, List + +from src.auth_manager import SessionManager +from src.license_manager import LicenseManager +from src.storage_engine import StorageEngine, StorageCryptoManager + + +def format_relative_time(dt_str: Optional[str]) -> str: + """Calculates human-readable relative time from ISO or SQL timestamp string.""" + if not dt_str or dt_str in ("N/A", "Never"): + return "Never" + try: + clean_str = dt_str.replace(" UTC", "") + if "T" in clean_str: + dt = datetime.fromisoformat(clean_str) + else: + dt = datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + diff = now - dt + seconds = int(diff.total_seconds()) + if seconds < 0: + return "Just now" + if seconds < 60: + return f"{seconds}s ago" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + days = hours // 24 + return f"{days}d ago" + except Exception: + return dt_str[:16] if dt_str else "N/A" + + +class WebUIRenderer: + """Renders HTML dashboards, login interfaces, and SVG assets for the LOGAR Server Hub.""" + + @staticmethod + def render_login_html() -> str: + """Returns the complete HTML string for the login page.""" + return """ + + + + + LOGAR Hub - Login + + + + + + + +""" + + @staticmethod + def render_dashboard_html( + auth_user: str = "admin", + request: Any = None, + server_state: Optional[Dict[str, Any]] = None, + ingest_queue: Optional[Any] = None + ) -> str: + """Renders the comprehensive LOGAR Server Hub & Fleet Licensing WebUI.""" + state = server_state or {} + config = state.get("config", {}) + db_path = config.get("db_path", "logar_state.db") + + summary = LicenseManager.get_license_summary(server_state=state, db_path=db_path) + is_db_unlocked = LicenseManager.is_feature_licensed("enterprise_db", db_path=db_path, server_state=state) + queue_stats = ingest_queue.get_stats() if ingest_queue else { + "queue_size": 0, "total_ingested": 0, "total_flushed": 0, "dropped_count": 0, + "last_flush_time": "Never", "avg_flush_latency_ms": 0.0, "flush_count": 0, + "flush_interval_seconds": 2.0, "batch_size_threshold": 100 + } + storage_info = StorageEngine.get_storage_engine_info( + db_path=db_path, server_state=state, ingest_queue=ingest_queue, is_unlocked=is_db_unlocked + ) + pg_config = config.get("postgres", {}) + + # Fetch clients + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + clients = c.fetchall() + + # Fetch verified anomalies + c.execute("SELECT fingerprint, server, severity, message, run_count, last_seen FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC LIMIT 50") + anomalies_raw = c.fetchall() + anomalies = [] + for an in anomalies_raw: + afp, srv, sev, msg, rc, ls = an + decrypted_msg = StorageCryptoManager.decrypt_field_from_storage(msg, server_state=state) + anomalies.append((afp, srv, sev, decrypted_msg, rc, ls)) + + # Fetch hermes tokens + c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC") + hermes_tokens = c.fetchall() + conn.close() + + # Build client rows with exact + relative activity time and seat management actions + client_rows = [] + for cl in clients: + cid, host, ost, fp, st, first, last = cl + esc_cid = html.escape(str(cid)) + esc_host = html.escape(str(host)) + esc_ost = html.escape(str(ost)) + esc_fp = html.escape(str(fp)) + esc_st = html.escape(str(st)) + badge_cls = "badge-active" if st == "active" else "badge-revoked" + rel_time = format_relative_time(last) + last_display = f"
{html.escape(str(last[:19]))}
{html.escape(rel_time)}
" + + if fp == "LOCAL-HOST-BUILTIN": + esc_st = "LOCAL HUB HOST" + badge_cls = "badge-active" + fp_display = '⚡ Built-in Log Collector' + btn_action = 'Integrated (0 Seats)' + btn_del = '' + else: + fp_display = f"{esc_fp[:16]}..." + btn_action = ( + f'' + if st == "active" else + f'' + ) + btn_del = f'' + + client_rows.append(f""" + + {esc_st.upper()} + {esc_host} + {esc_cid} + {esc_ost.upper()} + {fp_display} + {last_display} + +
+ {btn_action} + {btn_del} +
+ + + """) + clients_html = "".join(client_rows) if client_rows else 'No edge clients registered yet.' + + # Build anomaly rows + anomaly_rows = [] + for an in anomalies: + afp, srv, sev, msg, rc, ls = an + esc_srv = html.escape(str(srv)) + esc_sev = html.escape(str(sev)) + esc_msg = html.escape(str(msg)) + esc_ls = html.escape(str(ls[:19])) + sev_cls = "badge-error" if sev in ("ERROR", "CRITICAL", "FATAL") else "badge-warning" + short_msg = msg[:80] + "..." if len(msg) > 80 else msg + esc_short_msg = html.escape(str(short_msg)) + anomaly_rows.append(f""" + + {esc_sev} + {esc_srv} + {int(rc)} runs + {esc_short_msg} + {esc_ls} + + """) + anomalies_html = "".join(anomaly_rows) if anomaly_rows else 'No verified anomalies currently reported.' + + # Build hermes tokens rows + token_rows = [] + for tk in hermes_tokens: + tok, tname, tcreated, tlast, tstatus = tk + esc_tok = html.escape(str(tok)) + esc_tname = html.escape(str(tname)) + esc_tstatus = html.escape(str(tstatus)) + esc_tcreated = html.escape(str(tcreated[:19])) + esc_tlast = html.escape(str(tlast[:19])) if tlast else 'Never' + tbadge = "badge-active" if tstatus == "active" else "badge-revoked" + masked = esc_tok[:10] + "••••••••" + esc_tok[-4:] + tok_action = f'' if tstatus == "active" else f'' + token_rows.append(f""" + + {esc_tname} + {masked} + {esc_tstatus.upper()} + {esc_tcreated} + {esc_tlast} + +
+ + {tok_action} +
+ + + """) + tokens_html = "".join(token_rows) if token_rows else 'No Hermes API tokens created yet.' + + # Progress bar color logic + pct = summary["percentage"] + bar_color = "#10b981" if pct < 70 else ("#f59e0b" if pct < 90 else "#ef4444") + badge_status_cls = "badge-active" if summary["status"] == "ACTIVE" else ("badge-warning" if summary["status"] == "SELF-SIGNED" else "badge-revoked") + + unlicensed_banner_html = "" + if summary["status"] == "UNLICENSED" or summary["max_seats"] == 0: + unlicensed_banner_html = f""" +
+
+
⚠️
+
+
LOGAR Server is Unlicensed (0 Active Seats)
+
Forwarder enrollment and log ingestion are restricted. Import an activated .lic package to enable edge client connections and seat provisioning.
+
+
+
+ +
+
+ """ + + detected_host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or config.get("tcp_host", "127.0.0.1") + if detected_host in ("0.0.0.0", "localhost", ""): + detected_host = socket.gethostname() + + is_encrypt_at_rest = config.get("encrypt_at_rest", False) and is_db_unlocked + encrypt_status_label = "🔒 OpenPGP Encrypted" if is_encrypt_at_rest else "🔓 Plaintext Storage" + encrypt_status_color = "#34d399" if is_encrypt_at_rest else "#94a3b8" + + # Grayed-out styling & attributes for unlicensed enterprise features + ent_badge = 'ENTERPRISE LICENSE REQUIRED' + buf_disabled_attr = "" if is_db_unlocked else "disabled" + buf_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" + enc_disabled_attr = "" if is_db_unlocked else "disabled" + enc_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" + pg_disabled_attr = "" if is_db_unlocked else "disabled" + pg_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;" + + active_features = summary.get("features", []) + feature_cards = [] + if active_features: + for feat in active_features: + if feat == "enterprise_db": + feature_cards.append(""" +
+
🐘
+
+
+ enterprise_db + AUTHORIZED +
+
+ Enterprise Database & High-Throughput Engine: Unlocks external PostgreSQL storage clustering, customizable in-memory RAM buffering with batch flushing, and OpenPGP zero-knowledge data-at-rest encryption. +
+
+
+ """) + else: + esc_feat = html.escape(str(feat)) + feat_title = html.escape(str(feat).replace("_", " ").title()) + feature_cards.append(f""" +
+
✨
+
+
+ {esc_feat} + AUTHORIZED +
+
+ {feat_title}: Advanced capability authorized by your license certificate. +
+
+
+ """) + else: + feature_cards.append(""" +
+
+
No Enterprise Capabilities Active
+
Standard Community Edition is running. External PostgreSQL pooling, RAM queue buffering, and zero-knowledge encryption require an enterprise_db capability package.
+
+ +
+ """) + + features_block_html = "".join(feature_cards) + + ent_community_notice = "" if is_db_unlocked else f""" +
+
+ 💡 Standard Local Storage: Embedded SQLite 3 storage with synchronous logging is active. High-throughput in-memory buffering, zero-knowledge OpenPGP at-rest encryption, and external PostgreSQL clustering require an enterprise_db license capability. +
+ +
+ """ + + db_tab_pane_html = f""" + +
+ +
+
+
+ 💾 Storage Engine & Live Telemetry Metrics +
Centralized high-availability SQL pooling, buffer telemetry, and encryption at rest
+
+ {storage_info['engine'].upper()} BACKEND +
+
+
+
Active Storage Backend
+
{storage_info['backend_display']}
+
{storage_info['status']}
+
+
+
Telemetry Records
+
{storage_info['table_stats']['active_issues']} Issues / {storage_info['table_stats']['ingest_runs']} Runs
+
Clients: {storage_info['table_stats']['clients']} | Users: {storage_info['table_stats']['users']}
+
+
+
Buffered Events in RAM
+
{queue_stats['queue_size']} events
+
Ingested: {queue_stats['total_ingested']} | Flushed: {queue_stats['total_flushed']}
+
+
+
Data-at-Rest Security
+
{encrypt_status_label}
+
{'Zero-Knowledge Storage on External DB' if is_encrypt_at_rest else 'Local Encrypted/Plaintext Storage'}
+
+
+
+ + +
+
+ Database Storage Engine Configuration + CONFIG & TUNING +
+ + {ent_community_notice} + + +
+
+ ⚡ Ingestion Cache & Buffer Mode + +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ 🔐 Zero-Knowledge Data Encryption at Rest +
+ +

+ When enabled, anomaly message payloads stored in the external database are encrypted with the server's OpenPGP public key before writing. Only this hub instance can decrypt records for authorized WebUI and Hermes AI usage, ensuring zero-knowledge privacy on remote database servers. +

+
+ + +
+
+ 🗄️ Select Storage Engine Backend +
+
+ + +
+ + +
+
+ 🐘 External PostgreSQL Connection Parameters + ENTERPRISE POOL +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ """ + + html_content = f""" + + + + + LOGAR Hub - Fleet Aggregation & Management + + + + + +
+
+
+
+ + + + + + + +
+
+

LOGAR Fleet Hub

+
Fleet Log Aggregation & Centralized Provisioning
+
+
+
+ + +
+
+ 👤 {auth_user} + ▼ +
+
+
Logged in as {auth_user}
+ + + 🚪 Logout +
+
+
+
+ +
+ + {unlicensed_banner_html} + + +
+ + + + + +
+ + +
+
+
+ Seat Quota Usage + {summary['status']} +
+
{summary['active_seats']} / {summary['max_seats']} Seats
+
{summary['available_seats']} seats available for automatic leasing
+
+
+
+
+ +
+
+ Hub Status + HEALTHY +
+
mTLS Port {config.get('tcp_port', 9443)}
+
Hermes Provisioning API: Port {config.get('hermes_port', 8443)}
+
+ +
+
+ Subordinate CA + X.509 SUB-CA +
+
{summary['license_id']}
+
Expires: {summary['not_valid_after'][:10] if summary['not_valid_after'] != 'N/A' else 'N/A'} ({summary['days_remaining']} days left)
+
+ +
+
+ Fleet Forwarders + {len(clients)} Total +
+
{summary['active_seats']} Active Nodes
+
{summary['revoked_seats']} revoked nodes blocked at TLS socket
+
+
+ + +
+
+
+ Enrolled Forwarders & Seat Management +
+ + +
+
+
+ + + + + + + + + + + + + + {clients_html} + +
StatusHost NodeClient IdentifierOSCert FingerprintLast Activity TimeSeat Actions
+
+
+
+ + +
+
+
+
+
Hermes AI Bearer Access Tokens
+
Manage dedicated API tokens for Hermes AI to fetch real-time verified anomalies via GET /api/hermes/report
+
+
+
+
+ Generate Dedicated Hermes Token
+
+ + +
+
+
+ + + + + + + + + + + + + {tokens_html} + +
Token NameBearer TokenStatusCreatedLast UsedAction
+
+ +
+
🚀 Hermes AI Integration Quickstart
+
Configure Hermes to query the verified log telemetry stream with Bearer authorization:
+
curl -H "Authorization: Bearer <HERMES_TOKEN>" http://{detected_host}:{config.get('hermes_port', 8443)}/api/hermes/report
+
+
+
+ + +
+
+
+ Verified Anomaly Stream (Latest 50 Events • Rolling 12h Window) + Direct Hermes Intelligence +
+
+ + + + + + + + + + + + {anomalies_html} + +
SeveritySource NodePersistenceNormalized Anomaly MessageLast Ingested
+
+
+
+ + +
+
+
PKI & Seat Synchronization:
+
+ + +
+
+ +
+
+ Subordinate Certificate Details + {summary['status']} +
+
+
+
Subordinate CA Common Name
+
{summary['subject_cn']}
+
+
+
Master Root Issuer
+
{summary['issuer_cn']}
+
+
+
Master License Authority
+
{summary['issuer_url']}
+
+
+
Issued Seat Quota
+
{summary['max_seats']} Seats
+
+
+
Certificate Validity
+
{summary['not_valid_after'][:19] if summary['not_valid_after'] != 'N/A' else 'N/A'}
+
+
+
Server Hub Fingerprint
+
{summary['server_fingerprint']}
+
+
+
CA Certificate Fingerprint
+
{summary['fingerprint']}
+
+
+
+ + +
+
+
+ ✨ Licensed Capabilities & Feature Modules +
Features authorized by your subordinate CA license package and upstream authority
+
+ {len(active_features)} ACTIVE FEATURES +
+ +
+ {features_block_html} +
+
+
+ + {db_tab_pane_html} +
+ + + + + + + + + + + + + + + + """ + + return html_content