From df9f5d191b53fae816ea7d310852f7f2256d8301 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 2 Oct 2026 23:14:55 +0200 Subject: [PATCH] Organize codebase into hub, clients, and common packages with backward compatibility and comprehensive unit test suites --- .coverage | Bin 0 -> 53248 bytes src/License_Authority.py | 28 +- src/Linux_Client.py | 147 +- src/Server.py | 2457 +------------------------ src/Win_Client.py | 152 +- src/__init__.py | 13 + src/auth_manager.py | 399 +--- src/client_transport.py | 341 +--- src/client_updater.py | 249 +-- src/clients/Linux_Client.py | 169 ++ src/clients/Win_Client.py | 174 ++ src/clients/__init__.py | 1 + src/clients/client_transport.py | 359 ++++ src/clients/client_updater.py | 248 +++ src/clients/log_collector.py | 679 +++++++ src/common/__init__.py | 1 + src/common/version.py | 67 + src/hub/License_Authority.py | 28 + src/hub/Server.py | 2456 ++++++++++++++++++++++++ src/hub/__init__.py | 1 + src/hub/auth_manager.py | 398 ++++ src/hub/ingest_queue.py | 114 ++ src/hub/license_manager.py | 260 +++ src/hub/pki_manager.py | 141 ++ src/hub/server_enrollment.py | 1070 +++++++++++ src/hub/server_models.py | 88 + src/hub/storage_engine.py | 641 +++++++ src/hub/update_manager.py | 362 ++++ src/hub/web_ui.py | 1883 +++++++++++++++++++ src/ingest_queue.py | 115 +- src/license_manager.py | 261 +-- src/log_collector.py | 680 +------ src/pki_manager.py | 142 +- src/server_enrollment.py | 1071 +---------- src/server_models.py | 89 +- src/storage_engine.py | 642 +------ src/update_manager.py | 363 +--- src/version.py | 68 +- src/web_ui.py | 1884 +------------------ tests/test_clients_comprehensive.py | 178 ++ tests/test_clients_deep.py | 109 ++ tests/test_common_version.py | 52 + tests/test_hub_comprehensive.py | 201 ++ tests/test_hub_enrollment_pki_deep.py | 109 ++ tests/test_hub_server_deep.py | 149 ++ tests/test_hub_storage_deep.py | 141 ++ tests/test_hub_update_auth_deep.py | 150 ++ 47 files changed, 10262 insertions(+), 9068 deletions(-) create mode 100644 .coverage create mode 100644 src/clients/Linux_Client.py create mode 100644 src/clients/Win_Client.py create mode 100644 src/clients/__init__.py create mode 100644 src/clients/client_transport.py create mode 100644 src/clients/client_updater.py create mode 100644 src/clients/log_collector.py create mode 100644 src/common/__init__.py create mode 100644 src/common/version.py create mode 100644 src/hub/License_Authority.py create mode 100644 src/hub/Server.py create mode 100644 src/hub/__init__.py create mode 100644 src/hub/auth_manager.py create mode 100644 src/hub/ingest_queue.py create mode 100644 src/hub/license_manager.py create mode 100644 src/hub/pki_manager.py create mode 100644 src/hub/server_enrollment.py create mode 100644 src/hub/server_models.py create mode 100644 src/hub/storage_engine.py create mode 100644 src/hub/update_manager.py create mode 100644 src/hub/web_ui.py create mode 100644 tests/test_clients_comprehensive.py create mode 100644 tests/test_clients_deep.py create mode 100644 tests/test_common_version.py create mode 100644 tests/test_hub_comprehensive.py create mode 100644 tests/test_hub_enrollment_pki_deep.py create mode 100644 tests/test_hub_server_deep.py create mode 100644 tests/test_hub_storage_deep.py create mode 100644 tests/test_hub_update_auth_deep.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..34f240fcac803f63382e0c86fe382efb99840eda GIT binary patch literal 53248 zcmeI5Yj70Tm4I(|OFh#)J#!@_F?uo$=wTV52XZXqm%u<_vcwL?&c-p$Fd8+ZS!Sli zJV3I|&S=1fWFO>FmBkg?+O2)0%Gq6e?T}OjcE#GR;7wd1TU+HQMU3S2CQ!x?VVoe0 zq~3dbrZo?#TlwKs9M@^2IWyho+L)*rf@sK&TqxW2kcRwfw4Zw47xhK_14^t-_RUA=SBalP z@l3Q)HQW(brv#xrl0D%^A|JiLvf;BoXc-$p0)69;C@37MYIYs+pykZCuR;`#a}mkzD5D zEl8(zcH5;}@PAz9s{ z4;gALXOcszIe*M_7%+TMI?!Y_t4KWkgH{`LmiQp|h19;nTGJ~grjP+b<^n@)Pa@){ zLnTII@T>cks4t+jXOCv4g+Fv5k<#fBc=r~MS?SD|fVXS@sW?)@+uU_^Oj^*(D4VUU zuoaY$k;z4-QR7fp@x>BRV=6>qS{v`)SZ5YaR?pj&ePSQ6mDt=> zRm@0cM9_JO{+1R5LHFc_K+%cdcXxl#7ax!p`c8Kf$6HEy_ia^Xj#C%BUG$MZT*m4y zALVrVVKV8{U#`Gqx+mxIpD4p?_xnO*HW~BjLEtW3(rcuxi(_Mw2i?27TqV~JXNEgXN?wZx29yQOWzI&MeQ_lo z3@b8K(Vfu?>wqsN`=bgum_3*3juB!5#a?Baxtkm@=Fm(utu35Y?a&l^yXYfbCEMwx z+=z#)mk_8GBeU$O<2Rdg^{WcQL;np`5~7Z(Md$B@3v^gw6C zOxpW0=nSpw1(MiG7x)amj>r#eKmZ5;0U!VbfB+Bx0zd!=00AHX1c1P&j{wK8jE%1U zSv-mGd*lH&AOHk_01yBIKmZ5;0U!VbfB+Bx0zlx8l7Pf=4cPdzkj^rOtEf00e*l5SS+*aSe>|HUKMf zbwc)C0Q&iVi~WZP{~SMyhw(OC;rgZPd#=Y_+g&xz|8u_J9C3c$SuedUeNQ?p?T{KB zmmNQHe9IAV-0pCS6XL&#hs7_7UBY$Yr^3@hRM;fc+utV5!3G3?01yBIKmZ5;f!_mx z_7)ELAGmc_*f+Fmvl81IR|j`(zjy1#`*y{m{$2i1P>IBMdA-3%Fz)q|uakB*b0}b{ zzdh(zA~D6gF%ch7qrvzvv1n@I&<<0Jol2BkLlL#LjU4iss%0GUCI-oONOkqr)f{@z zRQ*qb5$`75chYjlDh?^8s@aywEiEFkK{XmD25VMw=wVZX?ZHT5DA)Q;GKW-C<$*-s zu0^4Ete_lF`n-uC@#Mx89D2yK@xi@8FZuQ``JO%nxTe9%YjX1pB^LLRdo&~zqTE)` zA+Kq}F+C~Xu-dPLVpO}%N;{+mynZzlQv7jM&t$DtCNn9_^SiaiDw7mou12b@T8@$K z2@9_h36PsL5VzaiR<#|<)@qJm>++>MnNLht6^DYR{HW6l;#^Z{l^=5di-9~ST@_2U zzcAWmR?$+I3hU*j@}&wlTLwxbO0pX!69Hk`S!Ol<7qeK0IkeXj)xu14xU7aOjisob zJDf`y6ans(tm2?O6c2)K2ZzF@ab%{E7#ZvZvqP6?l}E#w=f)fz%=wcMf&-Fi~S_BUlPs=4MGf$OHKBZ!ZX5V=dWC~ z_z~CVTyNk?{8`r%!i?h%{9R|-xx?8YJuaPaz9`-2_`q>SeBALj;-vVXbV+={G2n2y zPKfJ%52t%*5(oeRAOHk_01yBI2z79N#`F>=a{*OwNz}y!%=H&r>NYoXJIpQS*LF?q zoX=cs!R2M+jogFg>IE0nt2;TxTs7Nr{`!Aa8~3oeLD3cd%Fl4BxiY<=TnvhA31xnn z=~=@)WZrn;dTxcK*G224hP9m6yy4=dPrapf@ily%WhNJ_i)$@2NdeB+NR4I73s%I{ zmbJZb)#+Z+lljCfU&jT_`BA4A#93vTpQ7by<&y0$h_=EqTIy0^y)0X@aILQ% z9RG`1ly!4^tx+w^1hyQqG?t=zb}ea81lVaA2koJF5J)$1Ve>eQrDoCW;IPc2VN@_( ziMMcxrTbZA+7c{B$Ab00y@lIp-cN4*Z@Zb>XRc?2ko70G{^u>r%P=dRJ4!8!c;WiL z#4_(jFL^6<&TB%uq5$^v7mr_6g2mk>f00e*l5C8%|00;m9AOHkD z)dV>5Hv&%j`9Fha5cz=(2mk>f00e*l5C8%|00;m9AOHk_01)^T65u$eKtKO)5(f~T z##7`TfRp$T_TdM~eE_Ol)2?%_S6#%2!ZO$J$|IWG1xzQ;}ZV5{^@h9TT;z{vE z@w?*Ri{BQ%CLR@2;*dBucUG%FUUkz$|biw<={PZmxtw7P`QHT8>nnx`FbkrS-y_SI+m}cvX)J+uPJ4O z!_gY{v^IL|KlW848QC8@|Eua-o=j~WEZO?X`q@p<$ja`_#?YkvQ}XJOJJE(aE?#@~ z*=OHA&^|V+Y5VWJdaSp3a8ld))Oqc-6~~Vr|4QJ_X1@8Q6hi3LGxar-fw?nXDb?f1nvZw9b5^_X)}Nm_@WR~FKOgHpbxM-jF9t^^=d|7#jobnQ z{UuY4W=85xzJJ?$2l?5zk~5D!HrYU|Q|}AAC+;A(VMx_|v<~SGY{>Je;Ax7lrVOiD z-c6;O9f>X{ED!ZBPfr}VGpW7C=c}JS-SGOA|2)*4*;j)$%uNJdb)xB&srAWTvQtj6 zC1pRV=45;H?#;-7qV}UlkM?es5V~u#T}CPV82#_jvqVw&d*^~b(oPK(4G}GEejpHjRu}by}6$IEwTSZz+g|xHh&f@FmZkOKo3_pLa zw*-}?(9|Zx$cUMo-iqezmB;s^@sY6_!zcE&u2<`m=jOB@{H$y0qu$B6E3>a?z1n|i zn&w_c+V5PJ?g?Fe^NB}0(KD1VI{$w6h*VB`OjnoBC0BfIXcKxbIsN7pO-o$+_phT# zHsu*To76P#fu7#vSkJQ5Gn)3?$)9vEC~|FR*!~smp3e6I$)xtqZ!aHwRMRecG-2|} z^|23@rL==Adhwc;eB+IVQCiA{TYl2Y5IT20`HvUkZ@qi`8q1ea4rOdJCh{Jfo_L-| zPiRwT@J!M*aAM+&mK^W>;MnWm(X`Ro>t{U6D_unIMprs8GxqC`dYOse9-El{4cFr& zdk39q?dB-#}Ze@~vEN>&PT{c$ciM)$XYdw!$y0jc2LXB>6Bwcn&{8rP2o|1=3Y31Bp zO1e9aCP#Z`=pgD0HBKWJx(9u8HM;#Dl2b<>Ax1w&t`G0-J*;U`51n9k%j55)0u3!^ zUzvWc`@FK-^E~RHZR^_xOaya^FQIOfFg!;k$M7tbEW6e`)6f5#TpGgH@rU?b z{A)ac$MJvTALEztDSQI|EB+4tTl^&c2L20t7$3y@v5E)CI|N_CJMnhB1>cE#a3^lX zt8g8;OMnyeq$${d01yBIKmZ5;0U!VbfB+Bx0zd!=e9{Qe#W~Z|q>IKzU94WMi&d+1 zv2vv@WPKUWcs%;66)SYn(4dR@dR^4j>7uq)7d17ysIJz9+pUY`%XLvzrHjf+T~t)) zV%aiXl$YzGtV|ae>%!&Ih101ENz#SGp$k#eg&^p{Zr6p)rVF0eMQN!nN=kIWak^mH z67mj!{`tT0p2R0DIp7Zv00KY&2mk>f00e*l5C8%|00;m9An@@Jpr8N4`v2o$39SGD zAOHk_01yBIKmZ5;0U!VbfB+Eqq!OUt|A+PeCpA0Z7Z3mfKmZ5;0U!VbfB+Bx0zd!= K0D+H(!2bgSk7w=x literal 0 HcmV?d00001 diff --git a/src/License_Authority.py b/src/License_Authority.py index 36dae79..314d668 100644 --- a/src/License_Authority.py +++ b/src/License_Authority.py @@ -1,27 +1 @@ -#!/usr/bin/env python3 -""" -LOGAR Master License Authority Proxy -Delegates directly to the standalone submodule: LOGAR-LICENSING/License_Authority.py -Repo: https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git -""" - -import os -import importlib.util - -ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -SUBMODULE_FILE = os.path.join(ROOT_DIR, "LOGAR-LICENSING", "License_Authority.py") - -if not os.path.exists(SUBMODULE_FILE): - raise ImportError(f"LOGAR-LICENSING submodule not found at {SUBMODULE_FILE}. Run 'git submodule update --init --recursive'.") - -spec = importlib.util.spec_from_file_location("_submodule_authority", SUBMODULE_FILE) -_sub = importlib.util.module_from_spec(spec) -spec.loader.exec_module(_sub) - -# Export public symbols from submodule into this module's namespace -for attr in dir(_sub): - if not attr.startswith("__"): - globals()[attr] = getattr(_sub, attr) - -if __name__ == "__main__": - _sub.main() +from src.hub.License_Authority import * \ No newline at end of file diff --git a/src/Linux_Client.py b/src/Linux_Client.py index 76da2bd..eb3d540 100644 --- a/src/Linux_Client.py +++ b/src/Linux_Client.py @@ -1,146 +1,3 @@ -""" -LOGAR Linux Edge Log Forwarder -Collects Linux systemd journalctl / syslog entries and securely streams them -to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope. -""" - -import os -import sys -import time -import argparse -import warnings -from typing import Optional - -# Ensure script directory and repo root are in sys.path -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -if SCRIPT_DIR not in sys.path: - sys.path.insert(0, SCRIPT_DIR) -REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) -if REPO_ROOT not in sys.path: - sys.path.insert(0, REPO_ROOT) - -# Suppress cryptography / pgpy deprecation notices -warnings.filterwarnings("ignore") - -import pgpy - -from log_collector import ( - get_machine_identifier, - is_path_writable, - get_default_data_dir, - resolve_writable_path, - get_state_path, - load_state, - save_state, - commit_state, - LinuxLogCollector, - BaseLogCollector -) -from client_transport import ( - is_cert_expiring_soon, - enroll_client_if_needed, - get_tls_socket, - load_config, - ensure_enrolled as transport_ensure_enrolled, - send_encrypted_logs_over_socket as transport_send_encrypted_logs, - check_and_apply_auto_update -) - -CONFIG_FILE_NAME = "client_config.json" -STATE_FILE_NAME = "client_state.json" - - -def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: - """ - Collects info, warnings, and errors from systemd journalctl over the lookback window. - Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG noise. - State Tracking: Skips events older than lookback window and already sent cursors. - """ - collector = LinuxLogCollector(state=state) - return collector.collect_logs(hours=hours) - - -def send_encrypted_logs_over_socket(config: dict, logs: list): - """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" - return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux") - - -def ensure_enrolled(config: dict, machine_id: str) -> bool: - """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" - return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="linux") - - -def main(): - parser = argparse.ArgumentParser(description="LOGAR Linux Edge Log Forwarder with State Tracking") - parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json") - parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)") - parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)") - parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window") - parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)") - args = parser.parse_args() - - try: - config = load_config(args.config) - except Exception as e: - print(f"[!] Configuration error: {e}") - sys.exit(1) - - state_path = get_state_path(args.config, args.state_file) - state = None if args.no_state else load_state(state_path) - - machine_id = config.get("client_id") or get_machine_identifier() - print(f"[*] Edge Forwarder Node: {machine_id}") - if state and ("last_cursor" in state or "last_timestamp_us" in state): - print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})") - elif not args.no_state: - print(f"[*] State tracking initialized (state file: {state_path})") - - # Proactively ensure dynamic PKI certificate enrollment with Server Hub - ensure_enrolled(config, machine_id) - - collector = LinuxLogCollector(state=state, machine_id=machine_id) - - def run_forwarder_cycle(): - # Continuously verify/renew PKI enrollment if server is licensed & online - ensure_enrolled(config, machine_id) - - # Periodically check server for client auto-updates - try: - check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="linux") - except Exception as update_err: - pass - - print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") - candidate_logs = collector.collect_logs(hours=args.hours) - print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") - - if not candidate_logs: - print("[*] No new unsent events to transmit.") - if state is not None: - commit_state(state, state_path) - return - - try: - resp = send_encrypted_logs_over_socket(config, candidate_logs) - if state is not None and resp and resp.get("status") == "success": - commit_state(state, state_path) - print(f"[+] State successfully committed to {state_path}") - except Exception as e: - print(f"[!] Failed to stream logs to server: {e}") - if not args.interval: - sys.exit(1) - - if args.interval and args.interval > 0: - print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...") - while True: - try: - run_forwarder_cycle() - except Exception as e: - print(f"[!] Error in forwarder cycle: {e}") - time.sleep(args.interval) - else: - run_forwarder_cycle() - - -if __name__ == "__main__": +from src.clients.Linux_Client import * +if __name__ == '__main__': main() \ No newline at end of file diff --git a/src/Server.py b/src/Server.py index 6b055aa..eab821b 100644 --- a/src/Server.py +++ b/src/Server.py @@ -1,2456 +1,3 @@ -""" -LOGAR - Central Cloud Ingestion Hub & Fleet Licensing Server -Main orchestrator and FastAPI application server. -Delegates domain responsibilities to specialized modular managers: - - server_models: Pydantic request & response data models - - auth_manager: Session, Rate Limiting, Password, 2FA TOTP, Hermes token managers - - ingest_queue: Ingestion ring buffering and batch processing - - storage_engine: Multi-engine SQL schema, migrations, and data-at-rest encryption - - license_manager: Sub-CA licensing, OID parsing, and seat synchronization - - pki_manager: TLS 1.3 mTLS context creation and dynamic certificate rotation - - web_ui: Dark-mode administrative WebUI & reactive dashboard renderer -""" - -import os -import sys -import json -import uuid -import time -import struct -import socket -import sqlite3 -import argparse -import asyncio -import secrets -import warnings -import threading -from datetime import datetime, timezone, timedelta -from typing import Dict, Any, List, Optional, Tuple - -# Ensure script directory and repo root are in sys.path -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -if SCRIPT_DIR not in sys.path: - sys.path.insert(0, SCRIPT_DIR) -REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) -if REPO_ROOT not in sys.path: - sys.path.insert(0, REPO_ROOT) - -# Suppress cryptography / pgpy deprecation notices for a clean terminal output -warnings.filterwarnings("ignore") - -import pgpy -import ssl -from fastapi import FastAPI, HTTPException, Request, Depends -from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse, FileResponse -import uvicorn - -try: - from src import server_enrollment as enrollment -except ImportError: - import server_enrollment as enrollment - -try: - from src.version import LOGAR_VERSION, is_newer_version, compare_versions - from src.update_manager import ServerUpdateManager -except ImportError: - from version import LOGAR_VERSION, is_newer_version, compare_versions - from update_manager import ServerUpdateManager - -try: - from src.log_collector import ( - get_machine_identifier, - WindowsLogCollector, - LinuxLogCollector, - get_log_collector, - is_routine_service_noise - ) -except ImportError: - from log_collector import ( - get_machine_identifier, - WindowsLogCollector, - LinuxLogCollector, - get_log_collector, - is_routine_service_noise - ) - -# Import specialized modular components -from src.server_models import ( - ClientEnrollRequest, - ClientActionRequest, - LicenseImportRequest, - HermesTokenCreateRequest, - HermesTokenRevokeRequest, - ClientProfileExportRequest, - ChangePasswordRequest, - DbConnectionTestRequest, - DbSwitchBackendRequest, - DbFullConfigRequest, - ClientUpdateRequest, - ServerUpdateCheckRequest -) -from src.auth_manager import ( - SessionManager, - RateLimiter, - PasswordManager, - TOTPManager, - HermesTokenManager -) -from src.ingest_queue import IngestQueueManager -from src.storage_engine import StorageEngine, StorageCryptoManager -from src.license_manager import LicenseManager -from src.pki_manager import PKIManager -from src.web_ui import WebUIRenderer, format_relative_time - - -CONFIG_FILE_NAME = "server_config.json" -DEFAULT_DB_FILE = "logar_state.db" -EVALUATION_WINDOW_HOURS = 12 -RUN_THRESHOLD = 1 - -SESSION_COOKIE_NAME = SessionManager.SESSION_COOKIE_NAME -DEFAULT_ADMIN_USER = StorageEngine.DEFAULT_ADMIN_USER -DEFAULT_ADMIN_PASS = StorageEngine.DEFAULT_ADMIN_PASS - -app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version=LOGAR_VERSION) - -# Global context holding server state -UPDATE_MANAGER = ServerUpdateManager() -SERVER_STATE: Dict[str, Any] = { - "update_manager": UPDATE_MANAGER -} - -# Global rate limiter & ingestion queue -RATE_LIMITER = RateLimiter() -INGEST_QUEUE = IngestQueueManager() - - -# ===================================================================== -# Backward-Compatible Auth & Helper Functions (delegating to managers) -# ===================================================================== - -def get_session_secret(db_path: Optional[str] = None) -> str: - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - return SessionManager.get_session_secret(db_path=target_db, fallback_db=DEFAULT_DB_FILE) - - -SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32) - - -def get_client_ip(request: Optional[Request] = None) -> str: - return RateLimiter.get_client_ip(request) - - -def check_ip_rate_limit( - ip: str, - store: Any = None, - max_attempts: int = 10, - window_seconds: int = 60, - scope: str = "login", - db_path: Optional[str] = None -) -> bool: - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - return RATE_LIMITER.check_ip_rate_limit( - ip=ip, store=store, max_attempts=max_attempts, window_seconds=window_seconds, - scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE - ) - - -def record_failed_ip_attempt( - ip: str, - store: Any = None, - scope: str = "login", - db_path: Optional[str] = None -): - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - RATE_LIMITER.record_failed_ip_attempt( - ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE - ) - - -def reset_failed_ip_attempts( - ip: str, - store: Any = None, - scope: str = "login", - db_path: Optional[str] = None -): - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - RATE_LIMITER.reset_failed_ip_attempts( - ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE - ) - - -def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: - return PasswordManager.hash_password(password, salt=salt) - - -def verify_password(password: str, salt: str, password_hash: str) -> bool: - return PasswordManager.verify_password(password, salt, password_hash) - - -def generate_session_token(username: str, db_path: Optional[str] = None) -> str: - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - return SessionManager.generate_session_token(username, db_path=target_db, fallback_db=DEFAULT_DB_FILE) - - -def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]: - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - return SessionManager.verify_session_token(token, max_age_hours=max_age_hours, db_path=target_db, fallback_db=DEFAULT_DB_FILE) - - -def get_current_user_optional(request: Request) -> Optional[str]: - """Extracts authenticated username from cookie or Bearer header if valid.""" - db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE - cookie = request.cookies.get(SESSION_COOKIE_NAME) - if cookie: - user = verify_session_token(cookie, db_path=db_path) - if user: - return user - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1].strip() - user = verify_session_token(token, db_path=db_path) - if user: - return user - return None - - -def require_admin_auth(request: Request) -> str: - """Enforces administrator authentication for management endpoints.""" - # 1. Check if logged in admin user via session cookie or Bearer session token - user = get_current_user_optional(request) - if user: - return user - - # 2. Check Bearer token or token query param matching config auth_token - token = None - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1].strip() - if not token: - token = request.query_params.get("token") - - if token: - cfg_token = SERVER_STATE.get("config", {}).get("auth_token") - if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): - return "config_auth_token" - - raise HTTPException(status_code=401, detail="Authentication required. Please login or provide admin token.") - - -def verify_user_credentials(username: str, password: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return PasswordManager.verify_user_credentials(username, password, db_path=db_path) - - -def change_user_password(username: str, new_password: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return PasswordManager.change_user_password(username, new_password, db_path=db_path) - - -def generate_totp_secret(length: int = 20) -> str: - return TOTPManager.generate_totp_secret(length=length) - - -def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: - return TOTPManager.get_totp_token(secret, intervals_no=intervals_no) - - -def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: - return TOTPManager.verify_totp_code(secret, code, window=window) - - -def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: - return TOTPManager.get_totp_uri(username, secret, issuer=issuer) - - -def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: - return TOTPManager.get_user_totp_info(username, db_path=db_path) - - -def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return TOTPManager.enable_user_totp(username, secret, db_path=db_path) - - -def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return TOTPManager.disable_user_totp(username, db_path=db_path) - - -def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: - return HermesTokenManager.create_hermes_token(name, token=token, db_path=db_path) - - -def list_hermes_tokens(db_path: str = DEFAULT_DB_FILE) -> List[Dict[str, Any]]: - return HermesTokenManager.list_hermes_tokens(db_path=db_path) - - -def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return HermesTokenManager.revoke_hermes_token(token, db_path=db_path) - - -def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return HermesTokenManager.delete_hermes_token(token, db_path=db_path) - - -def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: - return HermesTokenManager.verify_hermes_token(token, db_path=db_path) - - -def require_hermes_or_admin_auth(request: Request) -> str: - """Authorizes Hermes API access via Bearer token, query parameter, admin session, or legacy auth token.""" - user = get_current_user_optional(request) - if user: - return f"user:{user}" - - token = None - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ", 1)[1].strip() - if not token: - token = request.query_params.get("token") - - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - - if token: - if verify_hermes_token(token, db_path=db_path): - return f"hermes_token:{token[:8]}..." - cfg_token = SERVER_STATE.get("config", {}).get("auth_token") - if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): - return "config_auth_token" - - # Fallback if no hermes tokens are defined in database yet - try: - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT COUNT(*) FROM hermes_tokens WHERE status = 'active'") - active_count = c.fetchone()[0] - conn.close() - if active_count == 0: - return "anonymous_fallback" - except Exception: - pass - - raise HTTPException(status_code=401, detail="Valid Hermes Bearer Token or Administrator Login Required") - - -# ===================================================================== -# Keypair, Storage & Path Resolution Utilities -# ===================================================================== - -def generate_server_keypair(server_name: str) -> Tuple[str, str, str]: - return StorageCryptoManager.generate_server_keypair(server_name) - - -def is_path_writable(filepath: str) -> bool: - """Checks if a file or directory path is writable by the current process.""" - try: - if os.path.exists(filepath): - if os.path.isdir(filepath): - test_f = os.path.join(filepath, f".perm_{os.getpid()}") - with open(test_f, "w") as f: - f.write("1") - os.remove(test_f) - return True - else: - with open(filepath, "a") as f: - pass - return True - else: - parent = os.path.dirname(os.path.abspath(filepath)) or "." - os.makedirs(parent, exist_ok=True) - test_f = os.path.join(parent, f".perm_{os.getpid()}") - with open(test_f, "w") as f: - f.write("1") - os.remove(test_f) - return True - except Exception: - return False - - -def get_default_data_dir(app_name: str = "LOGAR-Server") -> str: - """Returns a suitable writable application data directory.""" - if sys.platform == "win32": - prog_data = os.environ.get("ProgramData", r"C:\ProgramData") - target_prog = os.path.join(prog_data, app_name) - if is_path_writable(target_prog): - return target_prog - - local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") - target_local = os.path.join(local_app, app_name) - if is_path_writable(target_local): - return target_local - - temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) - if is_path_writable(temp_dir): - return temp_dir - - cwd = os.getcwd() - if is_path_writable(cwd): - return cwd - return os.path.expanduser("~") - - -def resolve_writable_path(path: str, app_name: str = "LOGAR-Server", default_filename: str = "logar_state.db") -> str: - """Resolves a path, ensuring it is writable or falling back to a writable data directory.""" - if not path: - path = default_filename - if os.path.isabs(path) and is_path_writable(path): - return path - if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): - return os.path.abspath(path) - data_dir = get_default_data_dir(app_name) - fname = os.path.basename(path) - return os.path.join(data_dir, fname) - - -def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: - """Loads existing server_config.json or creates a new one on first run.""" - actual_config_path = config_path - if not os.path.isabs(actual_config_path) and not os.path.exists(actual_config_path): - data_dir_cfg = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) - if os.path.exists(data_dir_cfg): - actual_config_path = data_dir_cfg - - if os.path.exists(actual_config_path): - print(f"[*] Loading server configuration from: {os.path.abspath(actual_config_path)}") - with open(actual_config_path, "r", encoding="utf-8") as f: - config = json.load(f) - if "enrollment_secret" not in config: - config["enrollment_secret"] = secrets.token_hex(24) - if "max_seats" not in config: - config["max_seats"] = 0 - if "cert_dir" not in config: - config["cert_dir"] = "certs" - if "tls_enabled" not in config: - config["tls_enabled"] = True - if "license_server_url" not in config: - config["license_server_url"] = "https://logar.eibl.tech" - if "admin_user" not in config: - config["admin_user"] = DEFAULT_ADMIN_USER - if "session_secret" not in config: - config["session_secret"] = secrets.token_hex(32) - if "db_path" not in config: - config["db_path"] = DEFAULT_DB_FILE - if "server_name" not in config: - config["server_name"] = "LOGAR Enterprise Hub" - if "tcp_host" not in config: - config["tcp_host"] = "0.0.0.0" - if "tcp_port" not in config: - config["tcp_port"] = 9443 - if "hermes_host" not in config: - config["hermes_host"] = "0.0.0.0" - if "hermes_port" not in config: - config["hermes_port"] = 8443 - if "evaluation_window_hours" not in config: - config["evaluation_window_hours"] = 12 - if "min_persistence_runs" not in config: - config["min_persistence_runs"] = 2 - if "storage_engine" not in config: - config["storage_engine"] = config.get("db_type", "sqlite") - if "cache_mode" not in config: - config["cache_mode"] = "direct" - if "encrypt_at_rest" not in config: - config["encrypt_at_rest"] = False - - config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE) - config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") - config["config_file_path"] = os.path.abspath(actual_config_path) - - global SESSION_SECRET - if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: - SESSION_SECRET = config["session_secret"] - return config - - if not os.path.isabs(config_path) and ("Program Files" in os.getcwd() or not is_path_writable(os.getcwd())): - config_path = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) - - print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...") - server_name = "LOGAR-Cloud-Hub" - private_key, public_key, fingerprint = generate_server_keypair(server_name) - auth_token = secrets.token_hex(24) - enrollment_secret = secrets.token_hex(24) - session_sec = secrets.token_hex(32) - - db_path = resolve_writable_path(DEFAULT_DB_FILE, "LOGAR-Server", DEFAULT_DB_FILE) - cert_dir = resolve_writable_path("certs", "LOGAR-Server", "certs") - - config = { - "server_name": server_name, - "tcp_host": "0.0.0.0", - "tcp_port": 9443, - "hermes_host": "0.0.0.0", - "hermes_port": 8443, - "admin_user": DEFAULT_ADMIN_USER, - "auth_token": auth_token, - "enrollment_secret": enrollment_secret, - "session_secret": session_sec, - "max_seats": 0, - "license_status": "unlicensed", - "license_server_url": "https://logar.eibl.tech", - "cert_dir": cert_dir, - "tls_enabled": True, - "db_path": db_path, - "evaluation_window_hours": EVALUATION_WINDOW_HOURS, - "min_persistence_runs": RUN_THRESHOLD, - "server_fingerprint": fingerprint, - "public_key": public_key, - "private_key": private_key, - "config_file_path": os.path.abspath(config_path) - } - if not os.environ.get("LOGAR_SESSION_SECRET"): - SESSION_SECRET = session_sec - - os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - - print("[+] Successfully generated new server config and OpenPGP keypair.") - print(f"[+] Server Encryption Fingerprint: {fingerprint}") - print(f"[+] Saved to: {os.path.abspath(config_path)}") - return config - - -def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> str: - """Generates a fresh enrollment secret, invalidates the previous secret, and persists change to disk & DB.""" - cfg = config_dict if config_dict is not None else SERVER_STATE.get("config", {}) - new_secret = secrets.token_hex(24) - cfg["enrollment_secret"] = new_secret - - config_file_path = cfg.get("config_file_path") or CONFIG_FILE_NAME - try: - if os.path.exists(config_file_path): - with open(config_file_path, "r", encoding="utf-8") as f: - saved = json.load(f) - saved["enrollment_secret"] = new_secret - with open(config_file_path, "w", encoding="utf-8") as f: - json.dump(saved, f, indent=2) - except Exception as e: - print(f"[!] Warning: Could not write new enrollment secret to config file: {e}") - - db_path = cfg.get("db_path", DEFAULT_DB_FILE) - try: - conn = sqlite3.connect(db_path, timeout=5.0) - c = conn.cursor() - c.execute("UPDATE license_config SET enrollment_secret = ? WHERE id = 1", (new_secret,)) - conn.commit() - conn.close() - except Exception as e: - print(f"[!] Warning: Could not update database with new enrollment secret: {e}") - - SERVER_STATE["config"] = cfg - return new_secret - - -def generate_client_profile_dict( - server_host: str, - server_port: int, - client_id: Optional[str] = None, - config_dict: Optional[Dict[str, Any]] = None -) -> Dict[str, Any]: - """Generates a complete standalone client profile dictionary for export.""" - cfg = config_dict or SERVER_STATE.get("config", {}) - ca_pem = SERVER_STATE.get("ca_cert_pem", "") - if not ca_pem: - cert_dir = cfg.get("cert_dir", "certs") - ca_path = os.path.join(cert_dir, "ca.crt") - if os.path.exists(ca_path): - with open(ca_path, "r", encoding="utf-8") as f: - ca_pem = f.read() - - return { - "format": "LOGAR_CLIENT_PROFILE", - "version": "3.0.0", - "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), - "server_host": server_host, - "server_port": int(server_port), - "hermes_host": server_host if cfg.get("hermes_host") in ("0.0.0.0", "::", None) else cfg.get("hermes_host", server_host), - "hermes_port": int(cfg.get("hermes_port", 8443)), - "enrollment_secret": cfg.get("enrollment_secret", ""), - "server_fingerprint": cfg.get("server_fingerprint", ""), - "server_public_key": cfg.get("public_key", ""), - "auth_token": cfg.get("auth_token", ""), - "ca_cert": ca_pem, - "client_id": client_id or "" - } - - -def create_client_config( - server_host: str, - server_port: int, - output_path: str, - config_path: str = CONFIG_FILE_NAME, - hermes_host: Optional[str] = None, - hermes_port: Optional[int] = None -) -> Dict[str, Any]: - """Creates a client configuration file containing the server address, auth token, and encryption key.""" - server_conf = load_or_init_config(config_path) - - effective_hermes_host = hermes_host - if not effective_hermes_host: - cfg_hermes = server_conf.get("hermes_host") - effective_hermes_host = server_host if cfg_hermes in ("0.0.0.0", "::", None) else cfg_hermes - - client_conf = { - "server_host": server_host, - "server_port": server_port, - "hermes_host": effective_hermes_host, - "hermes_port": hermes_port or server_conf.get("hermes_port", 8443), - "enrollment_secret": server_conf.get("enrollment_secret"), - "cert_dir": "certs", - "server_fingerprint": server_conf["server_fingerprint"], - "server_public_key": server_conf["public_key"], - "auth_token": server_conf["auth_token"] - } - - out_dir = os.path.dirname(os.path.abspath(output_path)) - if out_dir and not os.path.exists(out_dir): - os.makedirs(out_dir, exist_ok=True) - - with open(output_path, "w", encoding="utf-8") as f: - json.dump(client_conf, f, indent=2) - - print(f"[+] Client configuration successfully written to: {os.path.abspath(output_path)}") - print(f" - Server Target: {server_host}:{server_port}") - print(f" - Encryption Fingerprint: {server_conf['server_fingerprint']}") - return client_conf - - -# ===================================================================== -# Database, Storage & Crypto Integrations -# ===================================================================== - -def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str: - return StorageEngine.init_db(db_path, enrollment_secret=enrollment_secret, max_seats=max_seats) - - -def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]: - return StorageEngine.init_postgres_db(pg_config) - - -def migrate_sqlite_to_postgres(sqlite_path: str, pg_config: Dict[str, Any]) -> Tuple[bool, str, Dict[str, int]]: - return StorageEngine.migrate_sqlite_to_postgres(sqlite_path, pg_config, encrypt_fn=encrypt_field_for_storage) - - -def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: - return LicenseManager.export_license_request_lic(config) - - -def get_active_licensed_features(db_path: Optional[str] = None) -> List[str]: - return LicenseManager.get_active_licensed_features(db_path=db_path, server_state=SERVER_STATE) - - -def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool: - return LicenseManager.is_feature_licensed(feature, db_path=db_path, server_state=SERVER_STATE) - - -def get_server_public_key() -> Optional[pgpy.PGPKey]: - return StorageCryptoManager.get_server_public_key(server_state=SERVER_STATE) - - -def encrypt_field_for_storage(text: str, force: bool = False) -> str: - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - is_unlocked = is_feature_licensed("enterprise_db", db_path) - return StorageCryptoManager.encrypt_field_for_storage(text, force=force, server_state=SERVER_STATE, is_unlocked=is_unlocked) - - -def decrypt_field_from_storage(text: str) -> str: - return StorageCryptoManager.decrypt_field_from_storage(text, server_state=SERVER_STATE) - - -def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]: - target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db) - return StorageEngine.get_storage_engine_info( - db_path=target_db, server_state=SERVER_STATE, ingest_queue=INGEST_QUEUE, is_unlocked=is_unlocked - ) - - -def get_license_summary(db_path: Optional[str] = None) -> Dict[str, Any]: - return LicenseManager.get_license_summary(server_state=SERVER_STATE, db_path=db_path) - - -def report_seat_quota_to_authority_sync(): - LicenseManager.report_seat_quota_to_authority_sync(server_state=SERVER_STATE) - - -def trigger_async_seat_report(): - LicenseManager.trigger_async_seat_report(server_state=SERVER_STATE) - - -# ===================================================================== -# PKI & mTLS Management -# ===================================================================== - -def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: - return PKIManager.init_mtls_server_context(cert_dir=cert_dir) - - -def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): - PKIManager.reload_mtls_context(ssl_ctx, cert_dir=cert_dir) - - -def check_and_rotate_server_certs( - cert_dir: str = "certs", - hostnames: Optional[List[str]] = None, - threshold_days: int = 30 -) -> bool: - return PKIManager.check_and_rotate_server_certs( - cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days, server_state=SERVER_STATE - ) - - -# ===================================================================== -# Log Ingestion & Processing -# ===================================================================== - -async def ingest_buffer_worker_loop(): - """Periodically flushes the in-memory ingestion buffer to persistent database storage.""" - while True: - try: - await asyncio.sleep(INGEST_QUEUE.flush_interval) - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - window_hours = config.get("evaluation_window_hours", 12) - min_runs = config.get("min_persistence_runs", 1) - INGEST_QUEUE.flush( - db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs - ) - except asyncio.CancelledError: - break - except Exception as e: - print(f"[!] Ingestion buffer worker error: {e}") - - -def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]: - """ - Evaluates ingested log anomalies and updates temporal run tracking. - All anomalies are promoted directly to VERIFIED for Hermes AI reasoning and decision-making. - Supports SQLite and external PostgreSQL with optional OpenPGP data encryption at rest. - """ - client_server = payload.get("server", "unknown-host") - site_name = payload.get("site_name") or (client_server.split(".", 1)[1] if "." in client_server else "default") - logs = payload.get("logs", []) - run_id = str(uuid.uuid4()) - now = datetime.now(timezone.utc) - now_iso = now.isoformat() - - config = SERVER_STATE.get("config", {}) - db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower() - - if db_type in ("postgres", "postgresql"): - try: - try: - import psycopg2 - except ImportError: - import psycopg as psycopg2 - - pg_cfg = config.get("postgres", {}) - conn = psycopg2.connect( - host=pg_cfg.get("host", "localhost"), - port=int(pg_cfg.get("port", 5432)), - dbname=pg_cfg.get("database", "logar"), - user=pg_cfg.get("user", "postgres"), - password=pg_cfg.get("password", ""), - sslmode=pg_cfg.get("sslmode", "prefer"), - connect_timeout=int(pg_cfg.get("connect_timeout", 5)) - ) - cursor = conn.cursor() - cursor.execute( - "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (%s, %s, %s, %s, %s)", - (run_id, site_name, client_server, now_iso, len(logs)) - ) - processed_count = 0 - promoted_to_verified = 0 - - for log in logs: - severity = str(log.get("severity", "WARNING")).upper() - if severity in ["DEBUG", "TRACE"]: - continue - - signature = log.get("signature", "unknown") - server = log.get("server", client_server) - message = log.get("message", "") - os_type = log.get("os_type", "unknown") - - # Suppress repetitive benign routine service start/stop cycles - if is_routine_service_noise(signature, message, severity): - continue - - fp = f"{site_name}:{server}:{signature}" - stored_message = encrypt_field_for_storage(message) - - cursor.execute( - "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = %s", - (fp,) - ) - row = cursor.fetchone() - - if row: - run_count, first_seen_str, last_seen_str, current_status, last_run_id = row - try: - last_seen_dt = datetime.fromisoformat(last_seen_str) - except Exception: - last_seen_dt = now - - if (now - last_seen_dt) > timedelta(hours=window_hours): - new_runs = 1 - new_first_seen = now_iso - else: - new_runs = run_count + 1 if last_run_id != run_id else run_count - new_first_seen = first_seen_str - - new_status = "VERIFIED" - if current_status != "VERIFIED": - promoted_to_verified += 1 - - cursor.execute(""" - UPDATE active_issues - SET run_count = %s, last_seen = %s, first_seen = %s, status = %s, last_run_id = %s, message = %s, severity = %s - WHERE fingerprint = %s - """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) - else: - initial_status = "VERIFIED" - promoted_to_verified += 1 - cursor.execute(""" - INSERT INTO active_issues - (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) - - processed_count += 1 - - # Update client node metrics if attached - metrics = payload.get("metrics") - if metrics and isinstance(metrics, dict) and client_server: - cpu_p = float(metrics.get("cpu_percent", 0.0)) - mem_p = float(metrics.get("memory_percent", 0.0)) - disk_p = float(metrics.get("disk_percent_free", 0.0)) - try: - cursor.execute(""" - UPDATE clients - SET last_seen = %s, metrics_json = %s, cpu_percent = %s, memory_percent = %s, disk_percent_free = %s - WHERE client_id = %s OR hostname = %s - """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) - except Exception: - pass - - conn.commit() - conn.close() - return { - "status": "success", - "run_id": run_id, - "processed": processed_count, - "promoted_verified": promoted_to_verified - } - except Exception as pg_err: - print(f"[!] Warning: PostgreSQL ingestion failed ({pg_err}), falling back to SQLite...") - - # SQLite standard / fallback path - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute( - "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (?, ?, ?, ?, ?)", - (run_id, site_name, client_server, now_iso, len(logs)) - ) - - processed_count = 0 - promoted_to_verified = 0 - - for log in logs: - severity = str(log.get("severity", "WARNING")).upper() - if severity in ["DEBUG", "TRACE"]: - continue - - signature = log.get("signature", "unknown") - server = log.get("server", client_server) - message = log.get("message", "") - os_type = log.get("os_type", "unknown") - - # Suppress repetitive benign routine service start/stop cycles - if is_routine_service_noise(signature, message, severity): - continue - - fp = f"{site_name}:{server}:{signature}" - stored_message = encrypt_field_for_storage(message) - - cursor.execute( - "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = ?", - (fp,) - ) - row = cursor.fetchone() - - if row: - run_count, first_seen_str, last_seen_str, current_status, last_run_id = row - try: - last_seen_dt = datetime.fromisoformat(last_seen_str) - except Exception: - last_seen_dt = now - - if (now - last_seen_dt) > timedelta(hours=window_hours): - new_runs = 1 - new_first_seen = now_iso - else: - new_runs = run_count + 1 if last_run_id != run_id else run_count - new_first_seen = first_seen_str - - new_status = "VERIFIED" - if current_status != "VERIFIED": - promoted_to_verified += 1 - - cursor.execute(""" - UPDATE active_issues - SET run_count = ?, last_seen = ?, first_seen = ?, status = ?, last_run_id = ?, message = ?, severity = ? - WHERE fingerprint = ? - """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) - else: - initial_status = "VERIFIED" - promoted_to_verified += 1 - cursor.execute(""" - INSERT INTO active_issues - (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) - - processed_count += 1 - - # Update client node metrics & version if attached - metrics = payload.get("metrics") - client_ver = payload.get("version") - if metrics and isinstance(metrics, dict) and client_server: - cpu_p = float(metrics.get("cpu_percent", 0.0)) - mem_p = float(metrics.get("memory_percent", 0.0)) - disk_p = float(metrics.get("disk_percent_free", 0.0)) - try: - if client_ver: - cursor.execute(""" - UPDATE clients - SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, client_version = ? - WHERE client_id = ? OR hostname = ? - """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_ver, client_server, client_server)) - else: - cursor.execute(""" - UPDATE clients - SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? - WHERE client_id = ? OR hostname = ? - """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) - except Exception: - pass - elif client_ver and client_server: - try: - cursor.execute(""" - UPDATE clients SET last_seen = ?, client_version = ? WHERE client_id = ? OR hostname = ? - """, (now_iso, client_ver, client_server, client_server)) - except Exception: - pass - - conn.commit() - conn.close() - - return { - "status": "success", - "run_id": run_id, - "processed": processed_count, - "promoted_verified": promoted_to_verified - } - - -def get_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: - collector = WindowsLogCollector(state=state, channels=["Application", "System"]) - return collector.collect_logs(hours=hours) - - -def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: - collector = LinuxLogCollector(state=state) - return collector.collect_logs(hours=hours) - - -def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list: - collector = get_log_collector(state=state) - return collector.collect_logs(hours=hours) - - -def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int: - """Executes one cycle of local host log collection and direct ingestion into SQLite.""" - machine_id = get_machine_identifier() - os_type = "windows" if sys.platform == "win32" else "linux" - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - - try: - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) - if c.fetchone(): - c.execute(""" - UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? - WHERE client_id = ? - """, (machine_id, os_type, now_sql, machine_id)) - else: - c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) - VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) - """, (machine_id, machine_id, os_type, now_sql, now_sql)) - conn.commit() - conn.close() - except Exception as e: - print(f"[!] Warning: Failed to update local hub client record: {e}") - - logs = get_local_system_logs(hours=window_hours, state=state) - if not logs: - return 0 - - payload = { - "server": machine_id, - "site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub", - "timestamp": datetime.now(timezone.utc).isoformat(), - "logs": logs - } - res = process_ingested_logs(payload, db_path=db_path, window_hours=window_hours, min_runs=min_runs) - print(f"[+] Integrated Local Collector: Processed {len(logs)} local host events ({res.get('promoted_verified', 0)} verified anomalies).") - return len(logs) - - -async def local_collector_loop(interval_seconds: int = 30): - """Background worker that periodically ingests local host logs without requiring a client license.""" - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - window_hours = config.get("evaluation_window_hours", 12) - min_runs = config.get("min_persistence_runs", 1) - - state_file = resolve_writable_path("local_collector_state.json", "LOGAR-Server", "local_collector_state.json") - state = {} - if os.path.exists(state_file): - try: - with open(state_file, "r", encoding="utf-8") as f: - state = json.load(f) - except Exception: - state = {} - - try: - run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) - with open(state_file, "w", encoding="utf-8") as f: - json.dump(state, f) - except Exception as e: - print(f"[!] Error in initial local log collection cycle: {e}") - - while True: - try: - await asyncio.sleep(interval_seconds) - run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) - with open(state_file, "w", encoding="utf-8") as f: - json.dump(state, f) - except asyncio.CancelledError: - break - except Exception as e: - print(f"[!] Exception in local_collector_loop: {e}") - - -async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30): - """Periodically checks the validity of Hub Root CA and Server TLS certificates and reports seat quotas.""" - config = SERVER_STATE.get("config", {}) - cert_dir = config.get("cert_dir", "certs") - hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] - while True: - try: - await asyncio.sleep(interval_seconds) - check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) - report_seat_quota_to_authority_sync() - except asyncio.CancelledError: - break - except Exception as e: - print(f"[!] Exception in cert_validity_watchdog: {e}") - - -async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - """mTLS TCP socket handler with certificate validation, decryption, and buffering.""" - client_id = None - ssl_obj = writer.get_extra_info("ssl_object") - if ssl_obj: - peercert = ssl_obj.getpeercert() - if peercert and "subject" in peercert: - for rdn in peercert["subject"]: - for key, val in rdn: - if key == "commonName": - client_id = val - break - - if SERVER_STATE.get("tls_enabled", False): - if not client_id: - writer.close() - await writer.wait_closed() - return - - db_path = SERVER_STATE["config"]["db_path"] - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,)) - row = c.fetchone() - if not row or row[0] != "active": - conn.close() - writer.close() - await writer.wait_closed() - return - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - c.execute("UPDATE clients SET last_seen = ? WHERE client_id = ?", (now_sql, client_id)) - conn.commit() - conn.close() - - try: - length_bytes = await reader.readexactly(4) - length = struct.unpack(">I", length_bytes)[0] - if length <= 0 or length > 10 * 1024 * 1024: - raise ValueError(f"Invalid frame size: {length}") - - payload_bytes = await reader.readexactly(length) - raw_payload = json.loads(payload_bytes.decode("utf-8")) - - if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"): - pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"]) - priv_key = SERVER_STATE["private_key_obj"] - decrypted_obj = priv_key.decrypt(pgp_msg) - log_payload = json.loads(decrypted_obj.message) - else: - log_payload = raw_payload - - if client_id and "server" not in log_payload: - log_payload["server"] = client_id - - cache_mode = SERVER_STATE.get("config", {}).get("cache_mode", "direct") - if cache_mode == "buffered": - INGEST_QUEUE.push(log_payload) - if len(INGEST_QUEUE._buffer) >= INGEST_QUEUE.batch_size: - res = INGEST_QUEUE.flush( - db_path=SERVER_STATE["config"]["db_path"], - window_hours=SERVER_STATE["config"]["evaluation_window_hours"], - min_runs=SERVER_STATE["config"]["min_persistence_runs"], - process_callback=process_ingested_logs - ) - else: - res = { - "status": "success", - "buffered": True, - "processed": len(log_payload.get("logs", [])), - "queue_size": len(INGEST_QUEUE._buffer) - } - else: - res = process_ingested_logs( - log_payload, - db_path=SERVER_STATE["config"]["db_path"], - window_hours=SERVER_STATE["config"]["evaluation_window_hours"], - min_runs=SERVER_STATE["config"]["min_persistence_runs"] - ) - with INGEST_QUEUE._lock: - INGEST_QUEUE._total_ingested += len(log_payload.get("logs", [])) - INGEST_QUEUE._total_flushed += len(log_payload.get("logs", [])) - - resp_bytes = json.dumps(res).encode("utf-8") - writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes) - await writer.drain() - - except Exception as e: - err = json.dumps({"status": "error", "message": str(e)}).encode("utf-8") - try: - writer.write(struct.pack(">I", len(err)) + err) - await writer.drain() - except Exception: - pass - finally: - writer.close() - try: - await writer.wait_closed() - except Exception: - pass - - -# ===================================================================== -# FastAPI Route Handlers -# ===================================================================== - -@app.get("/login", response_class=HTMLResponse) -def login_page(request: Request): - """Renders the dark mode login page for the aggregation server.""" - if get_current_user_optional(request): - return RedirectResponse(url="/", status_code=303) - return HTMLResponse(content=WebUIRenderer.render_login_html()) - - -@app.post("/login") -async def process_login(request: Request): - """Processes login credentials and issues session cookie with 2FA TOTP enforcement.""" - client_ip = get_client_ip(request) - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not check_ip_rate_limit(client_ip, max_attempts=10, window_seconds=60, scope="login", db_path=db_path): - raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.") - - username, password, totp_code = None, None, None - content_type = request.headers.get("content-type", "") - if "application/json" in content_type: - try: - body = await request.json() - username = body.get("username") - password = body.get("password") - totp_code = body.get("totp_code") - except Exception: - pass - else: - form = await request.form() - username = form.get("username") - password = form.get("password") - totp_code = form.get("totp_code") - - if not username or not password: - raise HTTPException(status_code=400, detail="Username and password are required") - - if not verify_user_credentials(username, password, db_path=db_path): - record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) - await asyncio.sleep(0.5) - raise HTTPException(status_code=401, detail="Invalid username or password") - - # Check 2FA TOTP requirement - totp_info = get_user_totp_info(username, db_path=db_path) - if totp_info.get("enabled"): - if not totp_code: - return JSONResponse(status_code=200, content={ - "status": "2fa_required", - "requires_2fa": True, - "message": "Two-factor authentication code required" - }) - if not verify_totp_code(totp_info["secret"], totp_code): - record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) - await asyncio.sleep(0.3) - raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code") - - reset_failed_ip_attempts(client_ip, scope="login", db_path=db_path) - token = generate_session_token(username, db_path=db_path) - is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https" - response = JSONResponse(content={"status": "success", "username": username, "token": token}) - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=token, - httponly=True, - secure=is_https, - max_age=86400, - samesite="lax" - ) - return response - - -@app.get("/logout") -def process_logout(): - """Logs out and redirects to /login.""" - resp = RedirectResponse(url="/login", status_code=303) - resp.delete_cookie(SESSION_COOKIE_NAME) - return resp - - -@app.post("/api/auth/change-password") -async def api_change_password(req: ChangePasswordRequest, current_user: str = Depends(require_admin_auth)): - """Allows authenticated administrator to change password.""" - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not verify_user_credentials(current_user, req.old_password, db_path=db_path): - raise HTTPException(status_code=403, detail="Current password incorrect") - if len(req.new_password) < 6: - raise HTTPException(status_code=400, detail="New password must be at least 6 characters") - change_user_password(current_user, req.new_password, db_path=db_path) - return {"status": "success", "message": "Password updated successfully"} - - -@app.get("/api/user/2fa/status") -def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)): - """Returns 2FA status for currently logged in administrator.""" - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - info = get_user_totp_info(auth_user, db_path=db_path) - return {"enabled": info["enabled"], "username": auth_user} - - -@app.get("/api/user/2fa/setup") -def api_user_2fa_setup(auth_user: str = Depends(require_admin_auth)): - """Generates a new TOTP secret and otpauth URI for 2FA provisioning.""" - secret = generate_totp_secret() - otpauth = get_totp_uri(auth_user, secret, issuer="LOGAR Server Hub") - return {"secret": secret, "otpauth_uri": otpauth, "username": auth_user} - - -@app.post("/api/user/2fa/enable") -async def api_user_2fa_enable(request: Request, auth_user: str = Depends(require_admin_auth)): - """Enables TOTP for the current user after validating 6-digit code.""" - body = await request.json() - secret = body.get("secret", "").strip() - code = body.get("code", "").strip() - if not secret or not code: - raise HTTPException(status_code=400, detail="Secret and 6-digit verification code required") - if not verify_totp_code(secret, code): - raise HTTPException(status_code=400, detail="Invalid 6-digit TOTP verification code. Please check your device clock.") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - enable_user_totp(auth_user, secret, db_path=db_path) - return {"status": "success", "message": "2FA successfully activated"} - - -@app.post("/api/user/2fa/disable") -async def api_user_2fa_disable(request: Request, auth_user: str = Depends(require_admin_auth)): - """Disables TOTP for the current user after verifying password.""" - body = await request.json() - password = body.get("password", "") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not verify_user_credentials(auth_user, password, db_path=db_path): - raise HTTPException(status_code=403, detail="Current password incorrect") - disable_user_totp(auth_user, db_path=db_path) - return {"status": "success", "message": "2FA successfully disabled"} - - -@app.post("/api/client/enroll") -def enroll_client(req: ClientEnrollRequest, request: Request = None): - """Enrolls an edge client by validating the enrollment secret and issuing a signed certificate.""" - client_ip = get_client_ip(request) - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not check_ip_rate_limit(client_ip, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path): - raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.") - - conn = sqlite3.connect(db_path) - c = conn.cursor() - - c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1") - row = c.fetchone() - if not row: - conn.close() - raise HTTPException(status_code=500, detail="License configuration not initialized") - - expected_secret = row[0] - lic_summary = LicenseManager.get_license_summary(SERVER_STATE, db_path=db_path) - max_seats = lic_summary.get("max_seats", 0) - license_status = lic_summary.get("status", "UNLICENSED").lower() - if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"): - conn.close() - raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.") - - if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): - conn.close() - record_failed_ip_attempt(client_ip, scope="enroll", db_path=db_path) - time.sleep(0.5) - raise HTTPException(status_code=403, detail="Invalid enrollment secret") - - reset_failed_ip_attempts(client_ip, scope="enroll", db_path=db_path) - - ca_cert = SERVER_STATE.get("ca_cert") - ca_key = SERVER_STATE.get("ca_key") - ca_cert_pem = SERVER_STATE.get("ca_cert_pem") - - if not ca_cert or not ca_key: - conn.close() - raise HTTPException(status_code=500, detail="Root CA not loaded on server") - - cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") - upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_ca_path): - try: - with open(upstream_ca_path, "r", encoding="utf-8") as f: - upstream_pem = f.read().strip() - if upstream_pem and upstream_pem not in ca_cert_pem: - ca_cert_pem = ca_cert_pem.strip() + "\n" + upstream_pem - except Exception: - pass - - c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) - existing = c.fetchone() - if existing: - if existing[0] == "revoked": - conn.close() - raise HTTPException(status_code=403, detail="Client seat revoked by administrator.") - else: - c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") - active_count = c.fetchone()[0] - if active_count >= max_seats: - conn.close() - raise HTTPException(status_code=403, detail=f"License seat limit reached ({active_count}/{max_seats} seats used).") - - cert_pem, key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key) - fp = enrollment.calculate_cert_fingerprint(cert_pem) - - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - client_ver = req.version or "" - if existing: - try: - c.execute(""" - UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? - WHERE client_id = ? - """, (req.hostname, req.os, fp, now_sql, client_ver, req.client_id)) - except sqlite3.OperationalError: - c.execute(""" - UPDATE clients - SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? - WHERE client_id = ? - """, (req.hostname, req.os, fp, now_sql, req.client_id)) - else: - try: - c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) - VALUES (?, ?, ?, ?, 'active', ?, ?, ?) - """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) - except sqlite3.OperationalError: - c.execute(""" - INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) - VALUES (?, ?, ?, ?, 'active', ?, ?) - """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) - - conn.commit() - conn.close() - - trigger_async_seat_report() - - return { - "status": "success", - "client_id": req.client_id, - "client_cert": cert_pem, - "client_key": key_pem, - "ca_cert": ca_cert_pem, - "server_fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), - "server_public_key": SERVER_STATE.get("config", {}).get("public_key", "") - } - - -@app.get("/api/client/export-profile") -def export_client_profile( - server_host: Optional[str] = None, - server_port: Optional[int] = None, - client_id: Optional[str] = None, - request: Request = None -): - """Exports standalone client profile JSON for instant client deployment.""" - rotate_enrollment_secret() - - cfg = SERVER_STATE.get("config", {}) - host = server_host - if not host: - host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or cfg.get("tcp_host", "127.0.0.1") - if host in ("0.0.0.0", "localhost", ""): - host = socket.gethostname() - - port = server_port or cfg.get("tcp_port", 9443) - profile = generate_client_profile_dict(server_host=host, server_port=port, client_id=client_id, config_dict=cfg) - return JSONResponse( - content=profile, - headers={"Content-Disposition": f'attachment; filename="logar_client_{host}_{port}.json"'} - ) - - -@app.post("/api/server/reset-enrollment-secret") -def reset_server_enrollment_secret(auth_user: str = Depends(require_admin_auth)): - """Rotates and generates a fresh server enrollment secret on demand.""" - new_secret = rotate_enrollment_secret() - return { - "status": "success", - "message": "Server enrollment secret successfully rotated and updated.", - "enrollment_secret": new_secret - } - - -# ===================================================================== -# Update & Distribution Endpoints -# ===================================================================== - -@app.get("/api/update/status") -@app.get("/api/server/update/status") -def get_update_status(user: str = Depends(get_current_user_optional)): - """Returns current server update and client package distribution repository status.""" - mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER - return mgr.get_status() - - -@app.post("/api/update/check") -@app.post("/api/server/update/check") -def check_server_updates(user: str = Depends(get_current_user_optional)): - """Triggers an immediate upstream release check.""" - mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER - return mgr.check_upstream_updates(force=True) - - -@app.get("/api/client/update/check") -def check_client_update( - client_id: str, - os: Optional[str] = "windows", - os_name: Optional[str] = None, - current_version: Optional[str] = None, - arch: str = "x86_64" -): - """ - Invoked by edge forwarders to check whether a newer client binary package is available. - """ - import os as _os - target_os = os_name or os or "windows" - mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER - - # Record check timestamp in database - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if target_os: - now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") - try: - conn = sqlite3.connect(db_path) - conn.execute(""" - UPDATE clients SET last_update_check = ?, client_version = COALESCE(NULLIF(?, ''), client_version) - WHERE client_id = ? - """, (now_sql, current_version or "", client_id)) - conn.commit() - conn.close() - except Exception: - pass - - return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) - - -@app.get("/api/client/update/download") -def download_client_update_package( - request: Request, - os: Optional[str] = "windows", - os_name: Optional[str] = None -): - """ - Streams the latest client binary or installer package for the specified OS. - Supports HTTP ETag and conditional 304 Not Modified requests to minimize network bandwidth. - """ - import os as _os - import email.utils - target_os = os_name or os or "windows" - mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER - pkg_tuple = mgr.get_client_package_path(os_type=target_os) - if not pkg_tuple: - raise HTTPException(status_code=404, detail=f"No client update package available for OS: {target_os}") - - file_path, filename, sha256 = pkg_tuple - if not _os.path.exists(file_path): - raise HTTPException(status_code=404, detail="Client package file missing on server disk.") - - etag = f'"{sha256}"' - if request is not None: - if_none_match = request.headers.get("if-none-match") - if if_none_match and (if_none_match == etag or if_none_match.strip('"') == sha256): - return Response( - status_code=304, - headers={ - "ETag": etag, - "Cache-Control": "public, max-age=300, must-revalidate", - "X-Checksum-SHA256": sha256 - } - ) - - try: - mtime = _os.path.getmtime(file_path) - last_modified = email.utils.formatdate(mtime, usegmt=True) - except Exception: - last_modified = None - - if_modified_since = request.headers.get("if-modified-since") - if if_modified_since and last_modified and if_modified_since == last_modified: - return Response( - status_code=304, - headers={ - "ETag": etag, - "Cache-Control": "public, max-age=300, must-revalidate", - "X-Checksum-SHA256": sha256 - } - ) - - headers = { - "ETag": etag, - "Cache-Control": "public, max-age=300, must-revalidate", - "X-Checksum-SHA256": sha256, - "Content-Disposition": f'attachment; filename="{filename}"' - } - if last_modified: - headers["Last-Modified"] = last_modified - - return FileResponse( - path=file_path, - filename=filename, - media_type="application/octet-stream", - headers=headers - ) - - -@app.post("/api/server/client-package/upload") -async def upload_client_package( - request: Request, - auth_user: str = Depends(require_admin_auth) -): - """ - Uploads a new client executable/installer into the server's update distribution repository. - Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." } - or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'. - """ - import base64 - import os as _os - mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER - content_type = request.headers.get("content-type", "") - - os_type = "windows" - version = None - filename = "client_update.bin" - file_bytes = b"" - - if "application/json" in content_type: - body = await request.json() - os_type = body.get("os", "windows") - version = body.get("version") - raw_name = body.get("filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") - filename = _os.path.basename(str(raw_name)) - b64_data = body.get("data_base64", "") - file_bytes = base64.b64decode(b64_data) - else: - os_type = request.headers.get("X-Package-OS", "windows") - version = request.headers.get("X-Package-Version") - raw_name = request.headers.get("X-Package-Filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") - filename = _os.path.basename(str(raw_name)) - file_bytes = await request.body() - - if not file_bytes: - raise HTTPException(status_code=400, detail="Empty package payload.") - - temp_dir = _os.path.join(mgr.update_dir, "temp") - _os.makedirs(temp_dir, exist_ok=True) - temp_path = _os.path.join(temp_dir, filename) - - with open(temp_path, "wb") as f: - f.write(file_bytes) - - try: - manifest = mgr.register_client_package( - os_type=os_type, - file_path=temp_path, - version=version, - filename=filename - ) - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - pass - return {"status": "success", "manifest": manifest, "sha256": manifest.get("sha256")} - except Exception as e: - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - pass - raise HTTPException(status_code=400, detail=f"Package upload registration failed: {e}") - - -@app.get("/api/clients") -def list_clients(auth_user: str = Depends(require_admin_auth)): - """Returns all registered clients, seat usage, and activity timestamps.""" - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT max_seats FROM license_config WHERE id = 1") - lic_row = c.fetchone() - max_seats = lic_row[0] if lic_row else 10 - - c.execute(""" - SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, - metrics_json, cpu_percent, memory_percent, disk_percent_free - FROM clients ORDER BY last_seen DESC - """) - rows = c.fetchall() - conn.close() - - clients = [] - for r in rows: - cid, host, ost, fp, st, first, last, mjson, cpu, mem, disk = r - m_dict = {} - if mjson: - try: - m_dict = json.loads(mjson) - except Exception: - pass - clients.append({ - "client_id": cid, - "hostname": host, - "os_type": ost, - "cert_fingerprint": fp, - "status": st, - "first_seen": first, - "last_seen": last, - "last_activity_relative": format_relative_time(last), - "metrics": m_dict, - "cpu_percent": cpu or 0.0, - "memory_percent": mem or 0.0, - "disk_percent_free": disk or 0.0 - }) - - active_count = sum(1 for cl in clients if cl["status"] == "active" and cl.get("cert_fingerprint") != "LOCAL-HOST-BUILTIN") - return { - "active_seats": active_count, - "max_seats": max_seats, - "clients": clients - } - - -@app.post("/api/client/approve") -@app.post("/api/client/reactivate") -def approve_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): - """Approves / reactivates a client certificate if within seat quota.""" - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT max_seats FROM license_config WHERE id = 1") - row = c.fetchone() - max_seats = row[0] if row else 10 - - c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") - active_count = c.fetchone()[0] - if active_count >= max_seats: - conn.close() - raise HTTPException(status_code=403, detail=f"Cannot activate seat: maximum quota ({max_seats}) reached.") - - c.execute("UPDATE clients SET status = 'active' WHERE client_id = ?", (action.client_id,)) - affected = c.rowcount - conn.commit() - conn.close() - if affected == 0: - raise HTTPException(status_code=404, detail="Client ID not found") - print(f"[+] Approved/Reactivated client seat: {action.client_id}") - trigger_async_seat_report() - return {"status": "active", "client_id": action.client_id} - - -reactivate_client = approve_client - - -@app.post("/api/client/revoke") -def revoke_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): - """Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes.""" - if action.client_id == get_machine_identifier(): - raise HTTPException(status_code=400, detail="Cannot revoke integrated local hub collector") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("UPDATE clients SET status = 'revoked' WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) - affected = c.rowcount - conn.commit() - conn.close() - if affected == 0: - raise HTTPException(status_code=404, detail="Client ID not found") - print(f"[!] Revoked client license seat: {action.client_id}") - trigger_async_seat_report() - return {"status": "revoked", "client_id": action.client_id} - - -@app.post("/api/client/delete") -@app.post("/api/clients/delete") -@app.post("/api/clients/remove") -def delete_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): - """Permanently removes a client forwarder record from SQLite, immediately freeing its seat.""" - if action.client_id == get_machine_identifier(): - raise HTTPException(status_code=400, detail="Cannot remove integrated local hub collector") - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("DELETE FROM clients WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) - affected = c.rowcount - conn.commit() - conn.close() - if affected == 0: - raise HTTPException(status_code=404, detail="Client ID not found") - print(f"[+] Removed client forwarder record: {action.client_id}") - trigger_async_seat_report() - return {"status": "deleted", "client_id": action.client_id} - - -@app.get("/api/hermes/tokens") -def api_list_hermes_tokens(auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - return list_hermes_tokens(db_path=db_path) - - -@app.post("/api/hermes/tokens/create") -def api_create_hermes_token(req: HermesTokenCreateRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not req.name or not req.name.strip(): - raise HTTPException(status_code=400, detail="Token name is required") - return create_hermes_token(req.name.strip(), db_path=db_path) - - -@app.post("/api/hermes/tokens/revoke") -def api_revoke_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not revoke_hermes_token(req.token, db_path=db_path): - raise HTTPException(status_code=404, detail="Token not found") - return {"status": "success", "message": "Token revoked"} - - -@app.post("/api/hermes/tokens/delete") -def api_delete_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not delete_hermes_token(req.token, db_path=db_path): - raise HTTPException(status_code=404, detail="Token not found") - return {"status": "success", "message": "Token deleted"} - - -@app.get("/api/hermes/report") -def get_verified_anomalies_for_hermes(token_ctx: str = Depends(require_hermes_or_admin_auth)): - """ - Ingestion endpoint for Hermes agentic workflows. - Returns verified anomalies within the active 12-hour evaluation window, - system resource context, and silent/offline forwarder alerts for Hermes AI reasoning. - """ - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute(""" - SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status - FROM active_issues - WHERE status = 'VERIFIED' - ORDER BY last_seen DESC - """) - rows = cursor.fetchall() - - now = datetime.now(timezone.utc) - try: - cursor.execute("SELECT client_id, hostname, os_type, last_seen, metrics_json, cpu_percent, memory_percent, disk_percent_free FROM clients WHERE status = 'active'") - client_rows = cursor.fetchall() - except Exception: - client_rows = [] - conn.close() - - window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12) - report = [] - - client_metrics_map = {} - for cl in client_rows: - cid, chost, cost, clast, cmetrics_raw, ccpu, cmem, cdisk = cl - try: - m_dict = json.loads(cmetrics_raw) if cmetrics_raw else {} - except Exception: - m_dict = {} - if not m_dict: - m_dict = { - "cpu_percent": ccpu or 0.0, - "memory_percent": cmem or 0.0, - "disk_percent_free": cdisk or 0.0 - } - client_metrics_map[cid] = m_dict - if chost: - client_metrics_map[chost] = m_dict - - if clast: - try: - clast_clean = str(clast).replace("T", " ").split("+")[0].split("Z")[0].strip() - last_dt = datetime.fromisoformat(clast_clean).replace(tzinfo=timezone.utc) - if (now - last_dt) > timedelta(minutes=15): - offline_mins = int((now - last_dt).total_seconds() // 60) - report.append({ - "fingerprint": f"offline-node-{cid}", - "site": "default", - "server": chost or cid, - "signature": "LOGAR/NodeSilent", - "severity": "CRITICAL", - "message": f"Forwarder node '{chost or cid}' has gone silent! Last heartbeat was {offline_mins} minutes ago ({clast}). Potential node crash, network partition, or power failure.", - "os_type": cost or "unknown", - "first_seen": clast, - "last_seen": clast, - "consecutive_runs": 1, - "evaluation_window": f"{window_hours}h", - "verified": True, - "status": "NODE_OFFLINE", - "metrics": m_dict - }) - except Exception: - pass - - for r in rows: - srv_name = r[2] - report.append({ - "fingerprint": r[0], - "site": r[1], - "server": srv_name, - "signature": r[3], - "severity": r[4], - "message": decrypt_field_from_storage(r[5]), - "os_type": r[6], - "first_seen": r[7], - "last_seen": r[8], - "consecutive_runs": r[9], - "evaluation_window": f"{window_hours}h", - "verified": True, - "status": r[10], - "metrics": client_metrics_map.get(srv_name, {}) - }) - - return report - - -@app.get("/api/issues") -def get_all_issues(auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute("SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues ORDER BY last_seen DESC") - issues = [ - { - "fingerprint": r[0], - "site_name": r[1], - "server": r[2], - "signature": r[3], - "severity": r[4], - "message": decrypt_field_from_storage(r[5]), - "os_type": r[6], - "first_seen": r[7], - "last_seen": r[8], - "run_count": r[9], - "status": r[10] - } - for r in c.fetchall() - ] - conn.close() - return issues - - -@app.get("/api/license/status") -def api_license_status(): - return get_license_summary() - - -@app.get("/api/license/export-lic") -def export_lic_file(auth_user: str = Depends(require_admin_auth)): - config = SERVER_STATE.get("config", {}) - lic_dict, lic_json = export_license_request_lic(config) - hostname = socket.gethostname() - return Response( - content=lic_json, - media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="logar_license_request_{hostname}.lic"'} - ) - - -@app.get("/api/license/ca-csr") -def get_ca_csr(auth_user: str = Depends(require_admin_auth)): - cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") - csr_path = os.path.join(cert_dir, "ca.csr") - if not os.path.exists(csr_path): - enrollment.generate_ca_csr(cert_dir=cert_dir) - with open(csr_path, "r", encoding="utf-8") as f: - csr_content = f.read() - return Response( - content=csr_content, - media_type="application/x-pem-file", - headers={"Content-Disposition": 'attachment; filename="ca.csr"'} - ) - - -@app.post("/api/license/import") -def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(require_admin_auth)): - cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") - ca_file = os.path.join(cert_dir, "ca.crt") - upstream_file = os.path.join(cert_dir, "upstream_ca.crt") - upstream_pem = req.upstream_ca.strip() if req.upstream_ca else None - if not upstream_pem and os.path.exists(upstream_file): - try: - with open(upstream_file, "r", encoding="utf-8") as f: - upstream_pem = f.read().strip() - except Exception: - upstream_pem = None - - if upstream_pem: - if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem): - raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.") - - try: - info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}") - - with open(ca_file, "w", encoding="utf-8") as f: - f.write(req.ca_cert.strip() + "\n") - - if req.upstream_ca: - with open(upstream_file, "w", encoding="utf-8") as f: - f.write(req.upstream_ca.strip() + "\n") - - new_seats = info.get("seats") - license_id = info.get("license_id") - expires_at = info.get("not_valid_after") - issued_at = info.get("not_valid_before") - features = info.get("features", []) - - config = SERVER_STATE.get("config", {}) - if new_seats: - config["max_seats"] = new_seats - if license_id: - config["license_id"] = license_id - - db_path = config.get("db_path", DEFAULT_DB_FILE) - conn = sqlite3.connect(db_path) - conn.execute(""" - UPDATE license_config - SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? - WHERE id = 1 - """, (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) - conn.commit() - conn.close() - - ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) - SERVER_STATE["ca_cert"] = ca_cert - SERVER_STATE["ca_key"] = ca_key - SERVER_STATE["ca_cert_pem"] = ca_pem - - enrollment.generate_server_cert_if_needed( - ca_cert, ca_key, - hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], - cert_dir=cert_dir, - force_renew=True - ) - - ssl_ctx = SERVER_STATE.get("ssl_ctx") - if ssl_ctx is not None: - try: - reload_mtls_context(ssl_ctx, cert_dir=cert_dir) - except Exception as e: - print(f"[!] Warning: Failed to reload in-flight SSLContext: {e}") - - trigger_async_seat_report() - - return { - "status": "success", - "message": "Signed Subordinate CA certificate successfully activated!", - "license_id": license_id, - "seats": new_seats, - "expires_at": expires_at, - "issued_at": issued_at, - "features": features - } - - -@app.get("/api/db/stats") -def api_db_stats(auth_user: str = Depends(require_admin_auth)): - return { - "status": "success", - "queue": INGEST_QUEUE.get_stats(), - "queue_stats": INGEST_QUEUE.get_stats(), - "storage": get_storage_engine_info(), - "storage_info": get_storage_engine_info(), - "enterprise_db_licensed": is_feature_licensed("enterprise_db"), - "license_summary": get_license_summary() - } - - -@app.post("/api/db/flush-buffer") -def api_db_flush_buffer(auth_user: str = Depends(require_admin_auth)): - config = SERVER_STATE.get("config", {}) - db_path = config.get("db_path", DEFAULT_DB_FILE) - window_hours = config.get("evaluation_window_hours", 12) - min_runs = config.get("min_persistence_runs", 1) - res = INGEST_QUEUE.flush( - db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs - ) - return { - "status": "success", - "message": f"Successfully flushed {res.get('processed_logs', 0)} events across {res.get('flushed_runs', 0)} batch runs ({res.get('flush_latency_ms', 0)}ms).", - "details": res - } - - -@app.post("/api/db/test-connection") -def api_db_test_connection(req: DbConnectionTestRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - if not is_feature_licensed("enterprise_db", db_path): - raise HTTPException(status_code=403, detail="Enterprise database support requires an 'enterprise_db' license capability.") - ok, msg = init_postgres_db(req.dict()) - if not ok: - raise HTTPException(status_code=400, detail=f"Database connection failed: {msg}") - return {"status": "success", "message": msg} - - -@app.post("/api/db/switch-backend") -def api_db_switch_backend(req: DbSwitchBackendRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - config = SERVER_STATE.get("config", {}) - - if req.db_type.lower() in ("postgres", "postgresql"): - if not is_feature_licensed("enterprise_db", db_path): - raise HTTPException(status_code=403, detail="PostgreSQL enterprise storage requires an 'enterprise_db' license capability.") - pg_conf = { - "host": req.host, - "port": req.port, - "database": req.database, - "user": req.user, - "password": req.password, - "sslmode": req.sslmode, - "connect_timeout": req.connect_timeout, - "pool_size": req.pool_size - } - ok, msg = init_postgres_db(pg_conf) - if not ok: - raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}") - config["storage_engine"] = "postgres" - config["db_type"] = "postgres" - config["postgres"] = pg_conf - else: - config["storage_engine"] = "sqlite" - config["db_type"] = "sqlite" - - cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME - try: - with open(cfg_file, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - except Exception as e: - print(f"[!] Warning: Could not save config file on backend switch: {e}") - - return { - "status": "success", - "message": f"Storage backend switched to {config['storage_engine']}.", - "storage_info": get_storage_engine_info() - } - - -@app.post("/api/db/update-config") -def api_db_update_config(req: DbFullConfigRequest, auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - config = SERVER_STATE.get("config", {}) - is_licensed = is_feature_licensed("enterprise_db", db_path) - - if (req.storage_engine.lower() in ("postgres", "postgresql") or req.cache_mode == "buffered" or req.encrypt_at_rest) and not is_licensed: - raise HTTPException( - status_code=403, - detail="Advanced database features (PostgreSQL, in-memory buffering, encryption at rest) require an 'enterprise_db' license capability." - ) - - if req.storage_engine.lower() in ("postgres", "postgresql"): - if req.pg_config: - pg_conf = req.pg_config.dict() - ok, msg = init_postgres_db(pg_conf) - if not ok: - raise HTTPException(status_code=400, detail=f"PostgreSQL initialization failed: {msg}") - config["postgres"] = pg_conf - config["storage_engine"] = "postgres" - config["db_type"] = "postgres" - else: - config["storage_engine"] = "sqlite" - config["db_type"] = "sqlite" - - config["cache_mode"] = req.cache_mode - config["encrypt_at_rest"] = req.encrypt_at_rest - config["buffer_flush_interval_sec"] = req.flush_interval - config["buffer_batch_size"] = req.batch_size - config["buffer_max_queue"] = req.max_queue - - INGEST_QUEUE.flush_interval = req.flush_interval - INGEST_QUEUE.batch_size = req.batch_size - INGEST_QUEUE.max_queue = req.max_queue - - cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME - try: - with open(cfg_file, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - except Exception as e: - print(f"[!] Warning: Could not save config file: {e}") - - return { - "status": "success", - "message": "Storage & Ingestion parameters updated successfully.", - "storage_info": get_storage_engine_info() - } - - -@app.post("/api/db/migrate-to-postgres") -def api_db_migrate_to_postgres(auth_user: str = Depends(require_admin_auth)): - db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) - config = SERVER_STATE.get("config", {}) - - if not is_feature_licensed("enterprise_db", db_path): - raise HTTPException(status_code=403, detail="Database migration requires an 'enterprise_db' license capability.") - - pg_config = config.get("postgres") - if not pg_config: - raise HTTPException(status_code=400, detail="PostgreSQL configuration not found. Please configure PostgreSQL parameters first.") - - ok, msg, counts = migrate_sqlite_to_postgres(db_path, pg_config) - if not ok: - raise HTTPException(status_code=500, detail=f"Migration failed: {msg}") - - return { - "status": "success", - "message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.", - "counts": counts - } - - -@app.get("/", response_class=HTMLResponse) -@app.get("/webui", response_class=HTMLResponse) -def serve_webui(request: Request = None): - """Renders the comprehensive LOGAR Server Hub & Fleet Licensing WebUI.""" - auth_user = None - if request is not None: - auth_user = get_current_user_optional(request) - if not auth_user: - return RedirectResponse(url="/login", status_code=303) - else: - auth_user = DEFAULT_ADMIN_USER - - html_content = WebUIRenderer.render_dashboard_html( - auth_user=auth_user, - request=request, - server_state=SERVER_STATE, - ingest_queue=INGEST_QUEUE - ) - return HTMLResponse(content=html_content) - - -@app.get("/health") -def health_check(): - return { - "status": "healthy", - "server_name": SERVER_STATE.get("config", {}).get("server_name", "LOGAR-Cloud-Hub"), - "fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), - "tcp_port": SERVER_STATE.get("config", {}).get("tcp_port", 9443), - "hermes_port": SERVER_STATE.get("config", {}).get("hermes_port", 8443), - "tls_enabled": SERVER_STATE.get("tls_enabled", False) - } - - -async def run_server(): - """Runs the mTLS TCP socket listener and the Hermes REST API concurrently.""" - config = SERVER_STATE["config"] - tcp_host = config["tcp_host"] - tcp_port = int(config["tcp_port"]) - hermes_host = config["hermes_host"] - hermes_port = int(config["hermes_port"]) - ssl_ctx = SERVER_STATE.get("ssl_ctx") - - tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx) - mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP" - print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}") - - uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning") - uv_server = uvicorn.Server(uv_config) - print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report") - print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll") - - watchdog_task = asyncio.create_task(cert_validity_watchdog()) - collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30)) - buffer_task = asyncio.create_task(ingest_buffer_worker_loop()) - - try: - await asyncio.gather( - tcp_server.serve_forever(), - uv_server.serve(), - watchdog_task, - collector_task, - buffer_task - ) - finally: - watchdog_task.cancel() - collector_task.cancel() - buffer_task.cancel() - for t in [watchdog_task, collector_task, buffer_task]: - try: - await t - except asyncio.CancelledError: - pass - try: - db_p = config.get("db_path", DEFAULT_DB_FILE) - INGEST_QUEUE.flush(db_path=db_p, process_callback=process_ingested_logs) - except Exception: - pass - - -def main(): - parser = argparse.ArgumentParser(description="LOGAR Cloud Hub & TCP Socket Ingestion Server") - parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to server_config.json") - parser.add_argument("--create-client-config", action="store_true", help="Generate a client config with encryption-only fingerprint and server address") - parser.add_argument("--client-out", default="client_config.json", help="Output file path for generated client config") - parser.add_argument("--server-host", default="127.0.0.1", help="Server address to embed in client config") - parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config") - parser.add_argument("--export-lic", type=str, help="Export a .lic license request file containing CA CSR and coordinates") - parser.add_argument("--import-ca", type=str, help="Import and activate a signed CA certificate or activation .lic file") - parser.add_argument("--import-lic", type=str, help="Import and activate an activated .lic license file") - parser.add_argument("--sync-license", action="store_true", help="Sync license with master authority and check for seat increases via mTLS") - args = parser.parse_args() - - config = load_or_init_config(args.config) - config["db_path"] = init_db( - config["db_path"], - enrollment_secret=config.get("enrollment_secret"), - max_seats=config.get("max_seats", 10) - ) - - cert_dir = config.get("cert_dir", "certs") - - # Standalone CLI license export - if args.export_lic: - lic_dict, lic_json = export_license_request_lic(config) - with open(args.export_lic, "w", encoding="utf-8") as f: - f.write(lic_json) - print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}") - sys.exit(0) - - # Standalone CLI CA / .lic import - lic_file = args.import_lic or args.import_ca - if lic_file: - try: - res = enrollment.import_license_package( - lic_file, - cert_dir=cert_dir, - server_fingerprint=config.get("server_fingerprint", ""), - hostname=config.get("server_name", "") - ) - except Exception as e: - print(f"[-] Invalid license file {lic_file}: {e}") - sys.exit(1) - - info = res.get("license_info", {}) - new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10) - config["max_seats"] = new_seats - conn = sqlite3.connect(config["db_path"]) - conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, res.get("license_id") or info.get("license_id"))) - conn.commit() - conn.close() - - ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) - enrollment.generate_server_cert_if_needed( - ca_cert, ca_key, - hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], - cert_dir=cert_dir, - force_renew=True - ) - - print("[+] Successfully imported and activated signed license!") - print(f" - Granted Seats: {new_seats}") - print(f" - License ID: {res.get('license_id') or info.get('license_id')}") - print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}") - sys.exit(0) - - # Standalone CLI license sync / update against master server - if args.sync_license: - lic_server = config.get("license_server_url", "wss://logar.eibl.tech/ws/license") - print(f"[*] Connecting to master authority at {lic_server} via WebSocket to sync license...") - - ca_info = {} - ca_path = os.path.join(cert_dir, "ca.crt") - if os.path.exists(ca_path): - with open(ca_path, "r", encoding="utf-8") as f: - ca_info = enrollment.extract_license_info_from_cert(f.read()) - - ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license" - - active_seats = 0 - if os.path.exists(config.get("db_path", "server_logs.db")): - try: - conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db")) - row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone() - if row: - active_seats = row[0] - conn_tmp.close() - except Exception: - pass - - success, msg, res = enrollment.enroll_and_sync_license_ws( - cert_dir=cert_dir, - license_id=ca_info.get("license_id"), - license_authority_ws_url=ws_url, - server_fingerprint=config.get("server_fingerprint", ""), - hostname=config.get("server_name", ""), - seats_taken=active_seats - ) - - if not success or not res: - print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...") - http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "") - success, msg, new_pem = enrollment.renew_ca_cert_mtls( - cert_dir=cert_dir, - license_server_url=http_url, - seats_taken=active_seats, - active_seats=active_seats, - hostname=config.get("server_name", ""), - server_fingerprint=config.get("server_fingerprint", "") - ) - if not success or not new_pem: - print(f"[-] License sync failed: {msg}") - sys.exit(1) - info = enrollment.extract_license_info_from_cert(new_pem) - new_seats = info.get("seats") or config.get("max_seats", 10) - else: - info = res.get("license_info", {}) - new_seats = res.get("seats", config.get("max_seats", 10)) - - config["max_seats"] = new_seats - conn = sqlite3.connect(config["db_path"]) - conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id"))) - conn.commit() - conn.close() - - ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) - enrollment.generate_server_cert_if_needed( - ca_cert, ca_key, - hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], - cert_dir=cert_dir, - force_renew=True - ) - print("[+] License successfully synchronized with master server!") - print(f" - Active Seat Quota: {new_seats}") - print(f" - License ID: {info.get('license_id')}") - print(f" - Expires: {info.get('not_valid_after')}") - sys.exit(0) - - # Initialize dynamic PKI - ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir) - ca_info = enrollment.extract_license_info_from_cert(ca_pem) - if ca_info.get("seats"): - config["max_seats"] = ca_info["seats"] - conn = sqlite3.connect(config["db_path"]) - conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (ca_info["seats"],)) - conn.commit() - conn.close() - - csr_path = os.path.join(cert_dir, "ca.csr") - if not os.path.exists(csr_path): - enrollment.generate_ca_csr(cert_dir=cert_dir) - - srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed( - ca_cert, ca_key, - hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], - cert_dir=cert_dir - ) - - ssl_ctx = None - if config.get("tls_enabled", True): - ssl_ctx = init_mtls_server_context(cert_dir=cert_dir) - - priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"]) - SERVER_STATE["config"] = config - SERVER_STATE["private_key_obj"] = priv_key_obj - SERVER_STATE["ca_cert"] = ca_cert - SERVER_STATE["ca_key"] = ca_key - SERVER_STATE["ca_cert_pem"] = ca_pem - SERVER_STATE["ssl_ctx"] = ssl_ctx - SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True) - - if args.create_client_config: - port = args.server_port or config["tcp_port"] - create_client_config( - server_host=args.server_host, - server_port=port, - output_path=args.client_out, - config_path=args.config - ) - sys.exit(0) - - hermes_host = config.get("hermes_host", "0.0.0.0") - hermes_port = config.get("hermes_port", 8443) - display_host = "localhost" if hermes_host in ("0.0.0.0", "127.0.0.1") else hermes_host - - print("=" * 64) - print(f" LOGAR Server Hub: {config.get('server_name', 'LOGAR Enterprise Hub')}") - print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}") - print(f" License Quota: {config.get('max_seats', 10)} Active Seats ({ca_info.get('license_id') or 'Local Sub-CA'})") - print(f" Upstream Authority: {config.get('license_server_url', 'https://logar.eibl.tech')}") - print(f" Server Encryption Fingerprint: {config.get('server_fingerprint', 'N/A')}") - print(f" WebUI & Fleet Dashboard: http://{display_host}:{hermes_port}/webui") - print(f" Hermes Reporting API: http://{display_host}:{hermes_port}/api/hermes/report") - print("=" * 64) - - try: - asyncio.run(run_server()) - except KeyboardInterrupt: - print("\n[!] Server shutting down.") - - -if __name__ == "__main__": +from src.hub.Server import * +if __name__ == '__main__': main() \ No newline at end of file diff --git a/src/Win_Client.py b/src/Win_Client.py index dfd1008..0470a4b 100644 --- a/src/Win_Client.py +++ b/src/Win_Client.py @@ -1,151 +1,3 @@ -""" -LOGAR Windows Edge Log Forwarder -Collects Windows Event Logs (Application/System) and securely streams them -to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope. -""" - -import os -import sys -import time -import argparse -import warnings -from typing import Optional - -# Ensure script directory and repo root are in sys.path -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -if SCRIPT_DIR not in sys.path: - sys.path.insert(0, SCRIPT_DIR) -REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) -if REPO_ROOT not in sys.path: - sys.path.insert(0, REPO_ROOT) - -# Suppress cryptography / pgpy deprecation notices -warnings.filterwarnings("ignore") - -import pgpy - -try: - import win32evtlog -except ImportError: - win32evtlog = None - -from log_collector import ( - get_machine_identifier, - is_path_writable, - get_default_data_dir, - resolve_writable_path, - get_state_path, - load_state, - save_state, - commit_state, - WindowsLogCollector, - BaseLogCollector -) -from client_transport import ( - is_cert_expiring_soon, - enroll_client_if_needed, - get_tls_socket, - load_config, - ensure_enrolled as transport_ensure_enrolled, - send_encrypted_logs_over_socket as transport_send_encrypted_logs, - check_and_apply_auto_update -) - -CONFIG_FILE_NAME = "client_config.json" -STATE_FILE_NAME = "client_state.json" - - -def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: - """ - Scans the Windows Application Event Log backwards for events within the window. - Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise. - State Tracking: Skips events older than lookback window and already sent records. - """ - collector = WindowsLogCollector(state=state, channels=["Application"]) - return collector.collect_logs(hours=hours) - - -def send_encrypted_logs_over_socket(config: dict, logs: list): - """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" - return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows") - - -def ensure_enrolled(config: dict, machine_id: str) -> bool: - """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" - return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows") - - -def main(): - parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking") - parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json") - parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)") - parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)") - parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window") - parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)") - args = parser.parse_args() - - try: - config = load_config(args.config) - except Exception as e: - print(f"[!] Configuration error: {e}") - sys.exit(1) - - state_path = get_state_path(args.config, args.state_file) - state = None if args.no_state else load_state(state_path) - - machine_id = config.get("client_id") or get_machine_identifier() - print(f"[*] Edge Forwarder Node: {machine_id}") - if state and "last_record_number" in state: - print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})") - elif not args.no_state: - print(f"[*] State tracking initialized (state file: {state_path})") - - # Proactively ensure dynamic PKI certificate enrollment with Server Hub - ensure_enrolled(config, machine_id) - - collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"]) - - def run_forwarder_cycle(): - # Continuously verify/renew PKI enrollment if server is licensed & online - ensure_enrolled(config, machine_id) - - # Periodically check server for client auto-updates - try: - check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="windows") - except Exception as update_err: - pass - - print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...") - candidate_logs = collector.collect_logs(hours=args.hours) - print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") - - if not candidate_logs: - print("[*] No new unsent events to transmit.") - if state is not None: - commit_state(state, state_path) - return - - try: - resp = send_encrypted_logs_over_socket(config, candidate_logs) - if state is not None and resp and resp.get("status") == "success": - commit_state(state, state_path) - print(f"[+] State successfully committed to {state_path}") - except Exception as e: - print(f"[!] Failed to stream logs to server: {e}") - if not args.interval: - sys.exit(1) - - if args.interval and args.interval > 0: - print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...") - while True: - try: - run_forwarder_cycle() - except Exception as e: - print(f"[!] Error in forwarder cycle: {e}") - time.sleep(args.interval) - else: - run_forwarder_cycle() - - -if __name__ == "__main__": +from src.clients.Win_Client import * +if __name__ == '__main__': main() \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index e69de29..99f7185 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1,13 @@ +""" +LOGAR - Central Cloud Ingestion Hub & Fleet Log Distribution System +Modular package namespace exposing: + - hub: Central server, web UI, storage, licensing, and PKI modules + - clients: Windows and Linux log collector forwarders, transport, and updater modules + - common: Versioning and shared utilities +""" + +from src import hub +from src import clients +from src import common + +__all__ = ["hub", "clients", "common"] diff --git a/src/auth_manager.py b/src/auth_manager.py index 2d3e509..4e5f166 100644 --- a/src/auth_manager.py +++ b/src/auth_manager.py @@ -1,398 +1 @@ -""" -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 +from src.hub.auth_manager import * \ No newline at end of file diff --git a/src/client_transport.py b/src/client_transport.py index a50e98d..1bfdcf3 100644 --- a/src/client_transport.py +++ b/src/client_transport.py @@ -1,340 +1 @@ -""" -LOGAR - Unified Client Transport & PKI Enrollment Module -Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing, -and dynamic certificate enrollment with the LOGAR Server Hub. -""" - -import os -import sys -import json -import socket -import ssl -import struct -import urllib.request -import warnings -from datetime import datetime, timezone, timedelta -from typing import Optional, List, Dict, Any - -# Suppress cryptography / pgpy deprecation notices -warnings.filterwarnings("ignore") - -import pgpy -from log_collector import ( - get_machine_identifier, - is_path_writable, - get_default_data_dir, - resolve_writable_path, - collect_system_metrics -) -try: - from src.version import LOGAR_VERSION - from src.client_updater import ClientUpdater -except ImportError: - from version import LOGAR_VERSION - from client_updater import ClientUpdater - -CONFIG_FILE_NAME = "client_config.json" - - -def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool: - """Checks if client certificate at cert_path is expiring within threshold_days.""" - if not os.path.exists(cert_path): - return True - try: - from cryptography import x509 - with open(cert_path, "r", encoding="utf-8") as f: - cert = x509.load_pem_x509_certificate(f.read().encode("utf-8")) - expiry = getattr(cert, "not_valid_after_utc", None) - if expiry is None: - expiry = cert.not_valid_after.replace(tzinfo=timezone.utc) - now = datetime.now(timezone.utc) - return expiry <= (now + timedelta(days=threshold_days)) - except Exception: - return True - - -def enroll_client_if_needed( - hub_url: str, - enrollment_secret: str, - cert_dir: str, - client_id: str, - hostname: str, - os_type: str = "windows", - force_renew: bool = False, - threshold_days: int = 30 -) -> bool: - """Bootstraps client enrollment if certificates are missing or expiring soon.""" - os.makedirs(cert_dir, exist_ok=True) - ca_path = os.path.join(cert_dir, "ca.crt") - cert_path = os.path.join(cert_dir, "client.crt") - key_path = os.path.join(cert_dir, "client.key") - - if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path): - if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days): - return True - print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...") - - action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling" - print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...") - enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll" - payload = { - "client_id": client_id, - "hostname": hostname, - "os": os_type, - "enrollment_secret": enrollment_secret, - "version": LOGAR_VERSION - } - req = urllib.request.Request( - enroll_endpoint, - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status != 200: - raise RuntimeError(f"Enrollment failed with status code {resp.status}") - data = json.loads(resp.read().decode("utf-8")) - - with open(ca_path, "w", encoding="utf-8") as f: - f.write(data["ca_cert"]) - with open(cert_path, "w", encoding="utf-8") as f: - f.write(data["client_cert"]) - with open(key_path, "w", encoding="utf-8") as f: - f.write(data["client_key"]) - - try: - os.chmod(key_path, 0o600) - except Exception: - pass - - print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}") - return True - - -def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str): - """Establishes an mTLS connection with the LOGAR hub using client certificates.""" - ca_path = os.path.join(cert_dir, "ca.crt") - cert_path = os.path.join(cert_dir, "client.crt") - key_path = os.path.join(cert_dir, "client.key") - - if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)): - raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.") - - ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path) - ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) - ctx.minimum_version = ssl.TLSVersion.TLSv1_3 - ctx.check_hostname = False - - raw_sock = socket.create_connection((hub_host, hub_port), timeout=15) - return ctx.wrap_socket(raw_sock, server_hostname=hub_host) - - -def load_config(config_path: str = CONFIG_FILE_NAME) -> dict: - """Loads and normalizes client configuration JSON.""" - actual_config_path = config_path - if not os.path.exists(actual_config_path): - data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path)) - if os.path.exists(data_dir_cfg): - actual_config_path = data_dir_cfg - - if not os.path.exists(actual_config_path): - raise FileNotFoundError( - f"Client configuration file not found at: {config_path}\n" - f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}" - ) - with open(actual_config_path, "r", encoding="utf-8") as f: - config = json.load(f) - - # Resolve cert_dir to a writable directory - cfg_dir = os.path.dirname(os.path.abspath(actual_config_path)) - raw_cert_dir = config.get("cert_dir", "certs") - if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir): - config["cert_dir"] = raw_cert_dir - else: - candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir)) - if is_path_writable(candidate) or is_path_writable(cfg_dir): - config["cert_dir"] = candidate - else: - config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs") - - # If ca_cert is present in config and ca.crt doesn't exist yet, save it - ca_cert_pem = config.get("ca_cert") - if ca_cert_pem and isinstance(ca_cert_pem, str): - ca_file = os.path.join(config["cert_dir"], "ca.crt") - if not os.path.exists(ca_file): - try: - os.makedirs(config["cert_dir"], exist_ok=True) - with open(ca_file, "w", encoding="utf-8") as f: - f.write(ca_cert_pem) - except Exception: - pass - - return config - - -def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool: - """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" - cert_dir = config.get("cert_dir", "certs") - enrollment_secret = config.get("enrollment_secret") - if not enrollment_secret: - return False - - server_host = config.get("server_host", "127.0.0.1") - hermes_host = config.get("hermes_host", server_host) - if hermes_host in ("0.0.0.0", "::", None, ""): - hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1" - hermes_port = config.get("hermes_port", 8443) - hub_url = f"http://{hermes_host}:{hermes_port}" - try: - return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type) - except Exception as e: - print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}") - return False - - -def send_encrypted_logs_over_socket( - config: dict, - logs: list, - os_type: Optional[str] = None, - machine_id: Optional[str] = None -) -> dict: - """ - Streams logs to the LOGAR hub. - Uses mutual TLS 1.3 (mTLS) with client certificates if available, - or falls back to OpenPGP encrypted envelope over TCP socket. - """ - node_os = os_type or ("windows" if sys.platform == "win32" else "linux") - server_host = config["server_host"] - server_port = int(config["server_port"]) - cert_dir = config.get("cert_dir", "certs") - enrollment_secret = config.get("enrollment_secret") - actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier() - - # Attempt automatic enrollment bootstrap if certs are missing and secret is provided - hub_url = None - if enrollment_secret: - hermes_host = config.get("hermes_host", server_host) - if hermes_host in ("0.0.0.0", "::", None): - hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1" - hermes_port = config.get("hermes_port", 8443) - hub_url = f"http://{hermes_host}:{hermes_port}" - try: - enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os) - except Exception as e: - print(f"[!] Warning: Enrollment bootstrap failed: {e}") - - ca_path = os.path.join(cert_dir, "ca.crt") - cert_path = os.path.join(cert_dir, "client.crt") - key_path = os.path.join(cert_dir, "client.key") - has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path) - - if not has_mtls_certs and enrollment_secret and hub_url: - try: - enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os) - has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path) - except Exception as e: - print(f"[!] Warning: Enrollment before TLS connection failed: {e}") - - if has_mtls_certs: - print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...") - sock = None - try: - sock = get_tls_socket(server_host, server_port, cert_dir) - except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err: - if enrollment_secret and hub_url: - print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...") - try: - enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True) - sock = get_tls_socket(server_host, server_port, cert_dir) - except Exception as retry_err: - print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}") - raise - else: - raise - - with sock: - payload = { - "server": actual_machine_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - "logs": logs, - "metrics": collect_system_metrics(), - "version": LOGAR_VERSION - } - payload_bytes = json.dumps(payload).encode("utf-8") - frame = struct.pack(">I", len(payload_bytes)) + payload_bytes - sock.sendall(frame) - - resp_len_bytes = sock.recv(4) - if not resp_len_bytes: - raise ConnectionError("Server closed mTLS connection without response.") - resp_len = struct.unpack(">I", resp_len_bytes)[0] - resp_bytes = bytearray() - while len(resp_bytes) < resp_len: - chunk = sock.recv(min(4096, resp_len - len(resp_bytes))) - if not chunk: - break - resp_bytes.extend(chunk) - - response = json.loads(resp_bytes.decode("utf-8")) - print(f"[+] Server response: {response}") - return response - - # Fallback to OpenPGP envelope over plain TCP socket - auth_token = config.get("auth_token", "") - pub_key_armored = config.get("server_public_key") - if not pub_key_armored: - raise ValueError("No server public key or mTLS certificates available for connection.") - - expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper() - pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored) - actual_fp = str(pub_key.fingerprint).replace(" ", "").upper() - if expected_fp and actual_fp != expected_fp: - raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.") - - payload = { - "server": actual_machine_id, - "timestamp": datetime.now(timezone.utc).isoformat(), - "logs": logs, - "metrics": collect_system_metrics(), - "version": LOGAR_VERSION - } - payload_json = json.dumps(payload) - pgp_msg = pgpy.PGPMessage.new(payload_json) - encrypted_msg = pub_key.encrypt(pgp_msg) - encrypted_armored = str(encrypted_msg) - - envelope = { - "auth_token": auth_token, - "timestamp": datetime.now(timezone.utc).isoformat(), - "encrypted_payload": encrypted_armored - } - envelope_bytes = json.dumps(envelope).encode("utf-8") - - print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...") - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(15.0) - sock.connect((server_host, server_port)) - - frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes - sock.sendall(frame) - - resp_len_bytes = sock.recv(4) - if not resp_len_bytes: - raise ConnectionError("Server closed connection without response.") - - resp_len = struct.unpack(">I", resp_len_bytes)[0] - resp_bytes = bytearray() - while len(resp_bytes) < resp_len: - chunk = sock.recv(min(4096, resp_len - len(resp_bytes))) - if not chunk: - break - resp_bytes.extend(chunk) - - response = json.loads(resp_bytes.decode("utf-8")) - print(f"[+] Server response: {response}") - return response - - -def check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]: - """Helper to check and apply auto updates for edge forwarders.""" - updater = ClientUpdater(config=config, os_type=os_type) - return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply) +from src.clients.client_transport import * \ No newline at end of file diff --git a/src/client_updater.py b/src/client_updater.py index bfcaff1..9b89b46 100644 --- a/src/client_updater.py +++ b/src/client_updater.py @@ -1,248 +1 @@ -""" -LOGAR - Client Auto-Update Agent Module -Handles checking the LOGAR Server Hub for client updates, downloading verified binaries (SHA-256), -and applying atomic in-place updates across Windows and Linux forwarders. -""" - -import os -import sys -import json -import time -import shutil -import hashlib -import tempfile -import subprocess -import urllib.request -import urllib.error -import urllib.parse -from typing import Dict, Any, Optional, Tuple - -try: - from src.version import LOGAR_VERSION, is_newer_version -except ImportError: - from version import LOGAR_VERSION, is_newer_version - - -class ClientUpdater: - def __init__(self, config: dict, os_type: Optional[str] = None): - self.config = config - self.os_type = (os_type or ("windows" if sys.platform == "win32" else "linux")).lower() - self.current_version = LOGAR_VERSION - - @staticmethod - def calculate_sha256(filepath: str) -> str: - """Computes SHA-256 digest of a local file.""" - if not os.path.exists(filepath): - return "" - h = hashlib.sha256() - with open(filepath, "rb") as f: - while chunk := f.read(65536): - h.update(chunk) - return h.hexdigest().lower() - - def get_server_base_url(self) -> str: - """Resolves the HTTP base URL for server management endpoints.""" - server_host = self.config.get("server_host", "127.0.0.1") - hermes_host = self.config.get("hermes_host", server_host) - if hermes_host in ("0.0.0.0", "::", None, ""): - hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1" - hermes_port = int(self.config.get("hermes_port", 8443)) - return f"http://{hermes_host}:{hermes_port}" - - def check_for_update(self, client_id: str, timeout: int = 6) -> Dict[str, Any]: - """ - Queries the server Hub to check if a newer client binary is available. - """ - base_url = self.get_server_base_url() - url = ( - f"{base_url}/api/client/update/check" - f"?client_id={urllib.parse.quote(client_id)}" - f"&os={urllib.parse.quote(self.os_type)}" - f"¤t_version={urllib.parse.quote(self.current_version)}" - ) - try: - req = urllib.request.Request( - url, - headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status == 200: - return json.loads(resp.read().decode("utf-8")) - except Exception as e: - # Silent fallback during standard forwarder operation - pass - return {"update_available": False, "latest_version": self.current_version} - - def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool: - """ - Downloads the client binary package from the server and validates its SHA-256 checksum. - Uses local binary hash checking and ETag conditional headers to avoid redundant downloads. - """ - # 1. If running binary already matches target SHA-256, skip downloading - target_exec = sys.executable - if expected_sha256 and os.path.exists(target_exec): - try: - local_sha = self.calculate_sha256(target_exec) - if local_sha and local_sha == expected_sha256.lower(): - print(f"[*] Local binary already matches target checksum ({local_sha[:12]}...). Skipping redundant download.") - return True - except Exception: - pass - - base_url = self.get_server_base_url() - download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}" - - print(f"[*] Downloading client update package from {download_url}...") - headers = {"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} - if expected_sha256: - headers["If-None-Match"] = f'"{expected_sha256}"' - - req = urllib.request.Request(download_url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status == 304: - print("[+] Server returned 304 Not Modified. Local binary package is current.") - return True - if resp.status != 200: - print(f"[!] Server returned HTTP {resp.status} during update download.") - return False - with open(target_temp, "wb") as f: - while chunk := resp.read(65536): - f.write(chunk) - except urllib.error.HTTPError as e: - if e.code == 304: - print("[+] Server returned 304 Not Modified. Local binary package is current.") - return True - print(f"[!] HTTP error during update download: {e}") - return False - except Exception as e: - print(f"[!] Error downloading update package: {e}") - return False - - # Verify SHA-256 - actual_sha256 = self.calculate_sha256(target_temp) - if expected_sha256 and actual_sha256 != expected_sha256.lower(): - print(f"[!] Update verification failed: Checksum mismatch! Expected: {expected_sha256}, Got: {actual_sha256}") - if os.path.exists(target_temp): - os.remove(target_temp) - return False - - print(f"[+] Download verified successfully (SHA-256: {actual_sha256[:16]}...).") - return True - - def apply_update_linux(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: - """ - Applies update on Linux by setting permissions and atomically moving the binary. - """ - target = target_binary_path or sys.executable - try: - os.chmod(new_binary_path, 0o755) - # Atomically replace target binary - os.replace(new_binary_path, target) - print(f"[+] Successfully replaced client binary: {target}") - - # If running as a systemd service, restart service - if shutil.which("systemctl"): - try: - subprocess.run(["systemctl", "restart", "logar-client"], check=False) - print("[+] Signaled systemd to restart logar-client service.") - except Exception: - pass - return True - except Exception as e: - print(f"[!] Failed to apply Linux update: {e}") - return False - - def apply_update_windows(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: - """ - Applies update on Windows. Since the currently running .exe cannot be directly overwritten, - spawns a detached background helper script that waits for current PID to exit, swaps the binary, - and restarts the service or executable. - """ - target = target_binary_path or sys.executable - pid = os.getpid() - target_dir = os.path.dirname(os.path.abspath(target)) - updater_bat = os.path.join(target_dir, f"_update_{pid}.bat") - - bat_script = f"""@echo off -setlocal -echo [*] LOGAR Client Auto-Updater waiting for process {pid} to terminate... -:WAIT_LOOP -tasklist /FI "PID eq {pid}" | findstr "{pid}" >nul -if %ERRORLEVEL% == 0 ( - timeout /t 1 /nobreak >nul - goto WAIT_LOOP -) - -echo [*] Applying update to {target}... -move /Y "{new_binary_path}" "{target}" >nul -if %ERRORLEVEL% NEQ 0 ( - copy /Y "{new_binary_path}" "{target}" >nul -) - -if exist "{os.path.join(target_dir, 'nssm.exe')}" ( - "{os.path.join(target_dir, 'nssm.exe')}" restart LOGAR-Client >nul 2>&1 -) else ( - start "" "{target}" -) - -del "{new_binary_path}" 2>nul -(goto) 2>nul & del "%~f0" -""" - try: - with open(updater_bat, "w", encoding="utf-8") as f: - f.write(bat_script) - - # Spawn detached batch process - DETACHED_PROCESS = 0x00000008 - CREATE_NEW_PROCESS_GROUP = 0x00000200 - subprocess.Popen( - ["cmd.exe", "/c", updater_bat], - creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, - close_fds=True - ) - print(f"[+] Detached updater script launched ({updater_bat}). Terminating for restart...") - return True - except Exception as e: - print(f"[!] Failed to launch Windows updater script: {e}") - return False - - def check_and_apply_auto_update(self, client_id: str, auto_apply: bool = True) -> Dict[str, Any]: - """ - Main entry point for client periodic auto-update check. - Checks for update, downloads, verifies, and triggers update if available. - """ - update_info = self.check_for_update(client_id) - if not update_info.get("update_available"): - return {"status": "up_to_date", "current_version": self.current_version} - - target_ver = update_info.get("latest_version") - download_url = update_info.get("download_url") - expected_sha256 = update_info.get("sha256", "") - - print(f"[!] 🔔 New LOGAR Client update available: v{self.current_version} -> v{target_ver}") - - if not auto_apply or not download_url: - return {"status": "available", "latest_version": target_ver} - - # Prepare temporary file for download - temp_dir = os.path.dirname(os.path.abspath(sys.executable)) - if not os.access(temp_dir, os.W_OK): - temp_dir = tempfile.gettempdir() - - suffix = ".exe" if self.os_type == "windows" else ".bin" - temp_download = os.path.join(temp_dir, f"_update_pkg_{int(time.time())}{suffix}") - - ok = self.download_package(download_url, expected_sha256, temp_download) - if not ok: - return {"status": "download_failed"} - - print(f"[*] Applying client update to v{target_ver}...") - if self.os_type == "windows": - applied = self.apply_update_windows(temp_download) - if applied and getattr(sys, "frozen", False): - sys.exit(0) - else: - applied = self.apply_update_linux(temp_download) - - return {"status": "updated" if applied else "apply_failed", "latest_version": target_ver} +from src.clients.client_updater import * \ No newline at end of file diff --git a/src/clients/Linux_Client.py b/src/clients/Linux_Client.py new file mode 100644 index 0000000..f247967 --- /dev/null +++ b/src/clients/Linux_Client.py @@ -0,0 +1,169 @@ +""" +LOGAR Linux Edge Log Forwarder +Collects Linux systemd journalctl / syslog entries and securely streams them +to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope. +""" + +import os +import sys +import time +import argparse +import warnings +from typing import Optional + +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +# Suppress cryptography / pgpy deprecation notices +warnings.filterwarnings("ignore") + +import pgpy + +try: + from src.clients.log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + get_state_path, + load_state, + save_state, + commit_state, + LinuxLogCollector, + BaseLogCollector + ) + from src.clients.client_transport import ( + is_cert_expiring_soon, + enroll_client_if_needed, + get_tls_socket, + load_config, + ensure_enrolled as transport_ensure_enrolled, + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update + ) +except ImportError: + from log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + get_state_path, + load_state, + save_state, + commit_state, + LinuxLogCollector, + BaseLogCollector + ) + from client_transport import ( + is_cert_expiring_soon, + enroll_client_if_needed, + get_tls_socket, + load_config, + ensure_enrolled as transport_ensure_enrolled, + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update + ) + +CONFIG_FILE_NAME = "client_config.json" +STATE_FILE_NAME = "client_state.json" + + +def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: + """ + Collects info, warnings, and errors from systemd journalctl over the lookback window. + Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG noise. + State Tracking: Skips events older than lookback window and already sent cursors. + """ + collector = LinuxLogCollector(state=state) + return collector.collect_logs(hours=hours) + + +def send_encrypted_logs_over_socket(config: dict, logs: list): + """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" + return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux") + + +def ensure_enrolled(config: dict, machine_id: str) -> bool: + """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" + return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="linux") + + +def main(): + parser = argparse.ArgumentParser(description="LOGAR Linux Edge Log Forwarder with State Tracking") + parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json") + parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)") + parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)") + parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window") + parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)") + args = parser.parse_args() + + try: + config = load_config(args.config) + except Exception as e: + print(f"[!] Configuration error: {e}") + sys.exit(1) + + state_path = get_state_path(args.config, args.state_file) + state = None if args.no_state else load_state(state_path) + + machine_id = config.get("client_id") or get_machine_identifier() + print(f"[*] Edge Forwarder Node: {machine_id}") + if state and ("last_cursor" in state or "last_timestamp_us" in state): + print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})") + elif not args.no_state: + print(f"[*] State tracking initialized (state file: {state_path})") + + # Proactively ensure dynamic PKI certificate enrollment with Server Hub + ensure_enrolled(config, machine_id) + + collector = LinuxLogCollector(state=state, machine_id=machine_id) + + def run_forwarder_cycle(): + # Continuously verify/renew PKI enrollment if server is licensed & online + ensure_enrolled(config, machine_id) + + # Periodically check server for client auto-updates + try: + check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="linux") + except Exception as update_err: + pass + + print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") + candidate_logs = collector.collect_logs(hours=args.hours) + print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") + + if not candidate_logs: + print("[*] No new unsent events to transmit.") + if state is not None: + commit_state(state, state_path) + return + + try: + resp = send_encrypted_logs_over_socket(config, candidate_logs) + if state is not None and resp and resp.get("status") == "success": + commit_state(state, state_path) + print(f"[+] State successfully committed to {state_path}") + except Exception as e: + print(f"[!] Failed to stream logs to server: {e}") + if not args.interval: + sys.exit(1) + + if args.interval and args.interval > 0: + print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...") + while True: + try: + run_forwarder_cycle() + except Exception as e: + print(f"[!] Error in forwarder cycle: {e}") + time.sleep(args.interval) + else: + run_forwarder_cycle() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/clients/Win_Client.py b/src/clients/Win_Client.py new file mode 100644 index 0000000..da04fc1 --- /dev/null +++ b/src/clients/Win_Client.py @@ -0,0 +1,174 @@ +""" +LOGAR Windows Edge Log Forwarder +Collects Windows Event Logs (Application/System) and securely streams them +to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope. +""" + +import os +import sys +import time +import argparse +import warnings +from typing import Optional + +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +# Suppress cryptography / pgpy deprecation notices +warnings.filterwarnings("ignore") + +import pgpy + +try: + import win32evtlog +except ImportError: + win32evtlog = None + +try: + from src.clients.log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + get_state_path, + load_state, + save_state, + commit_state, + WindowsLogCollector, + BaseLogCollector + ) + from src.clients.client_transport import ( + is_cert_expiring_soon, + enroll_client_if_needed, + get_tls_socket, + load_config, + ensure_enrolled as transport_ensure_enrolled, + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update + ) +except ImportError: + from log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + get_state_path, + load_state, + save_state, + commit_state, + WindowsLogCollector, + BaseLogCollector + ) + from client_transport import ( + is_cert_expiring_soon, + enroll_client_if_needed, + get_tls_socket, + load_config, + ensure_enrolled as transport_ensure_enrolled, + send_encrypted_logs_over_socket as transport_send_encrypted_logs, + check_and_apply_auto_update + ) + +CONFIG_FILE_NAME = "client_config.json" +STATE_FILE_NAME = "client_state.json" + + +def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: + """ + Scans the Windows Application Event Log backwards for events within the window. + Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise. + State Tracking: Skips events older than lookback window and already sent records. + """ + collector = WindowsLogCollector(state=state, channels=["Application"]) + return collector.collect_logs(hours=hours) + + +def send_encrypted_logs_over_socket(config: dict, logs: list): + """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope.""" + return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows") + + +def ensure_enrolled(config: dict, machine_id: str) -> bool: + """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" + return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows") + + +def main(): + parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking") + parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json") + parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)") + parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)") + parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window") + parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)") + args = parser.parse_args() + + try: + config = load_config(args.config) + except Exception as e: + print(f"[!] Configuration error: {e}") + sys.exit(1) + + state_path = get_state_path(args.config, args.state_file) + state = None if args.no_state else load_state(state_path) + + machine_id = config.get("client_id") or get_machine_identifier() + print(f"[*] Edge Forwarder Node: {machine_id}") + if state and "last_record_number" in state: + print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})") + elif not args.no_state: + print(f"[*] State tracking initialized (state file: {state_path})") + + # Proactively ensure dynamic PKI certificate enrollment with Server Hub + ensure_enrolled(config, machine_id) + + collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"]) + + def run_forwarder_cycle(): + # Continuously verify/renew PKI enrollment if server is licensed & online + ensure_enrolled(config, machine_id) + + # Periodically check server for client auto-updates + try: + check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="windows") + except Exception as update_err: + pass + + print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...") + candidate_logs = collector.collect_logs(hours=args.hours) + print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).") + + if not candidate_logs: + print("[*] No new unsent events to transmit.") + if state is not None: + commit_state(state, state_path) + return + + try: + resp = send_encrypted_logs_over_socket(config, candidate_logs) + if state is not None and resp and resp.get("status") == "success": + commit_state(state, state_path) + print(f"[+] State successfully committed to {state_path}") + except Exception as e: + print(f"[!] Failed to stream logs to server: {e}") + if not args.interval: + sys.exit(1) + + if args.interval and args.interval > 0: + print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...") + while True: + try: + run_forwarder_cycle() + except Exception as e: + print(f"[!] Error in forwarder cycle: {e}") + time.sleep(args.interval) + else: + run_forwarder_cycle() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..f261fc7 --- /dev/null +++ b/src/clients/__init__.py @@ -0,0 +1 @@ +# LOGAR Clients Package diff --git a/src/clients/client_transport.py b/src/clients/client_transport.py new file mode 100644 index 0000000..4f59aee --- /dev/null +++ b/src/clients/client_transport.py @@ -0,0 +1,359 @@ +""" +LOGAR - Unified Client Transport & PKI Enrollment Module +Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing, +and dynamic certificate enrollment with the LOGAR Server Hub. +""" + +import os +import sys +import json +import socket +import ssl +import struct +import urllib.request +import warnings +import pgpy +from datetime import datetime, timezone, timedelta +from typing import Optional, List, Dict, Any + +# Suppress cryptography / pgpy deprecation notices +warnings.filterwarnings("ignore") + +try: + from src.clients.log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + collect_system_metrics + ) +except ImportError: + from log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + collect_system_metrics + ) + +try: + from src.common.version import LOGAR_VERSION +except ImportError: + try: + from src.version import LOGAR_VERSION + except ImportError: + from version import LOGAR_VERSION + +try: + from src.clients.client_updater import ClientUpdater +except ImportError: + try: + from src.client_updater import ClientUpdater + except ImportError: + from client_updater import ClientUpdater + +CONFIG_FILE_NAME = "client_config.json" + + +def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool: + """Checks if client certificate at cert_path is expiring within threshold_days.""" + if not os.path.exists(cert_path): + return True + try: + from cryptography import x509 + with open(cert_path, "r", encoding="utf-8") as f: + cert = x509.load_pem_x509_certificate(f.read().encode("utf-8")) + expiry = getattr(cert, "not_valid_after_utc", None) + if expiry is None: + expiry = cert.not_valid_after.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + return expiry <= (now + timedelta(days=threshold_days)) + except Exception: + return True + + +def enroll_client_if_needed( + hub_url: str, + enrollment_secret: str, + cert_dir: str, + client_id: str, + hostname: str, + os_type: str = "windows", + force_renew: bool = False, + threshold_days: int = 30 +) -> bool: + """Bootstraps client enrollment if certificates are missing or expiring soon.""" + os.makedirs(cert_dir, exist_ok=True) + ca_path = os.path.join(cert_dir, "ca.crt") + cert_path = os.path.join(cert_dir, "client.crt") + key_path = os.path.join(cert_dir, "client.key") + + if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path): + if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days): + return True + print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...") + + action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling" + print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...") + enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll" + payload = { + "client_id": client_id, + "hostname": hostname, + "os": os_type, + "enrollment_secret": enrollment_secret, + "version": LOGAR_VERSION + } + req = urllib.request.Request( + enroll_endpoint, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status != 200: + raise RuntimeError(f"Enrollment failed with status code {resp.status}") + data = json.loads(resp.read().decode("utf-8")) + + with open(ca_path, "w", encoding="utf-8") as f: + f.write(data["ca_cert"]) + with open(cert_path, "w", encoding="utf-8") as f: + f.write(data["client_cert"]) + with open(key_path, "w", encoding="utf-8") as f: + f.write(data["client_key"]) + + try: + os.chmod(key_path, 0o600) + except Exception: + pass + + print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}") + return True + + +def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str): + """Establishes an mTLS connection with the LOGAR hub using client certificates.""" + ca_path = os.path.join(cert_dir, "ca.crt") + cert_path = os.path.join(cert_dir, "client.crt") + key_path = os.path.join(cert_dir, "client.key") + + if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)): + raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.") + + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path) + ctx.load_cert_chain(certfile=cert_path, keyfile=key_path) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.check_hostname = False + + raw_sock = socket.create_connection((hub_host, hub_port), timeout=15) + return ctx.wrap_socket(raw_sock, server_hostname=hub_host) + + +def load_config(config_path: str = CONFIG_FILE_NAME) -> dict: + """Loads and normalizes client configuration JSON.""" + actual_config_path = config_path + if not os.path.exists(actual_config_path): + data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path)) + if os.path.exists(data_dir_cfg): + actual_config_path = data_dir_cfg + + if not os.path.exists(actual_config_path): + raise FileNotFoundError( + f"Client configuration file not found at: {config_path}\n" + f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}" + ) + with open(actual_config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # Resolve cert_dir to a writable directory + cfg_dir = os.path.dirname(os.path.abspath(actual_config_path)) + raw_cert_dir = config.get("cert_dir", "certs") + if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir): + config["cert_dir"] = raw_cert_dir + else: + candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir)) + if is_path_writable(candidate) or is_path_writable(cfg_dir): + config["cert_dir"] = candidate + else: + config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs") + + # If ca_cert is present in config and ca.crt doesn't exist yet, save it + ca_cert_pem = config.get("ca_cert") + if ca_cert_pem and isinstance(ca_cert_pem, str): + ca_file = os.path.join(config["cert_dir"], "ca.crt") + if not os.path.exists(ca_file): + try: + os.makedirs(config["cert_dir"], exist_ok=True) + with open(ca_file, "w", encoding="utf-8") as f: + f.write(ca_cert_pem) + except Exception: + pass + + return config + + +def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool: + """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available.""" + cert_dir = config.get("cert_dir", "certs") + enrollment_secret = config.get("enrollment_secret") + if not enrollment_secret: + return False + + server_host = config.get("server_host", "127.0.0.1") + hermes_host = config.get("hermes_host", server_host) + if hermes_host in ("0.0.0.0", "::", None, ""): + hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1" + hermes_port = config.get("hermes_port", 8443) + hub_url = f"http://{hermes_host}:{hermes_port}" + try: + return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type) + except Exception as e: + print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}") + return False + + +def send_encrypted_logs_over_socket( + config: dict, + logs: list, + os_type: Optional[str] = None, + machine_id: Optional[str] = None +) -> dict: + """ + Streams logs to the LOGAR hub. + Uses mutual TLS 1.3 (mTLS) with client certificates if available, + or falls back to OpenPGP encrypted envelope over TCP socket. + """ + node_os = os_type or ("windows" if sys.platform == "win32" else "linux") + server_host = config["server_host"] + server_port = int(config["server_port"]) + cert_dir = config.get("cert_dir", "certs") + enrollment_secret = config.get("enrollment_secret") + actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier() + + # Attempt automatic enrollment bootstrap if certs are missing and secret is provided + hub_url = None + if enrollment_secret: + hermes_host = config.get("hermes_host", server_host) + if hermes_host in ("0.0.0.0", "::", None): + hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1" + hermes_port = config.get("hermes_port", 8443) + hub_url = f"http://{hermes_host}:{hermes_port}" + try: + enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os) + except Exception as e: + print(f"[!] Warning: Enrollment bootstrap failed: {e}") + + ca_path = os.path.join(cert_dir, "ca.crt") + cert_path = os.path.join(cert_dir, "client.crt") + key_path = os.path.join(cert_dir, "client.key") + has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path) + + if not has_mtls_certs and enrollment_secret and hub_url: + try: + enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os) + has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path) + except Exception as e: + print(f"[!] Warning: Enrollment before TLS connection failed: {e}") + + if has_mtls_certs: + print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...") + sock = None + try: + sock = get_tls_socket(server_host, server_port, cert_dir) + except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err: + if enrollment_secret and hub_url: + print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...") + try: + enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True) + sock = get_tls_socket(server_host, server_port, cert_dir) + except Exception as retry_err: + print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}") + raise + else: + raise + + with sock: + payload = { + "server": actual_machine_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "logs": logs, + "metrics": collect_system_metrics(), + "version": LOGAR_VERSION + } + payload_bytes = json.dumps(payload).encode("utf-8") + frame = struct.pack(">I", len(payload_bytes)) + payload_bytes + sock.sendall(frame) + + resp_len_bytes = sock.recv(4) + if not resp_len_bytes: + raise ConnectionError("Server closed mTLS connection without response.") + resp_len = struct.unpack(">I", resp_len_bytes)[0] + resp_bytes = bytearray() + while len(resp_bytes) < resp_len: + chunk = sock.recv(min(4096, resp_len - len(resp_bytes))) + if not chunk: + break + resp_bytes.extend(chunk) + + response = json.loads(resp_bytes.decode("utf-8")) + print(f"[+] Server response: {response}") + return response + + # Fallback to OpenPGP envelope over plain TCP socket + auth_token = config.get("auth_token", "") + pub_key_armored = config.get("server_public_key") + if not pub_key_armored: + raise ValueError("No server public key or mTLS certificates available for connection.") + + expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper() + pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored) + actual_fp = str(pub_key.fingerprint).replace(" ", "").upper() + if expected_fp and actual_fp != expected_fp: + raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.") + + payload = { + "server": actual_machine_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "logs": logs, + "metrics": collect_system_metrics(), + "version": LOGAR_VERSION + } + payload_json = json.dumps(payload) + pgp_msg = pgpy.PGPMessage.new(payload_json) + encrypted_msg = pub_key.encrypt(pgp_msg) + encrypted_armored = str(encrypted_msg) + + envelope = { + "auth_token": auth_token, + "timestamp": datetime.now(timezone.utc).isoformat(), + "encrypted_payload": encrypted_armored + } + envelope_bytes = json.dumps(envelope).encode("utf-8") + + print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...") + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(15.0) + sock.connect((server_host, server_port)) + + frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes + sock.sendall(frame) + + resp_len_bytes = sock.recv(4) + if not resp_len_bytes: + raise ConnectionError("Server closed connection without response.") + + resp_len = struct.unpack(">I", resp_len_bytes)[0] + resp_bytes = bytearray() + while len(resp_bytes) < resp_len: + chunk = sock.recv(min(4096, resp_len - len(resp_bytes))) + if not chunk: + break + resp_bytes.extend(chunk) + + response = json.loads(resp_bytes.decode("utf-8")) + print(f"[+] Server response: {response}") + return response + + +def check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]: + """Helper to check and apply auto updates for edge forwarders.""" + updater = ClientUpdater(config=config, os_type=os_type) + return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply) diff --git a/src/clients/client_updater.py b/src/clients/client_updater.py new file mode 100644 index 0000000..bfcaff1 --- /dev/null +++ b/src/clients/client_updater.py @@ -0,0 +1,248 @@ +""" +LOGAR - Client Auto-Update Agent Module +Handles checking the LOGAR Server Hub for client updates, downloading verified binaries (SHA-256), +and applying atomic in-place updates across Windows and Linux forwarders. +""" + +import os +import sys +import json +import time +import shutil +import hashlib +import tempfile +import subprocess +import urllib.request +import urllib.error +import urllib.parse +from typing import Dict, Any, Optional, Tuple + +try: + from src.version import LOGAR_VERSION, is_newer_version +except ImportError: + from version import LOGAR_VERSION, is_newer_version + + +class ClientUpdater: + def __init__(self, config: dict, os_type: Optional[str] = None): + self.config = config + self.os_type = (os_type or ("windows" if sys.platform == "win32" else "linux")).lower() + self.current_version = LOGAR_VERSION + + @staticmethod + def calculate_sha256(filepath: str) -> str: + """Computes SHA-256 digest of a local file.""" + if not os.path.exists(filepath): + return "" + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest().lower() + + def get_server_base_url(self) -> str: + """Resolves the HTTP base URL for server management endpoints.""" + server_host = self.config.get("server_host", "127.0.0.1") + hermes_host = self.config.get("hermes_host", server_host) + if hermes_host in ("0.0.0.0", "::", None, ""): + hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1" + hermes_port = int(self.config.get("hermes_port", 8443)) + return f"http://{hermes_host}:{hermes_port}" + + def check_for_update(self, client_id: str, timeout: int = 6) -> Dict[str, Any]: + """ + Queries the server Hub to check if a newer client binary is available. + """ + base_url = self.get_server_base_url() + url = ( + f"{base_url}/api/client/update/check" + f"?client_id={urllib.parse.quote(client_id)}" + f"&os={urllib.parse.quote(self.os_type)}" + f"¤t_version={urllib.parse.quote(self.current_version)}" + ) + try: + req = urllib.request.Request( + url, + headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 200: + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + # Silent fallback during standard forwarder operation + pass + return {"update_available": False, "latest_version": self.current_version} + + def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool: + """ + Downloads the client binary package from the server and validates its SHA-256 checksum. + Uses local binary hash checking and ETag conditional headers to avoid redundant downloads. + """ + # 1. If running binary already matches target SHA-256, skip downloading + target_exec = sys.executable + if expected_sha256 and os.path.exists(target_exec): + try: + local_sha = self.calculate_sha256(target_exec) + if local_sha and local_sha == expected_sha256.lower(): + print(f"[*] Local binary already matches target checksum ({local_sha[:12]}...). Skipping redundant download.") + return True + except Exception: + pass + + base_url = self.get_server_base_url() + download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}" + + print(f"[*] Downloading client update package from {download_url}...") + headers = {"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"} + if expected_sha256: + headers["If-None-Match"] = f'"{expected_sha256}"' + + req = urllib.request.Request(download_url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 304: + print("[+] Server returned 304 Not Modified. Local binary package is current.") + return True + if resp.status != 200: + print(f"[!] Server returned HTTP {resp.status} during update download.") + return False + with open(target_temp, "wb") as f: + while chunk := resp.read(65536): + f.write(chunk) + except urllib.error.HTTPError as e: + if e.code == 304: + print("[+] Server returned 304 Not Modified. Local binary package is current.") + return True + print(f"[!] HTTP error during update download: {e}") + return False + except Exception as e: + print(f"[!] Error downloading update package: {e}") + return False + + # Verify SHA-256 + actual_sha256 = self.calculate_sha256(target_temp) + if expected_sha256 and actual_sha256 != expected_sha256.lower(): + print(f"[!] Update verification failed: Checksum mismatch! Expected: {expected_sha256}, Got: {actual_sha256}") + if os.path.exists(target_temp): + os.remove(target_temp) + return False + + print(f"[+] Download verified successfully (SHA-256: {actual_sha256[:16]}...).") + return True + + def apply_update_linux(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: + """ + Applies update on Linux by setting permissions and atomically moving the binary. + """ + target = target_binary_path or sys.executable + try: + os.chmod(new_binary_path, 0o755) + # Atomically replace target binary + os.replace(new_binary_path, target) + print(f"[+] Successfully replaced client binary: {target}") + + # If running as a systemd service, restart service + if shutil.which("systemctl"): + try: + subprocess.run(["systemctl", "restart", "logar-client"], check=False) + print("[+] Signaled systemd to restart logar-client service.") + except Exception: + pass + return True + except Exception as e: + print(f"[!] Failed to apply Linux update: {e}") + return False + + def apply_update_windows(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool: + """ + Applies update on Windows. Since the currently running .exe cannot be directly overwritten, + spawns a detached background helper script that waits for current PID to exit, swaps the binary, + and restarts the service or executable. + """ + target = target_binary_path or sys.executable + pid = os.getpid() + target_dir = os.path.dirname(os.path.abspath(target)) + updater_bat = os.path.join(target_dir, f"_update_{pid}.bat") + + bat_script = f"""@echo off +setlocal +echo [*] LOGAR Client Auto-Updater waiting for process {pid} to terminate... +:WAIT_LOOP +tasklist /FI "PID eq {pid}" | findstr "{pid}" >nul +if %ERRORLEVEL% == 0 ( + timeout /t 1 /nobreak >nul + goto WAIT_LOOP +) + +echo [*] Applying update to {target}... +move /Y "{new_binary_path}" "{target}" >nul +if %ERRORLEVEL% NEQ 0 ( + copy /Y "{new_binary_path}" "{target}" >nul +) + +if exist "{os.path.join(target_dir, 'nssm.exe')}" ( + "{os.path.join(target_dir, 'nssm.exe')}" restart LOGAR-Client >nul 2>&1 +) else ( + start "" "{target}" +) + +del "{new_binary_path}" 2>nul +(goto) 2>nul & del "%~f0" +""" + try: + with open(updater_bat, "w", encoding="utf-8") as f: + f.write(bat_script) + + # Spawn detached batch process + DETACHED_PROCESS = 0x00000008 + CREATE_NEW_PROCESS_GROUP = 0x00000200 + subprocess.Popen( + ["cmd.exe", "/c", updater_bat], + creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, + close_fds=True + ) + print(f"[+] Detached updater script launched ({updater_bat}). Terminating for restart...") + return True + except Exception as e: + print(f"[!] Failed to launch Windows updater script: {e}") + return False + + def check_and_apply_auto_update(self, client_id: str, auto_apply: bool = True) -> Dict[str, Any]: + """ + Main entry point for client periodic auto-update check. + Checks for update, downloads, verifies, and triggers update if available. + """ + update_info = self.check_for_update(client_id) + if not update_info.get("update_available"): + return {"status": "up_to_date", "current_version": self.current_version} + + target_ver = update_info.get("latest_version") + download_url = update_info.get("download_url") + expected_sha256 = update_info.get("sha256", "") + + print(f"[!] 🔔 New LOGAR Client update available: v{self.current_version} -> v{target_ver}") + + if not auto_apply or not download_url: + return {"status": "available", "latest_version": target_ver} + + # Prepare temporary file for download + temp_dir = os.path.dirname(os.path.abspath(sys.executable)) + if not os.access(temp_dir, os.W_OK): + temp_dir = tempfile.gettempdir() + + suffix = ".exe" if self.os_type == "windows" else ".bin" + temp_download = os.path.join(temp_dir, f"_update_pkg_{int(time.time())}{suffix}") + + ok = self.download_package(download_url, expected_sha256, temp_download) + if not ok: + return {"status": "download_failed"} + + print(f"[*] Applying client update to v{target_ver}...") + if self.os_type == "windows": + applied = self.apply_update_windows(temp_download) + if applied and getattr(sys, "frozen", False): + sys.exit(0) + else: + applied = self.apply_update_linux(temp_download) + + return {"status": "updated" if applied else "apply_failed", "latest_version": target_ver} diff --git a/src/clients/log_collector.py b/src/clients/log_collector.py new file mode 100644 index 0000000..47f3e98 --- /dev/null +++ b/src/clients/log_collector.py @@ -0,0 +1,679 @@ +""" +LOGAR - Unified Cross-Platform Log Collector & State Management +Provides modular log collection classes and state management for Windows and Linux, +reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux). +""" + +import os +import sys +import json +import socket +import shutil +import ctypes +import subprocess +import warnings +from abc import ABC, abstractmethod +from datetime import datetime, timezone, timedelta +from typing import Optional, List, Dict, Any, Set + +# Suppress cryptography / pgpy deprecation notices +warnings.filterwarnings("ignore") + +try: + import win32evtlog +except ImportError: + win32evtlog = None + +CONFIG_FILE_NAME = "client_config.json" +STATE_FILE_NAME = "client_state.json" + + +def collect_system_metrics() -> Dict[str, Any]: + """ + Collects cross-platform host health and resource telemetry without requiring external wheels. + Returns: + { + "cpu_percent": float, + "memory_percent": float, + "memory_total_mb": int, + "memory_free_mb": int, + "disk_percent_free": float, + "disk_total_gb": float, + "disk_free_gb": float + } + """ + metrics = { + "cpu_percent": 0.0, + "memory_percent": 0.0, + "memory_total_mb": 0, + "memory_free_mb": 0, + "disk_percent_free": 0.0, + "disk_total_gb": 0.0, + "disk_free_gb": 0.0 + } + # 1. Disk metrics + try: + path = "C:\\" if sys.platform == "win32" else "/" + total, used, free = shutil.disk_usage(path) + metrics["disk_total_gb"] = round(total / (1024 ** 3), 2) + metrics["disk_free_gb"] = round(free / (1024 ** 3), 2) + if total > 0: + metrics["disk_percent_free"] = round((free / total) * 100, 1) + except Exception: + pass + + # 2. Memory metrics + try: + if sys.platform == "win32": + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("sullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + stat = MEMORYSTATUSEX() + stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX) + if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): + metrics["memory_percent"] = float(stat.dwMemoryLoad) + metrics["memory_total_mb"] = int(stat.ullTotalPhys / (1024 * 1024)) + metrics["memory_free_mb"] = int(stat.ullAvailPhys / (1024 * 1024)) + else: + # Linux: read /proc/meminfo + if os.path.exists("/proc/meminfo"): + mem_total = 0 + mem_avail = 0 + with open("/proc/meminfo", "r", encoding="utf-8") as f: + for line in f: + parts = line.split(":") + if len(parts) == 2: + k, v = parts[0].strip(), parts[1].strip().split()[0] + if k == "MemTotal": + mem_total = int(v) + elif k in ("MemAvailable", "MemFree") and mem_avail == 0: + mem_avail = int(v) + if mem_total > 0: + metrics["memory_total_mb"] = mem_total // 1024 + metrics["memory_free_mb"] = mem_avail // 1024 + metrics["memory_percent"] = round(((mem_total - mem_avail) / mem_total) * 100, 1) + except Exception: + pass + + # 3. CPU metrics + try: + if hasattr(os, "getloadavg"): + load1, _, _ = os.getloadavg() + cpu_count = os.cpu_count() or 1 + metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1) + else: + try: + import psutil + metrics["cpu_percent"] = float(psutil.cpu_percent(interval=None)) + except ImportError: + metrics["cpu_percent"] = 0.0 + except Exception: + pass + + return metrics + + +def is_routine_service_noise(signature: str, message: str, severity: str) -> bool: + """ + Identifies high-frequency benign background service start/stop cycles + (e.g., sppsvc, edgeupdate, trustedinstaller, wuauserv, backgroundTaskHost) + to prevent false-positive anomaly fatigue and consecutive_run metric bloat. + """ + if str(severity).upper() != "INFO": + return False + sig_low = (signature or "").lower() + msg_low = (message or "").lower() + routine_services = [ + "sppsvc", + "edgeupdate", + "trustedinstaller", + "wuauserv", + "backgroundtaskhost", + "service control manager", + "systemd", + "cron" + ] + is_routine_svc = any(s in sig_low or s in msg_low for s in routine_services) + if is_routine_svc: + lifecycle_phrases = [ + "entered the running state", + "entered the stopped state", + "entered the start pending state", + "entered the stop pending state", + "started successfully", + "stopped successfully", + "service stopped", + "service started", + "session opened", + "session closed", + "clean", + "reloaded" + ] + if any(p in msg_low for p in lifecycle_phrases): + return True + return False + + +def get_machine_identifier() -> str: + """ + Returns the fully qualified hostname of the machine sending/collecting logs. + Cross-platform resolution: + 1. FQDN (if non-local) + 2. Windows USERDNSDOMAIN (on Windows) + 3. Linux /etc/resolv.conf domain/search directive (on Linux) + 4. Reverse DNS lookup + 5. socket.gethostname() fallback + """ + fqdn = socket.getfqdn() + if fqdn and "." in fqdn and not fqdn.startswith("localhost"): + return fqdn + + hostname = socket.gethostname() + + # Windows DNS domain + if sys.platform == "win32": + user_dns_domain = os.environ.get("USERDNSDOMAIN") + if user_dns_domain and user_dns_domain.lower() != hostname.lower(): + return f"{hostname}.{user_dns_domain.lower()}" + + # Linux resolv.conf search / domain + if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"): + try: + with open("/etc/resolv.conf", "r", encoding="utf-8") as f: + for line in f: + parts = line.strip().split() + if parts and parts[0] in ["domain", "search"] and len(parts) > 1: + domain = parts[1] + if domain and not domain.startswith("."): + return f"{hostname}.{domain}" + except Exception: + pass + + # Reverse DNS lookup + try: + host_ip = socket.gethostbyname(hostname) + canonical_name = socket.gethostbyaddr(host_ip)[0] + if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"): + return canonical_name + except Exception: + pass + + return hostname + + +def is_path_writable(filepath: str) -> bool: + """Checks if a file or directory path is actually writable by the current process.""" + try: + if os.path.exists(filepath): + if os.path.isdir(filepath): + test_f = os.path.join(filepath, f".perm_{os.getpid()}") + with open(test_f, "w") as f: + f.write("1") + os.remove(test_f) + return True + else: + with open(filepath, "a") as f: + pass + return True + else: + parent = os.path.dirname(os.path.abspath(filepath)) or "." + os.makedirs(parent, exist_ok=True) + test_f = os.path.join(parent, f".perm_{os.getpid()}") + with open(test_f, "w") as f: + f.write("1") + os.remove(test_f) + return True + except Exception: + return False + + +def get_default_data_dir(app_name: str = "LOGAR") -> str: + """Returns a suitable writable application data directory.""" + if sys.platform == "win32": + # 1. ProgramData (C:\ProgramData\LOGAR) + 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) + 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) + temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) + if is_path_writable(temp_dir): + return temp_dir + else: + # Linux / Unix candidates: /var/lib/, /tmp/, ~/.local/share/ + var_lib = f"/var/lib/{app_name.lower()}" + if is_path_writable(var_lib): + return var_lib + user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}") + if is_path_writable(user_share): + return user_share + + cwd = os.getcwd() + if is_path_writable(cwd): + return cwd + return os.path.expanduser("~") + + +def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str: + """Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory.""" + if not path: + path = default_filename + + if os.path.isabs(path) and is_path_writable(path): + return path + + if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): + return os.path.abspath(path) + + data_dir = get_default_data_dir(app_name) + fname = os.path.basename(path) + return os.path.join(data_dir, fname) + + +def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str: + if custom_state_path: + return custom_state_path + config_dir = os.path.dirname(os.path.abspath(config_path)) + if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)): + return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME) + return os.path.join(config_dir, STATE_FILE_NAME) + + +def load_state(state_path: str) -> dict: + """Loads state tracking dictionary from disk.""" + if os.path.exists(state_path): + try: + with open(state_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"[!] Warning: Failed to read state file '{state_path}': {e}") + return {} + return {} + + +def save_state(state_path: str, state: dict): + """Atomically saves state dictionary to disk.""" + try: + os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True) + temp_path = f"{state_path}.tmp" + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + os.replace(temp_path, state_path) + except Exception as e: + print(f"[!] Warning: Could not save client state to '{state_path}': {e}") + + +def commit_state(state: dict, state_path: str): + """ + Commits staged cursor/record state keys to persistent fields and writes to state_path. + Supports both Windows record numbers and Linux cursors/timestamps. + """ + if "new_last_record_number" in state: + val = state.pop("new_last_record_number") + if val: + state["last_record_number"] = val + if "new_sent_record_ids" in state: + state["sent_record_ids"] = state.pop("new_sent_record_ids") + if "new_last_cursor" in state: + val = state.pop("new_last_cursor") + if val: + state["last_cursor"] = val + if "new_last_timestamp_us" in state: + val = state.pop("new_last_timestamp_us") + if val: + state["last_timestamp_us"] = val + if "new_sent_cursors" in state: + state["sent_cursors"] = state.pop("new_sent_cursors") + save_state(state_path, state) + + +class BaseLogCollector(ABC): + """ + Abstract base class for platform-specific edge and hub log collectors. + Manages state persistence, machine identifiers, lookback windows, and normalization. + """ + + def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None): + self.state = state if state is not None else {} + self.machine_id = machine_id or get_machine_identifier() + + @abstractmethod + def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: + """ + Collects normalized logs within the given lookback window. + Returns a list of dicts: + [ + { + "server": str, + "os_type": "windows"|"linux", + "signature": str, + "severity": "ERROR"|"WARNING"|"INFO", + "message": str + }, + ... + ] + """ + pass + + def commit(self, state_path: Optional[str] = None): + """Commits staged state keys and optionally writes to disk.""" + if state_path: + commit_state(self.state, state_path) + else: + if "new_last_record_number" in self.state: + val = self.state.pop("new_last_record_number") + if val: + self.state["last_record_number"] = val + if "new_sent_record_ids" in self.state: + self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids") + if "new_last_cursor" in self.state: + val = self.state.pop("new_last_cursor") + if val: + self.state["last_cursor"] = val + if "new_last_timestamp_us" in self.state: + val = self.state.pop("new_last_timestamp_us") + if val: + self.state["last_timestamp_us"] = val + if "new_sent_cursors" in self.state: + self.state["sent_cursors"] = self.state.pop("new_sent_cursors") + + +class WindowsLogCollector(BaseLogCollector): + """ + Windows Event Log Collector. + Reads Application and System (or custom channels) backwards. + Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise. + State Tracking: Skips events older than lookback window and already sent records. + """ + + def __init__( + self, + state: Optional[dict] = None, + machine_id: Optional[str] = None, + channels: Optional[List[str]] = None + ): + super().__init__(state=state, machine_id=machine_id) + self.channels = channels or ["Application"] + + def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: + if win32evtlog is None: + return [] + + logs = [] + cutoff_time = datetime.now() - timedelta(hours=hours) + + last_record_number = 0 + sent_record_ids: Set[str] = set() + if self.state: + last_record_number = int(self.state.get("last_record_number", 0)) + sent_record_ids = set(self.state.get("sent_record_ids", [])) + + # Windows Event Log EventTypes: + # 1: EVENTLOG_ERROR_TYPE -> ERROR + # 2: EVENTLOG_WARNING_TYPE -> WARNING + # 4: EVENTLOG_INFORMATION_TYPE -> INFO + sev_map = { + 1: "ERROR", + 2: "WARNING", + 4: "INFO" + } + + newest_record_number = 0 + collected_record_ids = [] + + flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ + + for log_type in self.channels: + try: + hand = win32evtlog.OpenEventLog("localhost", log_type) + except Exception as e: + continue + + while True: + try: + events = win32evtlog.ReadEventLog(hand, flags, 0) + except Exception: + break + if not events: + break + + for event in events: + rec_num = int(event.RecordNumber) + if newest_record_number == 0: + newest_record_number = rec_num + + # 1. Skip entries older than lookback window + if event.TimeGenerated < cutoff_time: + break + + # 2. Skip already sent events + if last_record_number > 0 and newest_record_number >= last_record_number: + if rec_num <= last_record_number: + break + + rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}" + if rec_id in sent_record_ids: + continue + + # Filter: upload everything from INFO to ERROR + if event.EventType in sev_map: + msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry" + sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event") + logs.append({ + "server": self.machine_id, + "os_type": "windows", + "signature": sig, + "severity": sev_map[event.EventType], + "message": msg[:2048] + }) + collected_record_ids.append(rec_id) + + if events[-1].TimeGenerated < cutoff_time: + break + if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number: + break + + try: + win32evtlog.CloseEventLog(hand) + except Exception: + pass + + if self.state is not None: + target_rec = max(newest_record_number, last_record_number) + self.state["new_last_record_number"] = target_rec + self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:] + # Also update direct keys for immediate server-side ingestion + self.state["last_record_number"] = target_rec + self.state["sent_record_ids"] = self.state["new_sent_record_ids"] + self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat() + + return logs + + +class LinuxLogCollector(BaseLogCollector): + """ + Linux Systemd Journal / Syslog Log Collector. + Collects info, warnings, and errors from journalctl (or syslog fallback). + Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise. + State Tracking: Skips events older than lookback window and already sent cursors. + """ + + def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None): + super().__init__(state=state, machine_id=machine_id) + + def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: + last_cursor = None + last_timestamp_us = 0.0 + sent_cursors: Set[str] = set() + if self.state: + last_cursor = self.state.get("last_cursor") + try: + last_timestamp_us = float(self.state.get("last_timestamp_us", 0)) + except (ValueError, TypeError): + last_timestamp_us = 0.0 + sent_cursors = set(self.state.get("sent_cursors", [])) + + cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"] + result = None + + if last_cursor: + cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"] + try: + res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False) + if res.returncode == 0: + result = res + except Exception: + pass + + if result is None: + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + except FileNotFoundError: + # journalctl not present, attempt fallback to /var/log/syslog or messages + return self._collect_syslog_fallback(hours=hours) + except Exception as e: + print(f"[!] Error running journalctl: {e}") + return [] + + if not result or result.returncode != 0: + return [] + + logs = [] + cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000 + + newest_cursor = None + newest_timestamp_us = last_timestamp_us + collected_cursors = [] + + for line in result.stdout.splitlines(): + line_str = line.strip() + if not line_str: + continue + try: + entry = json.loads(line_str) + entry_cursor = entry.get("__CURSOR") + entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP") + + entry_ts_us = 0.0 + if entry_ts_us_raw: + try: + entry_ts_us = float(entry_ts_us_raw) + except (ValueError, TypeError): + pass + + # 1. Skip entries older than lookback window + if entry_ts_us and entry_ts_us < cutoff_epoch_us: + continue + + # 2. Skip already sent events + if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor): + continue + if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us: + continue + + # Advance tracking + if entry_cursor: + newest_cursor = entry_cursor + collected_cursors.append(entry_cursor) + if entry_ts_us > newest_timestamp_us: + newest_timestamp_us = entry_ts_us + + priority = int(entry.get("PRIORITY", "6")) + if priority > 6: + continue + + if priority <= 3: + sev = "ERROR" + elif priority in (4, 5): + sev = "WARNING" + else: + sev = "INFO" + + logs.append({ + "server": self.machine_id, + "os_type": "linux", + "signature": entry.get("SYSLOG_IDENTIFIER", "unknown"), + "severity": sev, + "message": entry.get("MESSAGE", "")[:2048] + }) + except (json.JSONDecodeError, ValueError): + continue + + if self.state is not None: + self.state["new_last_cursor"] = newest_cursor or last_cursor + self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us) + self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:] + # Also update direct keys for immediate server-side ingestion + self.state["last_cursor"] = self.state["new_last_cursor"] + self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"] + self.state["sent_cursors"] = self.state["new_sent_cursors"] + self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat() + + return logs + + def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]: + """Fallback log reader for non-systemd Linux environments.""" + candidates = ["/var/log/syslog", "/var/log/messages"] + target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None) + if not target: + return [] + + logs = [] + try: + with open(target, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines()[-500:] # Read recent 500 lines + for line in lines: + line_str = line.strip() + if not line_str: + continue + sev = "INFO" + lower_l = line_str.lower() + if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]): + sev = "ERROR" + elif any(w in lower_l for w in ["warn", "notice"]): + sev = "WARNING" + + logs.append({ + "server": self.machine_id, + "os_type": "linux", + "signature": "syslog", + "severity": sev, + "message": line_str[:2048] + }) + except Exception: + pass + return logs + + +def get_log_collector( + os_type: Optional[str] = None, + state: Optional[dict] = None, + machine_id: Optional[str] = None, + channels: Optional[List[str]] = None +) -> BaseLogCollector: + """ + Factory helper that returns the appropriate BaseLogCollector instance. + If os_type is omitted, auto-detects based on sys.platform. + """ + target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux") + if target_os == "windows": + chans = channels or (["Application", "System"] if channels is None else ["Application"]) + return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans) + else: + return LinuxLogCollector(state=state, machine_id=machine_id) diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 0000000..51dc656 --- /dev/null +++ b/src/common/__init__.py @@ -0,0 +1 @@ +# LOGAR Common Package diff --git a/src/common/version.py b/src/common/version.py new file mode 100644 index 0000000..aa2f276 --- /dev/null +++ b/src/common/version.py @@ -0,0 +1,67 @@ +""" +LOGAR - Unified Versioning and Semantic Comparison Module +Provides canonical application version constants and semantic version comparison utilities. +""" + +import os +import re +from typing import Tuple, Optional + + +LOGAR_VERSION = "0.3.5.2" +__version__ = LOGAR_VERSION + + +def parse_version(version_str: Optional[str]) -> Tuple[int, ...]: + """ + Parses a version string into a comparable tuple of integers. + Strips leading 'v' or 'V' and non-numeric suffixes. + e.g. 'v0.3.5.2' -> (0, 3, 5, 2), '0.3.5-beta' -> (0, 3, 5, 0) + """ + if not version_str: + return (0, 0, 0, 0) + + clean = str(version_str).strip() + if clean.lower().startswith("v"): + clean = clean[1:].strip() + + # Extract digit sequences separated by dots + match = re.match(r"^(\d+(?:\.\d+)*)", clean) + if not match: + return (0, 0, 0, 0) + + parts = [int(p) for p in match.group(1).split(".")] + # Pad to at least 4 components for consistent 4-part semantic version comparison + while len(parts) < 4: + parts.append(0) + return tuple(parts) + + +def compare_versions(v1: Optional[str], v2: Optional[str]) -> int: + """ + Compares two version strings. + Returns: + 1 if v1 > v2 (v1 is newer than v2) + 0 if v1 == v2 + -1 if v1 < v2 (v1 is older than v2) + """ + t1 = parse_version(v1) + t2 = parse_version(v2) + if t1 > t2: + return 1 + elif t1 < t2: + return -1 + return 0 + + +def is_newer_version(candidate_version: Optional[str], current_version: Optional[str] = None) -> bool: + """ + Returns True if candidate_version is strictly newer than current_version (or LOGAR_VERSION). + """ + base = current_version or LOGAR_VERSION + return compare_versions(candidate_version, base) > 0 + + +def get_current_version() -> str: + """Returns the canonical current version string.""" + return LOGAR_VERSION diff --git a/src/hub/License_Authority.py b/src/hub/License_Authority.py new file mode 100644 index 0000000..7898c4d --- /dev/null +++ b/src/hub/License_Authority.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +""" +LOGAR Master License Authority Proxy +Delegates directly to the standalone submodule: LOGAR-LICENSING/License_Authority.py +Repo: https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git +""" + +import os +import importlib.util + +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SUBMODULE_FILE = os.path.join(ROOT_DIR, "LOGAR-LICENSING", "License_Authority.py") +if not os.path.exists(SUBMODULE_FILE): + alt_sub = os.path.join(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), "LOGAR-LICENSING", "License_Authority.py") + if os.path.exists(alt_sub): + SUBMODULE_FILE = alt_sub + +spec = importlib.util.spec_from_file_location("_submodule_authority", SUBMODULE_FILE) +_sub = importlib.util.module_from_spec(spec) +spec.loader.exec_module(_sub) + +# Export public symbols from submodule into this module's namespace +for attr in dir(_sub): + if not attr.startswith("__"): + globals()[attr] = getattr(_sub, attr) + +if __name__ == "__main__": + _sub.main() diff --git a/src/hub/Server.py b/src/hub/Server.py new file mode 100644 index 0000000..6b055aa --- /dev/null +++ b/src/hub/Server.py @@ -0,0 +1,2456 @@ +""" +LOGAR - Central Cloud Ingestion Hub & Fleet Licensing Server +Main orchestrator and FastAPI application server. +Delegates domain responsibilities to specialized modular managers: + - server_models: Pydantic request & response data models + - auth_manager: Session, Rate Limiting, Password, 2FA TOTP, Hermes token managers + - ingest_queue: Ingestion ring buffering and batch processing + - storage_engine: Multi-engine SQL schema, migrations, and data-at-rest encryption + - license_manager: Sub-CA licensing, OID parsing, and seat synchronization + - pki_manager: TLS 1.3 mTLS context creation and dynamic certificate rotation + - web_ui: Dark-mode administrative WebUI & reactive dashboard renderer +""" + +import os +import sys +import json +import uuid +import time +import struct +import socket +import sqlite3 +import argparse +import asyncio +import secrets +import warnings +import threading +from datetime import datetime, timezone, timedelta +from typing import Dict, Any, List, Optional, Tuple + +# Ensure script directory and repo root are in sys.path +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +if SCRIPT_DIR not in sys.path: + sys.path.insert(0, SCRIPT_DIR) +REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..")) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +# Suppress cryptography / pgpy deprecation notices for a clean terminal output +warnings.filterwarnings("ignore") + +import pgpy +import ssl +from fastapi import FastAPI, HTTPException, Request, Depends +from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse, FileResponse +import uvicorn + +try: + from src import server_enrollment as enrollment +except ImportError: + import server_enrollment as enrollment + +try: + from src.version import LOGAR_VERSION, is_newer_version, compare_versions + from src.update_manager import ServerUpdateManager +except ImportError: + from version import LOGAR_VERSION, is_newer_version, compare_versions + from update_manager import ServerUpdateManager + +try: + from src.log_collector import ( + get_machine_identifier, + WindowsLogCollector, + LinuxLogCollector, + get_log_collector, + is_routine_service_noise + ) +except ImportError: + from log_collector import ( + get_machine_identifier, + WindowsLogCollector, + LinuxLogCollector, + get_log_collector, + is_routine_service_noise + ) + +# Import specialized modular components +from src.server_models import ( + ClientEnrollRequest, + ClientActionRequest, + LicenseImportRequest, + HermesTokenCreateRequest, + HermesTokenRevokeRequest, + ClientProfileExportRequest, + ChangePasswordRequest, + DbConnectionTestRequest, + DbSwitchBackendRequest, + DbFullConfigRequest, + ClientUpdateRequest, + ServerUpdateCheckRequest +) +from src.auth_manager import ( + SessionManager, + RateLimiter, + PasswordManager, + TOTPManager, + HermesTokenManager +) +from src.ingest_queue import IngestQueueManager +from src.storage_engine import StorageEngine, StorageCryptoManager +from src.license_manager import LicenseManager +from src.pki_manager import PKIManager +from src.web_ui import WebUIRenderer, format_relative_time + + +CONFIG_FILE_NAME = "server_config.json" +DEFAULT_DB_FILE = "logar_state.db" +EVALUATION_WINDOW_HOURS = 12 +RUN_THRESHOLD = 1 + +SESSION_COOKIE_NAME = SessionManager.SESSION_COOKIE_NAME +DEFAULT_ADMIN_USER = StorageEngine.DEFAULT_ADMIN_USER +DEFAULT_ADMIN_PASS = StorageEngine.DEFAULT_ADMIN_PASS + +app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version=LOGAR_VERSION) + +# Global context holding server state +UPDATE_MANAGER = ServerUpdateManager() +SERVER_STATE: Dict[str, Any] = { + "update_manager": UPDATE_MANAGER +} + +# Global rate limiter & ingestion queue +RATE_LIMITER = RateLimiter() +INGEST_QUEUE = IngestQueueManager() + + +# ===================================================================== +# Backward-Compatible Auth & Helper Functions (delegating to managers) +# ===================================================================== + +def get_session_secret(db_path: Optional[str] = None) -> str: + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + return SessionManager.get_session_secret(db_path=target_db, fallback_db=DEFAULT_DB_FILE) + + +SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32) + + +def get_client_ip(request: Optional[Request] = None) -> str: + return RateLimiter.get_client_ip(request) + + +def check_ip_rate_limit( + ip: str, + store: Any = None, + max_attempts: int = 10, + window_seconds: int = 60, + scope: str = "login", + db_path: Optional[str] = None +) -> bool: + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + return RATE_LIMITER.check_ip_rate_limit( + ip=ip, store=store, max_attempts=max_attempts, window_seconds=window_seconds, + scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE + ) + + +def record_failed_ip_attempt( + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None +): + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + RATE_LIMITER.record_failed_ip_attempt( + ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE + ) + + +def reset_failed_ip_attempts( + ip: str, + store: Any = None, + scope: str = "login", + db_path: Optional[str] = None +): + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + RATE_LIMITER.reset_failed_ip_attempts( + ip=ip, store=store, scope=scope, db_path=target_db, fallback_db=DEFAULT_DB_FILE + ) + + +def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]: + return PasswordManager.hash_password(password, salt=salt) + + +def verify_password(password: str, salt: str, password_hash: str) -> bool: + return PasswordManager.verify_password(password, salt, password_hash) + + +def generate_session_token(username: str, db_path: Optional[str] = None) -> str: + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + return SessionManager.generate_session_token(username, db_path=target_db, fallback_db=DEFAULT_DB_FILE) + + +def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]: + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + return SessionManager.verify_session_token(token, max_age_hours=max_age_hours, db_path=target_db, fallback_db=DEFAULT_DB_FILE) + + +def get_current_user_optional(request: Request) -> Optional[str]: + """Extracts authenticated username from cookie or Bearer header if valid.""" + db_path = SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE + cookie = request.cookies.get(SESSION_COOKIE_NAME) + if cookie: + user = verify_session_token(cookie, db_path=db_path) + if user: + return user + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1].strip() + user = verify_session_token(token, db_path=db_path) + if user: + return user + return None + + +def require_admin_auth(request: Request) -> str: + """Enforces administrator authentication for management endpoints.""" + # 1. Check if logged in admin user via session cookie or Bearer session token + user = get_current_user_optional(request) + if user: + return user + + # 2. Check Bearer token or token query param matching config auth_token + token = None + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1].strip() + if not token: + token = request.query_params.get("token") + + if token: + cfg_token = SERVER_STATE.get("config", {}).get("auth_token") + if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): + return "config_auth_token" + + raise HTTPException(status_code=401, detail="Authentication required. Please login or provide admin token.") + + +def verify_user_credentials(username: str, password: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return PasswordManager.verify_user_credentials(username, password, db_path=db_path) + + +def change_user_password(username: str, new_password: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return PasswordManager.change_user_password(username, new_password, db_path=db_path) + + +def generate_totp_secret(length: int = 20) -> str: + return TOTPManager.generate_totp_secret(length=length) + + +def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: + return TOTPManager.get_totp_token(secret, intervals_no=intervals_no) + + +def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: + return TOTPManager.verify_totp_code(secret, code, window=window) + + +def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: + return TOTPManager.get_totp_uri(username, secret, issuer=issuer) + + +def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: + return TOTPManager.get_user_totp_info(username, db_path=db_path) + + +def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return TOTPManager.enable_user_totp(username, secret, db_path=db_path) + + +def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return TOTPManager.disable_user_totp(username, db_path=db_path) + + +def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: + return HermesTokenManager.create_hermes_token(name, token=token, db_path=db_path) + + +def list_hermes_tokens(db_path: str = DEFAULT_DB_FILE) -> List[Dict[str, Any]]: + return HermesTokenManager.list_hermes_tokens(db_path=db_path) + + +def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return HermesTokenManager.revoke_hermes_token(token, db_path=db_path) + + +def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return HermesTokenManager.delete_hermes_token(token, db_path=db_path) + + +def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool: + return HermesTokenManager.verify_hermes_token(token, db_path=db_path) + + +def require_hermes_or_admin_auth(request: Request) -> str: + """Authorizes Hermes API access via Bearer token, query parameter, admin session, or legacy auth token.""" + user = get_current_user_optional(request) + if user: + return f"user:{user}" + + token = None + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ", 1)[1].strip() + if not token: + token = request.query_params.get("token") + + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + + if token: + if verify_hermes_token(token, db_path=db_path): + return f"hermes_token:{token[:8]}..." + cfg_token = SERVER_STATE.get("config", {}).get("auth_token") + if cfg_token and secrets.compare_digest(str(token), str(cfg_token)): + return "config_auth_token" + + # Fallback if no hermes tokens are defined in database yet + try: + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT COUNT(*) FROM hermes_tokens WHERE status = 'active'") + active_count = c.fetchone()[0] + conn.close() + if active_count == 0: + return "anonymous_fallback" + except Exception: + pass + + raise HTTPException(status_code=401, detail="Valid Hermes Bearer Token or Administrator Login Required") + + +# ===================================================================== +# Keypair, Storage & Path Resolution Utilities +# ===================================================================== + +def generate_server_keypair(server_name: str) -> Tuple[str, str, str]: + return StorageCryptoManager.generate_server_keypair(server_name) + + +def is_path_writable(filepath: str) -> bool: + """Checks if a file or directory path is writable by the current process.""" + try: + if os.path.exists(filepath): + if os.path.isdir(filepath): + test_f = os.path.join(filepath, f".perm_{os.getpid()}") + with open(test_f, "w") as f: + f.write("1") + os.remove(test_f) + return True + else: + with open(filepath, "a") as f: + pass + return True + else: + parent = os.path.dirname(os.path.abspath(filepath)) or "." + os.makedirs(parent, exist_ok=True) + test_f = os.path.join(parent, f".perm_{os.getpid()}") + with open(test_f, "w") as f: + f.write("1") + os.remove(test_f) + return True + except Exception: + return False + + +def get_default_data_dir(app_name: str = "LOGAR-Server") -> str: + """Returns a suitable writable application data directory.""" + if sys.platform == "win32": + prog_data = os.environ.get("ProgramData", r"C:\ProgramData") + target_prog = os.path.join(prog_data, app_name) + if is_path_writable(target_prog): + return target_prog + + local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local") + target_local = os.path.join(local_app, app_name) + if is_path_writable(target_local): + return target_local + + temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) + if is_path_writable(temp_dir): + return temp_dir + + cwd = os.getcwd() + if is_path_writable(cwd): + return cwd + return os.path.expanduser("~") + + +def resolve_writable_path(path: str, app_name: str = "LOGAR-Server", default_filename: str = "logar_state.db") -> str: + """Resolves a path, ensuring it is writable or falling back to a writable data directory.""" + if not path: + path = default_filename + if os.path.isabs(path) and is_path_writable(path): + return path + if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): + return os.path.abspath(path) + data_dir = get_default_data_dir(app_name) + fname = os.path.basename(path) + return os.path.join(data_dir, fname) + + +def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: + """Loads existing server_config.json or creates a new one on first run.""" + actual_config_path = config_path + if not os.path.isabs(actual_config_path) and not os.path.exists(actual_config_path): + data_dir_cfg = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) + if os.path.exists(data_dir_cfg): + actual_config_path = data_dir_cfg + + if os.path.exists(actual_config_path): + print(f"[*] Loading server configuration from: {os.path.abspath(actual_config_path)}") + with open(actual_config_path, "r", encoding="utf-8") as f: + config = json.load(f) + if "enrollment_secret" not in config: + config["enrollment_secret"] = secrets.token_hex(24) + if "max_seats" not in config: + config["max_seats"] = 0 + if "cert_dir" not in config: + config["cert_dir"] = "certs" + if "tls_enabled" not in config: + config["tls_enabled"] = True + if "license_server_url" not in config: + config["license_server_url"] = "https://logar.eibl.tech" + if "admin_user" not in config: + config["admin_user"] = DEFAULT_ADMIN_USER + if "session_secret" not in config: + config["session_secret"] = secrets.token_hex(32) + if "db_path" not in config: + config["db_path"] = DEFAULT_DB_FILE + if "server_name" not in config: + config["server_name"] = "LOGAR Enterprise Hub" + if "tcp_host" not in config: + config["tcp_host"] = "0.0.0.0" + if "tcp_port" not in config: + config["tcp_port"] = 9443 + if "hermes_host" not in config: + config["hermes_host"] = "0.0.0.0" + if "hermes_port" not in config: + config["hermes_port"] = 8443 + if "evaluation_window_hours" not in config: + config["evaluation_window_hours"] = 12 + if "min_persistence_runs" not in config: + config["min_persistence_runs"] = 2 + if "storage_engine" not in config: + config["storage_engine"] = config.get("db_type", "sqlite") + if "cache_mode" not in config: + config["cache_mode"] = "direct" + if "encrypt_at_rest" not in config: + config["encrypt_at_rest"] = False + + config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE) + config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs") + config["config_file_path"] = os.path.abspath(actual_config_path) + + global SESSION_SECRET + if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config: + SESSION_SECRET = config["session_secret"] + return config + + if not os.path.isabs(config_path) and ("Program Files" in os.getcwd() or not is_path_writable(os.getcwd())): + config_path = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path)) + + print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...") + server_name = "LOGAR-Cloud-Hub" + private_key, public_key, fingerprint = generate_server_keypair(server_name) + auth_token = secrets.token_hex(24) + enrollment_secret = secrets.token_hex(24) + session_sec = secrets.token_hex(32) + + db_path = resolve_writable_path(DEFAULT_DB_FILE, "LOGAR-Server", DEFAULT_DB_FILE) + cert_dir = resolve_writable_path("certs", "LOGAR-Server", "certs") + + config = { + "server_name": server_name, + "tcp_host": "0.0.0.0", + "tcp_port": 9443, + "hermes_host": "0.0.0.0", + "hermes_port": 8443, + "admin_user": DEFAULT_ADMIN_USER, + "auth_token": auth_token, + "enrollment_secret": enrollment_secret, + "session_secret": session_sec, + "max_seats": 0, + "license_status": "unlicensed", + "license_server_url": "https://logar.eibl.tech", + "cert_dir": cert_dir, + "tls_enabled": True, + "db_path": db_path, + "evaluation_window_hours": EVALUATION_WINDOW_HOURS, + "min_persistence_runs": RUN_THRESHOLD, + "server_fingerprint": fingerprint, + "public_key": public_key, + "private_key": private_key, + "config_file_path": os.path.abspath(config_path) + } + if not os.environ.get("LOGAR_SESSION_SECRET"): + SESSION_SECRET = session_sec + + os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + + print("[+] Successfully generated new server config and OpenPGP keypair.") + print(f"[+] Server Encryption Fingerprint: {fingerprint}") + print(f"[+] Saved to: {os.path.abspath(config_path)}") + return config + + +def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> str: + """Generates a fresh enrollment secret, invalidates the previous secret, and persists change to disk & DB.""" + cfg = config_dict if config_dict is not None else SERVER_STATE.get("config", {}) + new_secret = secrets.token_hex(24) + cfg["enrollment_secret"] = new_secret + + config_file_path = cfg.get("config_file_path") or CONFIG_FILE_NAME + try: + if os.path.exists(config_file_path): + with open(config_file_path, "r", encoding="utf-8") as f: + saved = json.load(f) + saved["enrollment_secret"] = new_secret + with open(config_file_path, "w", encoding="utf-8") as f: + json.dump(saved, f, indent=2) + except Exception as e: + print(f"[!] Warning: Could not write new enrollment secret to config file: {e}") + + db_path = cfg.get("db_path", DEFAULT_DB_FILE) + try: + conn = sqlite3.connect(db_path, timeout=5.0) + c = conn.cursor() + c.execute("UPDATE license_config SET enrollment_secret = ? WHERE id = 1", (new_secret,)) + conn.commit() + conn.close() + except Exception as e: + print(f"[!] Warning: Could not update database with new enrollment secret: {e}") + + SERVER_STATE["config"] = cfg + return new_secret + + +def generate_client_profile_dict( + server_host: str, + server_port: int, + client_id: Optional[str] = None, + config_dict: Optional[Dict[str, Any]] = None +) -> Dict[str, Any]: + """Generates a complete standalone client profile dictionary for export.""" + cfg = config_dict or SERVER_STATE.get("config", {}) + ca_pem = SERVER_STATE.get("ca_cert_pem", "") + if not ca_pem: + cert_dir = cfg.get("cert_dir", "certs") + ca_path = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_path): + with open(ca_path, "r", encoding="utf-8") as f: + ca_pem = f.read() + + return { + "format": "LOGAR_CLIENT_PROFILE", + "version": "3.0.0", + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), + "server_host": server_host, + "server_port": int(server_port), + "hermes_host": server_host if cfg.get("hermes_host") in ("0.0.0.0", "::", None) else cfg.get("hermes_host", server_host), + "hermes_port": int(cfg.get("hermes_port", 8443)), + "enrollment_secret": cfg.get("enrollment_secret", ""), + "server_fingerprint": cfg.get("server_fingerprint", ""), + "server_public_key": cfg.get("public_key", ""), + "auth_token": cfg.get("auth_token", ""), + "ca_cert": ca_pem, + "client_id": client_id or "" + } + + +def create_client_config( + server_host: str, + server_port: int, + output_path: str, + config_path: str = CONFIG_FILE_NAME, + hermes_host: Optional[str] = None, + hermes_port: Optional[int] = None +) -> Dict[str, Any]: + """Creates a client configuration file containing the server address, auth token, and encryption key.""" + server_conf = load_or_init_config(config_path) + + effective_hermes_host = hermes_host + if not effective_hermes_host: + cfg_hermes = server_conf.get("hermes_host") + effective_hermes_host = server_host if cfg_hermes in ("0.0.0.0", "::", None) else cfg_hermes + + client_conf = { + "server_host": server_host, + "server_port": server_port, + "hermes_host": effective_hermes_host, + "hermes_port": hermes_port or server_conf.get("hermes_port", 8443), + "enrollment_secret": server_conf.get("enrollment_secret"), + "cert_dir": "certs", + "server_fingerprint": server_conf["server_fingerprint"], + "server_public_key": server_conf["public_key"], + "auth_token": server_conf["auth_token"] + } + + out_dir = os.path.dirname(os.path.abspath(output_path)) + if out_dir and not os.path.exists(out_dir): + os.makedirs(out_dir, exist_ok=True) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(client_conf, f, indent=2) + + print(f"[+] Client configuration successfully written to: {os.path.abspath(output_path)}") + print(f" - Server Target: {server_host}:{server_port}") + print(f" - Encryption Fingerprint: {server_conf['server_fingerprint']}") + return client_conf + + +# ===================================================================== +# Database, Storage & Crypto Integrations +# ===================================================================== + +def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str: + return StorageEngine.init_db(db_path, enrollment_secret=enrollment_secret, max_seats=max_seats) + + +def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]: + return StorageEngine.init_postgres_db(pg_config) + + +def migrate_sqlite_to_postgres(sqlite_path: str, pg_config: Dict[str, Any]) -> Tuple[bool, str, Dict[str, int]]: + return StorageEngine.migrate_sqlite_to_postgres(sqlite_path, pg_config, encrypt_fn=encrypt_field_for_storage) + + +def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + return LicenseManager.export_license_request_lic(config) + + +def get_active_licensed_features(db_path: Optional[str] = None) -> List[str]: + return LicenseManager.get_active_licensed_features(db_path=db_path, server_state=SERVER_STATE) + + +def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool: + return LicenseManager.is_feature_licensed(feature, db_path=db_path, server_state=SERVER_STATE) + + +def get_server_public_key() -> Optional[pgpy.PGPKey]: + return StorageCryptoManager.get_server_public_key(server_state=SERVER_STATE) + + +def encrypt_field_for_storage(text: str, force: bool = False) -> str: + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + is_unlocked = is_feature_licensed("enterprise_db", db_path) + return StorageCryptoManager.encrypt_field_for_storage(text, force=force, server_state=SERVER_STATE, is_unlocked=is_unlocked) + + +def decrypt_field_from_storage(text: str) -> str: + return StorageCryptoManager.decrypt_field_from_storage(text, server_state=SERVER_STATE) + + +def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]: + target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db) + return StorageEngine.get_storage_engine_info( + db_path=target_db, server_state=SERVER_STATE, ingest_queue=INGEST_QUEUE, is_unlocked=is_unlocked + ) + + +def get_license_summary(db_path: Optional[str] = None) -> Dict[str, Any]: + return LicenseManager.get_license_summary(server_state=SERVER_STATE, db_path=db_path) + + +def report_seat_quota_to_authority_sync(): + LicenseManager.report_seat_quota_to_authority_sync(server_state=SERVER_STATE) + + +def trigger_async_seat_report(): + LicenseManager.trigger_async_seat_report(server_state=SERVER_STATE) + + +# ===================================================================== +# PKI & mTLS Management +# ===================================================================== + +def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: + return PKIManager.init_mtls_server_context(cert_dir=cert_dir) + + +def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"): + PKIManager.reload_mtls_context(ssl_ctx, cert_dir=cert_dir) + + +def check_and_rotate_server_certs( + cert_dir: str = "certs", + hostnames: Optional[List[str]] = None, + threshold_days: int = 30 +) -> bool: + return PKIManager.check_and_rotate_server_certs( + cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days, server_state=SERVER_STATE + ) + + +# ===================================================================== +# Log Ingestion & Processing +# ===================================================================== + +async def ingest_buffer_worker_loop(): + """Periodically flushes the in-memory ingestion buffer to persistent database storage.""" + while True: + try: + await asyncio.sleep(INGEST_QUEUE.flush_interval) + config = SERVER_STATE.get("config", {}) + db_path = config.get("db_path", DEFAULT_DB_FILE) + window_hours = config.get("evaluation_window_hours", 12) + min_runs = config.get("min_persistence_runs", 1) + INGEST_QUEUE.flush( + db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs + ) + except asyncio.CancelledError: + break + except Exception as e: + print(f"[!] Ingestion buffer worker error: {e}") + + +def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]: + """ + Evaluates ingested log anomalies and updates temporal run tracking. + All anomalies are promoted directly to VERIFIED for Hermes AI reasoning and decision-making. + Supports SQLite and external PostgreSQL with optional OpenPGP data encryption at rest. + """ + client_server = payload.get("server", "unknown-host") + site_name = payload.get("site_name") or (client_server.split(".", 1)[1] if "." in client_server else "default") + logs = payload.get("logs", []) + run_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + now_iso = now.isoformat() + + config = SERVER_STATE.get("config", {}) + db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower() + + if db_type in ("postgres", "postgresql"): + try: + try: + import psycopg2 + except ImportError: + import psycopg as psycopg2 + + pg_cfg = config.get("postgres", {}) + conn = psycopg2.connect( + host=pg_cfg.get("host", "localhost"), + port=int(pg_cfg.get("port", 5432)), + dbname=pg_cfg.get("database", "logar"), + user=pg_cfg.get("user", "postgres"), + password=pg_cfg.get("password", ""), + sslmode=pg_cfg.get("sslmode", "prefer"), + connect_timeout=int(pg_cfg.get("connect_timeout", 5)) + ) + cursor = conn.cursor() + cursor.execute( + "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (%s, %s, %s, %s, %s)", + (run_id, site_name, client_server, now_iso, len(logs)) + ) + processed_count = 0 + promoted_to_verified = 0 + + for log in logs: + severity = str(log.get("severity", "WARNING")).upper() + if severity in ["DEBUG", "TRACE"]: + continue + + signature = log.get("signature", "unknown") + server = log.get("server", client_server) + message = log.get("message", "") + os_type = log.get("os_type", "unknown") + + # Suppress repetitive benign routine service start/stop cycles + if is_routine_service_noise(signature, message, severity): + continue + + fp = f"{site_name}:{server}:{signature}" + stored_message = encrypt_field_for_storage(message) + + cursor.execute( + "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = %s", + (fp,) + ) + row = cursor.fetchone() + + if row: + run_count, first_seen_str, last_seen_str, current_status, last_run_id = row + try: + last_seen_dt = datetime.fromisoformat(last_seen_str) + except Exception: + last_seen_dt = now + + if (now - last_seen_dt) > timedelta(hours=window_hours): + new_runs = 1 + new_first_seen = now_iso + else: + new_runs = run_count + 1 if last_run_id != run_id else run_count + new_first_seen = first_seen_str + + new_status = "VERIFIED" + if current_status != "VERIFIED": + promoted_to_verified += 1 + + cursor.execute(""" + UPDATE active_issues + SET run_count = %s, last_seen = %s, first_seen = %s, status = %s, last_run_id = %s, message = %s, severity = %s + WHERE fingerprint = %s + """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) + else: + initial_status = "VERIFIED" + promoted_to_verified += 1 + cursor.execute(""" + INSERT INTO active_issues + (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) + + processed_count += 1 + + # Update client node metrics if attached + metrics = payload.get("metrics") + if metrics and isinstance(metrics, dict) and client_server: + cpu_p = float(metrics.get("cpu_percent", 0.0)) + mem_p = float(metrics.get("memory_percent", 0.0)) + disk_p = float(metrics.get("disk_percent_free", 0.0)) + try: + cursor.execute(""" + UPDATE clients + SET last_seen = %s, metrics_json = %s, cpu_percent = %s, memory_percent = %s, disk_percent_free = %s + WHERE client_id = %s OR hostname = %s + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + except Exception: + pass + + conn.commit() + conn.close() + return { + "status": "success", + "run_id": run_id, + "processed": processed_count, + "promoted_verified": promoted_to_verified + } + except Exception as pg_err: + print(f"[!] Warning: PostgreSQL ingestion failed ({pg_err}), falling back to SQLite...") + + # SQLite standard / fallback path + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (?, ?, ?, ?, ?)", + (run_id, site_name, client_server, now_iso, len(logs)) + ) + + processed_count = 0 + promoted_to_verified = 0 + + for log in logs: + severity = str(log.get("severity", "WARNING")).upper() + if severity in ["DEBUG", "TRACE"]: + continue + + signature = log.get("signature", "unknown") + server = log.get("server", client_server) + message = log.get("message", "") + os_type = log.get("os_type", "unknown") + + # Suppress repetitive benign routine service start/stop cycles + if is_routine_service_noise(signature, message, severity): + continue + + fp = f"{site_name}:{server}:{signature}" + stored_message = encrypt_field_for_storage(message) + + cursor.execute( + "SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = ?", + (fp,) + ) + row = cursor.fetchone() + + if row: + run_count, first_seen_str, last_seen_str, current_status, last_run_id = row + try: + last_seen_dt = datetime.fromisoformat(last_seen_str) + except Exception: + last_seen_dt = now + + if (now - last_seen_dt) > timedelta(hours=window_hours): + new_runs = 1 + new_first_seen = now_iso + else: + new_runs = run_count + 1 if last_run_id != run_id else run_count + new_first_seen = first_seen_str + + new_status = "VERIFIED" + if current_status != "VERIFIED": + promoted_to_verified += 1 + + cursor.execute(""" + UPDATE active_issues + SET run_count = ?, last_seen = ?, first_seen = ?, status = ?, last_run_id = ?, message = ?, severity = ? + WHERE fingerprint = ? + """, (new_runs, now_iso, new_first_seen, new_status, run_id, stored_message, severity, fp)) + else: + initial_status = "VERIFIED" + promoted_to_verified += 1 + cursor.execute(""" + INSERT INTO active_issues + (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) + + processed_count += 1 + + # Update client node metrics & version if attached + metrics = payload.get("metrics") + client_ver = payload.get("version") + if metrics and isinstance(metrics, dict) and client_server: + cpu_p = float(metrics.get("cpu_percent", 0.0)) + mem_p = float(metrics.get("memory_percent", 0.0)) + disk_p = float(metrics.get("disk_percent_free", 0.0)) + try: + if client_ver: + cursor.execute(""" + UPDATE clients + SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, client_version = ? + WHERE client_id = ? OR hostname = ? + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_ver, client_server, client_server)) + else: + cursor.execute(""" + UPDATE clients + SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? + WHERE client_id = ? OR hostname = ? + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + except Exception: + pass + elif client_ver and client_server: + try: + cursor.execute(""" + UPDATE clients SET last_seen = ?, client_version = ? WHERE client_id = ? OR hostname = ? + """, (now_iso, client_ver, client_server, client_server)) + except Exception: + pass + + conn.commit() + conn.close() + + return { + "status": "success", + "run_id": run_id, + "processed": processed_count, + "promoted_verified": promoted_to_verified + } + + +def get_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list: + collector = WindowsLogCollector(state=state, channels=["Application", "System"]) + return collector.collect_logs(hours=hours) + + +def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: + collector = LinuxLogCollector(state=state) + return collector.collect_logs(hours=hours) + + +def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list: + collector = get_log_collector(state=state) + return collector.collect_logs(hours=hours) + + +def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int: + """Executes one cycle of local host log collection and direct ingestion into SQLite.""" + machine_id = get_machine_identifier() + os_type = "windows" if sys.platform == "win32" else "linux" + now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + try: + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,)) + if c.fetchone(): + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ? + WHERE client_id = ? + """, (machine_id, os_type, now_sql, machine_id)) + else: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) + VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?) + """, (machine_id, machine_id, os_type, now_sql, now_sql)) + conn.commit() + conn.close() + except Exception as e: + print(f"[!] Warning: Failed to update local hub client record: {e}") + + logs = get_local_system_logs(hours=window_hours, state=state) + if not logs: + return 0 + + payload = { + "server": machine_id, + "site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub", + "timestamp": datetime.now(timezone.utc).isoformat(), + "logs": logs + } + res = process_ingested_logs(payload, db_path=db_path, window_hours=window_hours, min_runs=min_runs) + print(f"[+] Integrated Local Collector: Processed {len(logs)} local host events ({res.get('promoted_verified', 0)} verified anomalies).") + return len(logs) + + +async def local_collector_loop(interval_seconds: int = 30): + """Background worker that periodically ingests local host logs without requiring a client license.""" + config = SERVER_STATE.get("config", {}) + db_path = config.get("db_path", DEFAULT_DB_FILE) + window_hours = config.get("evaluation_window_hours", 12) + min_runs = config.get("min_persistence_runs", 1) + + state_file = resolve_writable_path("local_collector_state.json", "LOGAR-Server", "local_collector_state.json") + state = {} + if os.path.exists(state_file): + try: + with open(state_file, "r", encoding="utf-8") as f: + state = json.load(f) + except Exception: + state = {} + + try: + run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) + with open(state_file, "w", encoding="utf-8") as f: + json.dump(state, f) + except Exception as e: + print(f"[!] Error in initial local log collection cycle: {e}") + + while True: + try: + await asyncio.sleep(interval_seconds) + run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state) + with open(state_file, "w", encoding="utf-8") as f: + json.dump(state, f) + except asyncio.CancelledError: + break + except Exception as e: + print(f"[!] Exception in local_collector_loop: {e}") + + +async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30): + """Periodically checks the validity of Hub Root CA and Server TLS certificates and reports seat quotas.""" + config = SERVER_STATE.get("config", {}) + cert_dir = config.get("cert_dir", "certs") + hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"] + while True: + try: + await asyncio.sleep(interval_seconds) + check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days) + report_seat_quota_to_authority_sync() + except asyncio.CancelledError: + break + except Exception as e: + print(f"[!] Exception in cert_validity_watchdog: {e}") + + +async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + """mTLS TCP socket handler with certificate validation, decryption, and buffering.""" + client_id = None + ssl_obj = writer.get_extra_info("ssl_object") + if ssl_obj: + peercert = ssl_obj.getpeercert() + if peercert and "subject" in peercert: + for rdn in peercert["subject"]: + for key, val in rdn: + if key == "commonName": + client_id = val + break + + if SERVER_STATE.get("tls_enabled", False): + if not client_id: + writer.close() + await writer.wait_closed() + return + + db_path = SERVER_STATE["config"]["db_path"] + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,)) + row = c.fetchone() + if not row or row[0] != "active": + conn.close() + writer.close() + await writer.wait_closed() + return + now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + c.execute("UPDATE clients SET last_seen = ? WHERE client_id = ?", (now_sql, client_id)) + conn.commit() + conn.close() + + try: + length_bytes = await reader.readexactly(4) + length = struct.unpack(">I", length_bytes)[0] + if length <= 0 or length > 10 * 1024 * 1024: + raise ValueError(f"Invalid frame size: {length}") + + payload_bytes = await reader.readexactly(length) + raw_payload = json.loads(payload_bytes.decode("utf-8")) + + if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"): + pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"]) + priv_key = SERVER_STATE["private_key_obj"] + decrypted_obj = priv_key.decrypt(pgp_msg) + log_payload = json.loads(decrypted_obj.message) + else: + log_payload = raw_payload + + if client_id and "server" not in log_payload: + log_payload["server"] = client_id + + cache_mode = SERVER_STATE.get("config", {}).get("cache_mode", "direct") + if cache_mode == "buffered": + INGEST_QUEUE.push(log_payload) + if len(INGEST_QUEUE._buffer) >= INGEST_QUEUE.batch_size: + res = INGEST_QUEUE.flush( + db_path=SERVER_STATE["config"]["db_path"], + window_hours=SERVER_STATE["config"]["evaluation_window_hours"], + min_runs=SERVER_STATE["config"]["min_persistence_runs"], + process_callback=process_ingested_logs + ) + else: + res = { + "status": "success", + "buffered": True, + "processed": len(log_payload.get("logs", [])), + "queue_size": len(INGEST_QUEUE._buffer) + } + else: + res = process_ingested_logs( + log_payload, + db_path=SERVER_STATE["config"]["db_path"], + window_hours=SERVER_STATE["config"]["evaluation_window_hours"], + min_runs=SERVER_STATE["config"]["min_persistence_runs"] + ) + with INGEST_QUEUE._lock: + INGEST_QUEUE._total_ingested += len(log_payload.get("logs", [])) + INGEST_QUEUE._total_flushed += len(log_payload.get("logs", [])) + + resp_bytes = json.dumps(res).encode("utf-8") + writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes) + await writer.drain() + + except Exception as e: + err = json.dumps({"status": "error", "message": str(e)}).encode("utf-8") + try: + writer.write(struct.pack(">I", len(err)) + err) + await writer.drain() + except Exception: + pass + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + +# ===================================================================== +# FastAPI Route Handlers +# ===================================================================== + +@app.get("/login", response_class=HTMLResponse) +def login_page(request: Request): + """Renders the dark mode login page for the aggregation server.""" + if get_current_user_optional(request): + return RedirectResponse(url="/", status_code=303) + return HTMLResponse(content=WebUIRenderer.render_login_html()) + + +@app.post("/login") +async def process_login(request: Request): + """Processes login credentials and issues session cookie with 2FA TOTP enforcement.""" + client_ip = get_client_ip(request) + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not check_ip_rate_limit(client_ip, max_attempts=10, window_seconds=60, scope="login", db_path=db_path): + raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.") + + username, password, totp_code = None, None, None + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + try: + body = await request.json() + username = body.get("username") + password = body.get("password") + totp_code = body.get("totp_code") + except Exception: + pass + else: + form = await request.form() + username = form.get("username") + password = form.get("password") + totp_code = form.get("totp_code") + + if not username or not password: + raise HTTPException(status_code=400, detail="Username and password are required") + + if not verify_user_credentials(username, password, db_path=db_path): + record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) + await asyncio.sleep(0.5) + raise HTTPException(status_code=401, detail="Invalid username or password") + + # Check 2FA TOTP requirement + totp_info = get_user_totp_info(username, db_path=db_path) + if totp_info.get("enabled"): + if not totp_code: + return JSONResponse(status_code=200, content={ + "status": "2fa_required", + "requires_2fa": True, + "message": "Two-factor authentication code required" + }) + if not verify_totp_code(totp_info["secret"], totp_code): + record_failed_ip_attempt(client_ip, scope="login", db_path=db_path) + await asyncio.sleep(0.3) + raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code") + + reset_failed_ip_attempts(client_ip, scope="login", db_path=db_path) + token = generate_session_token(username, db_path=db_path) + is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https" + response = JSONResponse(content={"status": "success", "username": username, "token": token}) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=is_https, + max_age=86400, + samesite="lax" + ) + return response + + +@app.get("/logout") +def process_logout(): + """Logs out and redirects to /login.""" + resp = RedirectResponse(url="/login", status_code=303) + resp.delete_cookie(SESSION_COOKIE_NAME) + return resp + + +@app.post("/api/auth/change-password") +async def api_change_password(req: ChangePasswordRequest, current_user: str = Depends(require_admin_auth)): + """Allows authenticated administrator to change password.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not verify_user_credentials(current_user, req.old_password, db_path=db_path): + raise HTTPException(status_code=403, detail="Current password incorrect") + if len(req.new_password) < 6: + raise HTTPException(status_code=400, detail="New password must be at least 6 characters") + change_user_password(current_user, req.new_password, db_path=db_path) + return {"status": "success", "message": "Password updated successfully"} + + +@app.get("/api/user/2fa/status") +def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)): + """Returns 2FA status for currently logged in administrator.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + info = get_user_totp_info(auth_user, db_path=db_path) + return {"enabled": info["enabled"], "username": auth_user} + + +@app.get("/api/user/2fa/setup") +def api_user_2fa_setup(auth_user: str = Depends(require_admin_auth)): + """Generates a new TOTP secret and otpauth URI for 2FA provisioning.""" + secret = generate_totp_secret() + otpauth = get_totp_uri(auth_user, secret, issuer="LOGAR Server Hub") + return {"secret": secret, "otpauth_uri": otpauth, "username": auth_user} + + +@app.post("/api/user/2fa/enable") +async def api_user_2fa_enable(request: Request, auth_user: str = Depends(require_admin_auth)): + """Enables TOTP for the current user after validating 6-digit code.""" + body = await request.json() + secret = body.get("secret", "").strip() + code = body.get("code", "").strip() + if not secret or not code: + raise HTTPException(status_code=400, detail="Secret and 6-digit verification code required") + if not verify_totp_code(secret, code): + raise HTTPException(status_code=400, detail="Invalid 6-digit TOTP verification code. Please check your device clock.") + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + enable_user_totp(auth_user, secret, db_path=db_path) + return {"status": "success", "message": "2FA successfully activated"} + + +@app.post("/api/user/2fa/disable") +async def api_user_2fa_disable(request: Request, auth_user: str = Depends(require_admin_auth)): + """Disables TOTP for the current user after verifying password.""" + body = await request.json() + password = body.get("password", "") + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not verify_user_credentials(auth_user, password, db_path=db_path): + raise HTTPException(status_code=403, detail="Current password incorrect") + disable_user_totp(auth_user, db_path=db_path) + return {"status": "success", "message": "2FA successfully disabled"} + + +@app.post("/api/client/enroll") +def enroll_client(req: ClientEnrollRequest, request: Request = None): + """Enrolls an edge client by validating the enrollment secret and issuing a signed certificate.""" + client_ip = get_client_ip(request) + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not check_ip_rate_limit(client_ip, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path): + raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.") + + conn = sqlite3.connect(db_path) + c = conn.cursor() + + c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1") + row = c.fetchone() + if not row: + conn.close() + raise HTTPException(status_code=500, detail="License configuration not initialized") + + expected_secret = row[0] + lic_summary = LicenseManager.get_license_summary(SERVER_STATE, db_path=db_path) + max_seats = lic_summary.get("max_seats", 0) + license_status = lic_summary.get("status", "UNLICENSED").lower() + if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"): + conn.close() + raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.") + + if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)): + conn.close() + record_failed_ip_attempt(client_ip, scope="enroll", db_path=db_path) + time.sleep(0.5) + raise HTTPException(status_code=403, detail="Invalid enrollment secret") + + reset_failed_ip_attempts(client_ip, scope="enroll", db_path=db_path) + + ca_cert = SERVER_STATE.get("ca_cert") + ca_key = SERVER_STATE.get("ca_key") + ca_cert_pem = SERVER_STATE.get("ca_cert_pem") + + if not ca_cert or not ca_key: + conn.close() + raise HTTPException(status_code=500, detail="Root CA not loaded on server") + + cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") + upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_ca_path): + try: + with open(upstream_ca_path, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + if upstream_pem and upstream_pem not in ca_cert_pem: + ca_cert_pem = ca_cert_pem.strip() + "\n" + upstream_pem + except Exception: + pass + + c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) + existing = c.fetchone() + if existing: + if existing[0] == "revoked": + conn.close() + raise HTTPException(status_code=403, detail="Client seat revoked by administrator.") + else: + c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") + active_count = c.fetchone()[0] + if active_count >= max_seats: + conn.close() + raise HTTPException(status_code=403, detail=f"License seat limit reached ({active_count}/{max_seats} seats used).") + + cert_pem, key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key) + fp = enrollment.calculate_cert_fingerprint(cert_pem) + + now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + client_ver = req.version or "" + if existing: + try: + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ? + WHERE client_id = ? + """, (req.hostname, req.os, fp, now_sql, client_ver, req.client_id)) + except sqlite3.OperationalError: + c.execute(""" + UPDATE clients + SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ? + WHERE client_id = ? + """, (req.hostname, req.os, fp, now_sql, req.client_id)) + else: + try: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?) + """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver)) + except sqlite3.OperationalError: + c.execute(""" + INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen) + VALUES (?, ?, ?, ?, 'active', ?, ?) + """, (req.client_id, req.hostname, req.os, fp, now_sql, now_sql)) + + conn.commit() + conn.close() + + trigger_async_seat_report() + + return { + "status": "success", + "client_id": req.client_id, + "client_cert": cert_pem, + "client_key": key_pem, + "ca_cert": ca_cert_pem, + "server_fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), + "server_public_key": SERVER_STATE.get("config", {}).get("public_key", "") + } + + +@app.get("/api/client/export-profile") +def export_client_profile( + server_host: Optional[str] = None, + server_port: Optional[int] = None, + client_id: Optional[str] = None, + request: Request = None +): + """Exports standalone client profile JSON for instant client deployment.""" + rotate_enrollment_secret() + + cfg = SERVER_STATE.get("config", {}) + host = server_host + if not host: + host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or cfg.get("tcp_host", "127.0.0.1") + if host in ("0.0.0.0", "localhost", ""): + host = socket.gethostname() + + port = server_port or cfg.get("tcp_port", 9443) + profile = generate_client_profile_dict(server_host=host, server_port=port, client_id=client_id, config_dict=cfg) + return JSONResponse( + content=profile, + headers={"Content-Disposition": f'attachment; filename="logar_client_{host}_{port}.json"'} + ) + + +@app.post("/api/server/reset-enrollment-secret") +def reset_server_enrollment_secret(auth_user: str = Depends(require_admin_auth)): + """Rotates and generates a fresh server enrollment secret on demand.""" + new_secret = rotate_enrollment_secret() + return { + "status": "success", + "message": "Server enrollment secret successfully rotated and updated.", + "enrollment_secret": new_secret + } + + +# ===================================================================== +# Update & Distribution Endpoints +# ===================================================================== + +@app.get("/api/update/status") +@app.get("/api/server/update/status") +def get_update_status(user: str = Depends(get_current_user_optional)): + """Returns current server update and client package distribution repository status.""" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + return mgr.get_status() + + +@app.post("/api/update/check") +@app.post("/api/server/update/check") +def check_server_updates(user: str = Depends(get_current_user_optional)): + """Triggers an immediate upstream release check.""" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + return mgr.check_upstream_updates(force=True) + + +@app.get("/api/client/update/check") +def check_client_update( + client_id: str, + os: Optional[str] = "windows", + os_name: Optional[str] = None, + current_version: Optional[str] = None, + arch: str = "x86_64" +): + """ + Invoked by edge forwarders to check whether a newer client binary package is available. + """ + import os as _os + target_os = os_name or os or "windows" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + + # Record check timestamp in database + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if target_os: + now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + try: + conn = sqlite3.connect(db_path) + conn.execute(""" + UPDATE clients SET last_update_check = ?, client_version = COALESCE(NULLIF(?, ''), client_version) + WHERE client_id = ? + """, (now_sql, current_version or "", client_id)) + conn.commit() + conn.close() + except Exception: + pass + + return mgr.check_client_update(client_id=client_id, os_type=target_os, current_version=current_version, arch=arch) + + +@app.get("/api/client/update/download") +def download_client_update_package( + request: Request, + os: Optional[str] = "windows", + os_name: Optional[str] = None +): + """ + Streams the latest client binary or installer package for the specified OS. + Supports HTTP ETag and conditional 304 Not Modified requests to minimize network bandwidth. + """ + import os as _os + import email.utils + target_os = os_name or os or "windows" + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + pkg_tuple = mgr.get_client_package_path(os_type=target_os) + if not pkg_tuple: + raise HTTPException(status_code=404, detail=f"No client update package available for OS: {target_os}") + + file_path, filename, sha256 = pkg_tuple + if not _os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Client package file missing on server disk.") + + etag = f'"{sha256}"' + if request is not None: + if_none_match = request.headers.get("if-none-match") + if if_none_match and (if_none_match == etag or if_none_match.strip('"') == sha256): + return Response( + status_code=304, + headers={ + "ETag": etag, + "Cache-Control": "public, max-age=300, must-revalidate", + "X-Checksum-SHA256": sha256 + } + ) + + try: + mtime = _os.path.getmtime(file_path) + last_modified = email.utils.formatdate(mtime, usegmt=True) + except Exception: + last_modified = None + + if_modified_since = request.headers.get("if-modified-since") + if if_modified_since and last_modified and if_modified_since == last_modified: + return Response( + status_code=304, + headers={ + "ETag": etag, + "Cache-Control": "public, max-age=300, must-revalidate", + "X-Checksum-SHA256": sha256 + } + ) + + headers = { + "ETag": etag, + "Cache-Control": "public, max-age=300, must-revalidate", + "X-Checksum-SHA256": sha256, + "Content-Disposition": f'attachment; filename="{filename}"' + } + if last_modified: + headers["Last-Modified"] = last_modified + + return FileResponse( + path=file_path, + filename=filename, + media_type="application/octet-stream", + headers=headers + ) + + +@app.post("/api/server/client-package/upload") +async def upload_client_package( + request: Request, + auth_user: str = Depends(require_admin_auth) +): + """ + Uploads a new client executable/installer into the server's update distribution repository. + Accepts JSON payload: { "os": "windows"|"linux", "version": "...", "filename": "...", "data_base64": "..." } + or raw stream with headers 'X-Package-OS', 'X-Package-Filename', 'X-Package-Version'. + """ + import base64 + import os as _os + mgr: ServerUpdateManager = SERVER_STATE.get("update_manager") or UPDATE_MANAGER + content_type = request.headers.get("content-type", "") + + os_type = "windows" + version = None + filename = "client_update.bin" + file_bytes = b"" + + if "application/json" in content_type: + body = await request.json() + os_type = body.get("os", "windows") + version = body.get("version") + raw_name = body.get("filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") + filename = _os.path.basename(str(raw_name)) + b64_data = body.get("data_base64", "") + file_bytes = base64.b64decode(b64_data) + else: + os_type = request.headers.get("X-Package-OS", "windows") + version = request.headers.get("X-Package-Version") + raw_name = request.headers.get("X-Package-Filename") or ("LOGAR_Client_Setup.exe" if os_type == "windows" else "Linux_Client.bin") + filename = _os.path.basename(str(raw_name)) + file_bytes = await request.body() + + if not file_bytes: + raise HTTPException(status_code=400, detail="Empty package payload.") + + temp_dir = _os.path.join(mgr.update_dir, "temp") + _os.makedirs(temp_dir, exist_ok=True) + temp_path = _os.path.join(temp_dir, filename) + + with open(temp_path, "wb") as f: + f.write(file_bytes) + + try: + manifest = mgr.register_client_package( + os_type=os_type, + file_path=temp_path, + version=version, + filename=filename + ) + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + return {"status": "success", "manifest": manifest, "sha256": manifest.get("sha256")} + except Exception as e: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + raise HTTPException(status_code=400, detail=f"Package upload registration failed: {e}") + + +@app.get("/api/clients") +def list_clients(auth_user: str = Depends(require_admin_auth)): + """Returns all registered clients, seat usage, and activity timestamps.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT max_seats FROM license_config WHERE id = 1") + lic_row = c.fetchone() + max_seats = lic_row[0] if lic_row else 10 + + c.execute(""" + SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, + metrics_json, cpu_percent, memory_percent, disk_percent_free + FROM clients ORDER BY last_seen DESC + """) + rows = c.fetchall() + conn.close() + + clients = [] + for r in rows: + cid, host, ost, fp, st, first, last, mjson, cpu, mem, disk = r + m_dict = {} + if mjson: + try: + m_dict = json.loads(mjson) + except Exception: + pass + clients.append({ + "client_id": cid, + "hostname": host, + "os_type": ost, + "cert_fingerprint": fp, + "status": st, + "first_seen": first, + "last_seen": last, + "last_activity_relative": format_relative_time(last), + "metrics": m_dict, + "cpu_percent": cpu or 0.0, + "memory_percent": mem or 0.0, + "disk_percent_free": disk or 0.0 + }) + + active_count = sum(1 for cl in clients if cl["status"] == "active" and cl.get("cert_fingerprint") != "LOCAL-HOST-BUILTIN") + return { + "active_seats": active_count, + "max_seats": max_seats, + "clients": clients + } + + +@app.post("/api/client/approve") +@app.post("/api/client/reactivate") +def approve_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): + """Approves / reactivates a client certificate if within seat quota.""" + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT max_seats FROM license_config WHERE id = 1") + row = c.fetchone() + max_seats = row[0] if row else 10 + + c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'") + active_count = c.fetchone()[0] + if active_count >= max_seats: + conn.close() + raise HTTPException(status_code=403, detail=f"Cannot activate seat: maximum quota ({max_seats}) reached.") + + c.execute("UPDATE clients SET status = 'active' WHERE client_id = ?", (action.client_id,)) + affected = c.rowcount + conn.commit() + conn.close() + if affected == 0: + raise HTTPException(status_code=404, detail="Client ID not found") + print(f"[+] Approved/Reactivated client seat: {action.client_id}") + trigger_async_seat_report() + return {"status": "active", "client_id": action.client_id} + + +reactivate_client = approve_client + + +@app.post("/api/client/revoke") +def revoke_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): + """Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes.""" + if action.client_id == get_machine_identifier(): + raise HTTPException(status_code=400, detail="Cannot revoke integrated local hub collector") + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("UPDATE clients SET status = 'revoked' WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) + affected = c.rowcount + conn.commit() + conn.close() + if affected == 0: + raise HTTPException(status_code=404, detail="Client ID not found") + print(f"[!] Revoked client license seat: {action.client_id}") + trigger_async_seat_report() + return {"status": "revoked", "client_id": action.client_id} + + +@app.post("/api/client/delete") +@app.post("/api/clients/delete") +@app.post("/api/clients/remove") +def delete_client(action: ClientActionRequest, auth_user: str = Depends(require_admin_auth)): + """Permanently removes a client forwarder record from SQLite, immediately freeing its seat.""" + if action.client_id == get_machine_identifier(): + raise HTTPException(status_code=400, detail="Cannot remove integrated local hub collector") + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("DELETE FROM clients WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,)) + affected = c.rowcount + conn.commit() + conn.close() + if affected == 0: + raise HTTPException(status_code=404, detail="Client ID not found") + print(f"[+] Removed client forwarder record: {action.client_id}") + trigger_async_seat_report() + return {"status": "deleted", "client_id": action.client_id} + + +@app.get("/api/hermes/tokens") +def api_list_hermes_tokens(auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + return list_hermes_tokens(db_path=db_path) + + +@app.post("/api/hermes/tokens/create") +def api_create_hermes_token(req: HermesTokenCreateRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not req.name or not req.name.strip(): + raise HTTPException(status_code=400, detail="Token name is required") + return create_hermes_token(req.name.strip(), db_path=db_path) + + +@app.post("/api/hermes/tokens/revoke") +def api_revoke_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not revoke_hermes_token(req.token, db_path=db_path): + raise HTTPException(status_code=404, detail="Token not found") + return {"status": "success", "message": "Token revoked"} + + +@app.post("/api/hermes/tokens/delete") +def api_delete_hermes_token(req: HermesTokenRevokeRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not delete_hermes_token(req.token, db_path=db_path): + raise HTTPException(status_code=404, detail="Token not found") + return {"status": "success", "message": "Token deleted"} + + +@app.get("/api/hermes/report") +def get_verified_anomalies_for_hermes(token_ctx: str = Depends(require_hermes_or_admin_auth)): + """ + Ingestion endpoint for Hermes agentic workflows. + Returns verified anomalies within the active 12-hour evaluation window, + system resource context, and silent/offline forwarder alerts for Hermes AI reasoning. + """ + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute(""" + SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status + FROM active_issues + WHERE status = 'VERIFIED' + ORDER BY last_seen DESC + """) + rows = cursor.fetchall() + + now = datetime.now(timezone.utc) + try: + cursor.execute("SELECT client_id, hostname, os_type, last_seen, metrics_json, cpu_percent, memory_percent, disk_percent_free FROM clients WHERE status = 'active'") + client_rows = cursor.fetchall() + except Exception: + client_rows = [] + conn.close() + + window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12) + report = [] + + client_metrics_map = {} + for cl in client_rows: + cid, chost, cost, clast, cmetrics_raw, ccpu, cmem, cdisk = cl + try: + m_dict = json.loads(cmetrics_raw) if cmetrics_raw else {} + except Exception: + m_dict = {} + if not m_dict: + m_dict = { + "cpu_percent": ccpu or 0.0, + "memory_percent": cmem or 0.0, + "disk_percent_free": cdisk or 0.0 + } + client_metrics_map[cid] = m_dict + if chost: + client_metrics_map[chost] = m_dict + + if clast: + try: + clast_clean = str(clast).replace("T", " ").split("+")[0].split("Z")[0].strip() + last_dt = datetime.fromisoformat(clast_clean).replace(tzinfo=timezone.utc) + if (now - last_dt) > timedelta(minutes=15): + offline_mins = int((now - last_dt).total_seconds() // 60) + report.append({ + "fingerprint": f"offline-node-{cid}", + "site": "default", + "server": chost or cid, + "signature": "LOGAR/NodeSilent", + "severity": "CRITICAL", + "message": f"Forwarder node '{chost or cid}' has gone silent! Last heartbeat was {offline_mins} minutes ago ({clast}). Potential node crash, network partition, or power failure.", + "os_type": cost or "unknown", + "first_seen": clast, + "last_seen": clast, + "consecutive_runs": 1, + "evaluation_window": f"{window_hours}h", + "verified": True, + "status": "NODE_OFFLINE", + "metrics": m_dict + }) + except Exception: + pass + + for r in rows: + srv_name = r[2] + report.append({ + "fingerprint": r[0], + "site": r[1], + "server": srv_name, + "signature": r[3], + "severity": r[4], + "message": decrypt_field_from_storage(r[5]), + "os_type": r[6], + "first_seen": r[7], + "last_seen": r[8], + "consecutive_runs": r[9], + "evaluation_window": f"{window_hours}h", + "verified": True, + "status": r[10], + "metrics": client_metrics_map.get(srv_name, {}) + }) + + return report + + +@app.get("/api/issues") +def get_all_issues(auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + c = conn.cursor() + c.execute("SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues ORDER BY last_seen DESC") + issues = [ + { + "fingerprint": r[0], + "site_name": r[1], + "server": r[2], + "signature": r[3], + "severity": r[4], + "message": decrypt_field_from_storage(r[5]), + "os_type": r[6], + "first_seen": r[7], + "last_seen": r[8], + "run_count": r[9], + "status": r[10] + } + for r in c.fetchall() + ] + conn.close() + return issues + + +@app.get("/api/license/status") +def api_license_status(): + return get_license_summary() + + +@app.get("/api/license/export-lic") +def export_lic_file(auth_user: str = Depends(require_admin_auth)): + config = SERVER_STATE.get("config", {}) + lic_dict, lic_json = export_license_request_lic(config) + hostname = socket.gethostname() + return Response( + content=lic_json, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="logar_license_request_{hostname}.lic"'} + ) + + +@app.get("/api/license/ca-csr") +def get_ca_csr(auth_user: str = Depends(require_admin_auth)): + cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") + csr_path = os.path.join(cert_dir, "ca.csr") + if not os.path.exists(csr_path): + enrollment.generate_ca_csr(cert_dir=cert_dir) + with open(csr_path, "r", encoding="utf-8") as f: + csr_content = f.read() + return Response( + content=csr_content, + media_type="application/x-pem-file", + headers={"Content-Disposition": 'attachment; filename="ca.csr"'} + ) + + +@app.post("/api/license/import") +def import_signed_license(req: LicenseImportRequest, auth_user: str = Depends(require_admin_auth)): + cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs") + ca_file = os.path.join(cert_dir, "ca.crt") + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + upstream_pem = req.upstream_ca.strip() if req.upstream_ca else None + if not upstream_pem and os.path.exists(upstream_file): + try: + with open(upstream_file, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + except Exception: + upstream_pem = None + + if upstream_pem: + if not enrollment.verify_certificate_signature(req.ca_cert, upstream_pem): + raise HTTPException(status_code=400, detail="Cryptographic verification failed: Sub-CA certificate was not signed by the upstream Master Authority.") + + try: + info = enrollment.extract_license_info_from_cert(req.ca_cert, upstream_ca_pem=upstream_pem) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse X.509 certificate: {e}") + + with open(ca_file, "w", encoding="utf-8") as f: + f.write(req.ca_cert.strip() + "\n") + + if req.upstream_ca: + with open(upstream_file, "w", encoding="utf-8") as f: + f.write(req.upstream_ca.strip() + "\n") + + new_seats = info.get("seats") + license_id = info.get("license_id") + expires_at = info.get("not_valid_after") + issued_at = info.get("not_valid_before") + features = info.get("features", []) + + config = SERVER_STATE.get("config", {}) + if new_seats: + config["max_seats"] = new_seats + if license_id: + config["license_id"] = license_id + + db_path = config.get("db_path", DEFAULT_DB_FILE) + conn = sqlite3.connect(db_path) + conn.execute(""" + UPDATE license_config + SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active', features = ? + WHERE id = 1 + """, (new_seats or config.get("max_seats", 10), license_id or "", issued_at or "", expires_at or "", json.dumps(features))) + conn.commit() + conn.close() + + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + SERVER_STATE["ca_cert"] = ca_cert + SERVER_STATE["ca_key"] = ca_key + SERVER_STATE["ca_cert_pem"] = ca_pem + + enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], + cert_dir=cert_dir, + force_renew=True + ) + + ssl_ctx = SERVER_STATE.get("ssl_ctx") + if ssl_ctx is not None: + try: + reload_mtls_context(ssl_ctx, cert_dir=cert_dir) + except Exception as e: + print(f"[!] Warning: Failed to reload in-flight SSLContext: {e}") + + trigger_async_seat_report() + + return { + "status": "success", + "message": "Signed Subordinate CA certificate successfully activated!", + "license_id": license_id, + "seats": new_seats, + "expires_at": expires_at, + "issued_at": issued_at, + "features": features + } + + +@app.get("/api/db/stats") +def api_db_stats(auth_user: str = Depends(require_admin_auth)): + return { + "status": "success", + "queue": INGEST_QUEUE.get_stats(), + "queue_stats": INGEST_QUEUE.get_stats(), + "storage": get_storage_engine_info(), + "storage_info": get_storage_engine_info(), + "enterprise_db_licensed": is_feature_licensed("enterprise_db"), + "license_summary": get_license_summary() + } + + +@app.post("/api/db/flush-buffer") +def api_db_flush_buffer(auth_user: str = Depends(require_admin_auth)): + config = SERVER_STATE.get("config", {}) + db_path = config.get("db_path", DEFAULT_DB_FILE) + window_hours = config.get("evaluation_window_hours", 12) + min_runs = config.get("min_persistence_runs", 1) + res = INGEST_QUEUE.flush( + db_path=db_path, window_hours=window_hours, min_runs=min_runs, process_callback=process_ingested_logs + ) + return { + "status": "success", + "message": f"Successfully flushed {res.get('processed_logs', 0)} events across {res.get('flushed_runs', 0)} batch runs ({res.get('flush_latency_ms', 0)}ms).", + "details": res + } + + +@app.post("/api/db/test-connection") +def api_db_test_connection(req: DbConnectionTestRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + if not is_feature_licensed("enterprise_db", db_path): + raise HTTPException(status_code=403, detail="Enterprise database support requires an 'enterprise_db' license capability.") + ok, msg = init_postgres_db(req.dict()) + if not ok: + raise HTTPException(status_code=400, detail=f"Database connection failed: {msg}") + return {"status": "success", "message": msg} + + +@app.post("/api/db/switch-backend") +def api_db_switch_backend(req: DbSwitchBackendRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + config = SERVER_STATE.get("config", {}) + + if req.db_type.lower() in ("postgres", "postgresql"): + if not is_feature_licensed("enterprise_db", db_path): + raise HTTPException(status_code=403, detail="PostgreSQL enterprise storage requires an 'enterprise_db' license capability.") + pg_conf = { + "host": req.host, + "port": req.port, + "database": req.database, + "user": req.user, + "password": req.password, + "sslmode": req.sslmode, + "connect_timeout": req.connect_timeout, + "pool_size": req.pool_size + } + ok, msg = init_postgres_db(pg_conf) + if not ok: + raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}") + config["storage_engine"] = "postgres" + config["db_type"] = "postgres" + config["postgres"] = pg_conf + else: + config["storage_engine"] = "sqlite" + config["db_type"] = "sqlite" + + cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME + try: + with open(cfg_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except Exception as e: + print(f"[!] Warning: Could not save config file on backend switch: {e}") + + return { + "status": "success", + "message": f"Storage backend switched to {config['storage_engine']}.", + "storage_info": get_storage_engine_info() + } + + +@app.post("/api/db/update-config") +def api_db_update_config(req: DbFullConfigRequest, auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + config = SERVER_STATE.get("config", {}) + is_licensed = is_feature_licensed("enterprise_db", db_path) + + if (req.storage_engine.lower() in ("postgres", "postgresql") or req.cache_mode == "buffered" or req.encrypt_at_rest) and not is_licensed: + raise HTTPException( + status_code=403, + detail="Advanced database features (PostgreSQL, in-memory buffering, encryption at rest) require an 'enterprise_db' license capability." + ) + + if req.storage_engine.lower() in ("postgres", "postgresql"): + if req.pg_config: + pg_conf = req.pg_config.dict() + ok, msg = init_postgres_db(pg_conf) + if not ok: + raise HTTPException(status_code=400, detail=f"PostgreSQL initialization failed: {msg}") + config["postgres"] = pg_conf + config["storage_engine"] = "postgres" + config["db_type"] = "postgres" + else: + config["storage_engine"] = "sqlite" + config["db_type"] = "sqlite" + + config["cache_mode"] = req.cache_mode + config["encrypt_at_rest"] = req.encrypt_at_rest + config["buffer_flush_interval_sec"] = req.flush_interval + config["buffer_batch_size"] = req.batch_size + config["buffer_max_queue"] = req.max_queue + + INGEST_QUEUE.flush_interval = req.flush_interval + INGEST_QUEUE.batch_size = req.batch_size + INGEST_QUEUE.max_queue = req.max_queue + + cfg_file = config.get("config_file_path") or CONFIG_FILE_NAME + try: + with open(cfg_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except Exception as e: + print(f"[!] Warning: Could not save config file: {e}") + + return { + "status": "success", + "message": "Storage & Ingestion parameters updated successfully.", + "storage_info": get_storage_engine_info() + } + + +@app.post("/api/db/migrate-to-postgres") +def api_db_migrate_to_postgres(auth_user: str = Depends(require_admin_auth)): + db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) + config = SERVER_STATE.get("config", {}) + + if not is_feature_licensed("enterprise_db", db_path): + raise HTTPException(status_code=403, detail="Database migration requires an 'enterprise_db' license capability.") + + pg_config = config.get("postgres") + if not pg_config: + raise HTTPException(status_code=400, detail="PostgreSQL configuration not found. Please configure PostgreSQL parameters first.") + + ok, msg, counts = migrate_sqlite_to_postgres(db_path, pg_config) + if not ok: + raise HTTPException(status_code=500, detail=f"Migration failed: {msg}") + + return { + "status": "success", + "message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.", + "counts": counts + } + + +@app.get("/", response_class=HTMLResponse) +@app.get("/webui", response_class=HTMLResponse) +def serve_webui(request: Request = None): + """Renders the comprehensive LOGAR Server Hub & Fleet Licensing WebUI.""" + auth_user = None + if request is not None: + auth_user = get_current_user_optional(request) + if not auth_user: + return RedirectResponse(url="/login", status_code=303) + else: + auth_user = DEFAULT_ADMIN_USER + + html_content = WebUIRenderer.render_dashboard_html( + auth_user=auth_user, + request=request, + server_state=SERVER_STATE, + ingest_queue=INGEST_QUEUE + ) + return HTMLResponse(content=html_content) + + +@app.get("/health") +def health_check(): + return { + "status": "healthy", + "server_name": SERVER_STATE.get("config", {}).get("server_name", "LOGAR-Cloud-Hub"), + "fingerprint": SERVER_STATE.get("config", {}).get("server_fingerprint", ""), + "tcp_port": SERVER_STATE.get("config", {}).get("tcp_port", 9443), + "hermes_port": SERVER_STATE.get("config", {}).get("hermes_port", 8443), + "tls_enabled": SERVER_STATE.get("tls_enabled", False) + } + + +async def run_server(): + """Runs the mTLS TCP socket listener and the Hermes REST API concurrently.""" + config = SERVER_STATE["config"] + tcp_host = config["tcp_host"] + tcp_port = int(config["tcp_port"]) + hermes_host = config["hermes_host"] + hermes_port = int(config["hermes_port"]) + ssl_ctx = SERVER_STATE.get("ssl_ctx") + + tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx) + mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP" + print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}") + + uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning") + uv_server = uvicorn.Server(uv_config) + print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report") + print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll") + + watchdog_task = asyncio.create_task(cert_validity_watchdog()) + collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30)) + buffer_task = asyncio.create_task(ingest_buffer_worker_loop()) + + try: + await asyncio.gather( + tcp_server.serve_forever(), + uv_server.serve(), + watchdog_task, + collector_task, + buffer_task + ) + finally: + watchdog_task.cancel() + collector_task.cancel() + buffer_task.cancel() + for t in [watchdog_task, collector_task, buffer_task]: + try: + await t + except asyncio.CancelledError: + pass + try: + db_p = config.get("db_path", DEFAULT_DB_FILE) + INGEST_QUEUE.flush(db_path=db_p, process_callback=process_ingested_logs) + except Exception: + pass + + +def main(): + parser = argparse.ArgumentParser(description="LOGAR Cloud Hub & TCP Socket Ingestion Server") + parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to server_config.json") + parser.add_argument("--create-client-config", action="store_true", help="Generate a client config with encryption-only fingerprint and server address") + parser.add_argument("--client-out", default="client_config.json", help="Output file path for generated client config") + parser.add_argument("--server-host", default="127.0.0.1", help="Server address to embed in client config") + parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config") + parser.add_argument("--export-lic", type=str, help="Export a .lic license request file containing CA CSR and coordinates") + parser.add_argument("--import-ca", type=str, help="Import and activate a signed CA certificate or activation .lic file") + parser.add_argument("--import-lic", type=str, help="Import and activate an activated .lic license file") + parser.add_argument("--sync-license", action="store_true", help="Sync license with master authority and check for seat increases via mTLS") + args = parser.parse_args() + + config = load_or_init_config(args.config) + config["db_path"] = init_db( + config["db_path"], + enrollment_secret=config.get("enrollment_secret"), + max_seats=config.get("max_seats", 10) + ) + + cert_dir = config.get("cert_dir", "certs") + + # Standalone CLI license export + if args.export_lic: + lic_dict, lic_json = export_license_request_lic(config) + with open(args.export_lic, "w", encoding="utf-8") as f: + f.write(lic_json) + print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}") + sys.exit(0) + + # Standalone CLI CA / .lic import + lic_file = args.import_lic or args.import_ca + if lic_file: + try: + res = enrollment.import_license_package( + lic_file, + cert_dir=cert_dir, + server_fingerprint=config.get("server_fingerprint", ""), + hostname=config.get("server_name", "") + ) + except Exception as e: + print(f"[-] Invalid license file {lic_file}: {e}") + sys.exit(1) + + info = res.get("license_info", {}) + new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10) + config["max_seats"] = new_seats + conn = sqlite3.connect(config["db_path"]) + conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, res.get("license_id") or info.get("license_id"))) + conn.commit() + conn.close() + + ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], + cert_dir=cert_dir, + force_renew=True + ) + + print("[+] Successfully imported and activated signed license!") + print(f" - Granted Seats: {new_seats}") + print(f" - License ID: {res.get('license_id') or info.get('license_id')}") + print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}") + sys.exit(0) + + # Standalone CLI license sync / update against master server + if args.sync_license: + lic_server = config.get("license_server_url", "wss://logar.eibl.tech/ws/license") + print(f"[*] Connecting to master authority at {lic_server} via WebSocket to sync license...") + + ca_info = {} + ca_path = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_path): + with open(ca_path, "r", encoding="utf-8") as f: + ca_info = enrollment.extract_license_info_from_cert(f.read()) + + ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license" + + active_seats = 0 + if os.path.exists(config.get("db_path", "server_logs.db")): + try: + conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db")) + row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone() + if row: + active_seats = row[0] + conn_tmp.close() + except Exception: + pass + + success, msg, res = enrollment.enroll_and_sync_license_ws( + cert_dir=cert_dir, + license_id=ca_info.get("license_id"), + license_authority_ws_url=ws_url, + server_fingerprint=config.get("server_fingerprint", ""), + hostname=config.get("server_name", ""), + seats_taken=active_seats + ) + + if not success or not res: + print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...") + http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "") + success, msg, new_pem = enrollment.renew_ca_cert_mtls( + cert_dir=cert_dir, + license_server_url=http_url, + seats_taken=active_seats, + active_seats=active_seats, + hostname=config.get("server_name", ""), + server_fingerprint=config.get("server_fingerprint", "") + ) + if not success or not new_pem: + print(f"[-] License sync failed: {msg}") + sys.exit(1) + info = enrollment.extract_license_info_from_cert(new_pem) + new_seats = info.get("seats") or config.get("max_seats", 10) + else: + info = res.get("license_info", {}) + new_seats = res.get("seats", config.get("max_seats", 10)) + + config["max_seats"] = new_seats + conn = sqlite3.connect(config["db_path"]) + conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id"))) + conn.commit() + conn.close() + + ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], + cert_dir=cert_dir, + force_renew=True + ) + print("[+] License successfully synchronized with master server!") + print(f" - Active Seat Quota: {new_seats}") + print(f" - License ID: {info.get('license_id')}") + print(f" - Expires: {info.get('not_valid_after')}") + sys.exit(0) + + # Initialize dynamic PKI + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir) + ca_info = enrollment.extract_license_info_from_cert(ca_pem) + if ca_info.get("seats"): + config["max_seats"] = ca_info["seats"] + conn = sqlite3.connect(config["db_path"]) + conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (ca_info["seats"],)) + conn.commit() + conn.close() + + csr_path = os.path.join(cert_dir, "ca.csr") + if not os.path.exists(csr_path): + enrollment.generate_ca_csr(cert_dir=cert_dir) + + srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"], + cert_dir=cert_dir + ) + + ssl_ctx = None + if config.get("tls_enabled", True): + ssl_ctx = init_mtls_server_context(cert_dir=cert_dir) + + priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"]) + SERVER_STATE["config"] = config + SERVER_STATE["private_key_obj"] = priv_key_obj + SERVER_STATE["ca_cert"] = ca_cert + SERVER_STATE["ca_key"] = ca_key + SERVER_STATE["ca_cert_pem"] = ca_pem + SERVER_STATE["ssl_ctx"] = ssl_ctx + SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True) + + if args.create_client_config: + port = args.server_port or config["tcp_port"] + create_client_config( + server_host=args.server_host, + server_port=port, + output_path=args.client_out, + config_path=args.config + ) + sys.exit(0) + + hermes_host = config.get("hermes_host", "0.0.0.0") + hermes_port = config.get("hermes_port", 8443) + display_host = "localhost" if hermes_host in ("0.0.0.0", "127.0.0.1") else hermes_host + + print("=" * 64) + print(f" LOGAR Server Hub: {config.get('server_name', 'LOGAR Enterprise Hub')}") + print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}") + print(f" License Quota: {config.get('max_seats', 10)} Active Seats ({ca_info.get('license_id') or 'Local Sub-CA'})") + print(f" Upstream Authority: {config.get('license_server_url', 'https://logar.eibl.tech')}") + print(f" Server Encryption Fingerprint: {config.get('server_fingerprint', 'N/A')}") + print(f" WebUI & Fleet Dashboard: http://{display_host}:{hermes_port}/webui") + print(f" Hermes Reporting API: http://{display_host}:{hermes_port}/api/hermes/report") + print("=" * 64) + + try: + asyncio.run(run_server()) + except KeyboardInterrupt: + print("\n[!] Server shutting down.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/hub/__init__.py b/src/hub/__init__.py new file mode 100644 index 0000000..b5bb64b --- /dev/null +++ b/src/hub/__init__.py @@ -0,0 +1 @@ +# LOGAR Hub Package diff --git a/src/hub/auth_manager.py b/src/hub/auth_manager.py new file mode 100644 index 0000000..2d3e509 --- /dev/null +++ b/src/hub/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/hub/ingest_queue.py b/src/hub/ingest_queue.py new file mode 100644 index 0000000..9abddb1 --- /dev/null +++ b/src/hub/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/hub/license_manager.py b/src/hub/license_manager.py new file mode 100644 index 0000000..7f03513 --- /dev/null +++ b/src/hub/license_manager.py @@ -0,0 +1,260 @@ +""" +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 verified X.509 certificate extensions or the persistent database license config. + """ + features = set() + state = server_state or {} + cert_dir = state.get("config", {}).get("cert_dir", "certs") + upstream_pem = None + upstream_file = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_file): + try: + with open(upstream_file, "r", encoding="utf-8") as f: + upstream_pem = f.read() + except Exception: + pass + + # 1. Check in-memory / active Subordinate CA certificate + ca_pem = state.get("ca_cert_pem") + if not ca_pem: + 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 + + cert_is_verified = False + if ca_pem: + try: + info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem) + cert_is_verified = info.get("signature_verified", False) or not info.get("is_self_signed", False) + 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, verifies cryptographic signatures, 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") + upstream_path = os.path.join(cert_dir, "upstream_ca.crt") + target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE) + + upstream_pem = None + if os.path.exists(upstream_path): + try: + with open(upstream_path, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + except Exception: + pass + + db_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: + db_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) + + cert_seats = None + 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, upstream_ca_pem=upstream_pem) + if info.get("seats") is not None: + cert_seats = info["seats"] + except Exception: + info = {"valid": False} + else: + info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0} + + # Anti-tampering reconciliation: If database max_seats diverged from certified quota, enforce certified quota and update DB + if cert_seats is not None: + max_seats = cert_seats + if db_max_seats != cert_seats and os.path.exists(target_db): + try: + conn = sqlite3.connect(target_db) + conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (cert_seats,)) + conn.commit() + conn.close() + except Exception: + pass + else: + max_seats = db_max_seats + + 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), + "signature_verified": info.get("signature_verified", 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/hub/pki_manager.py b/src/hub/pki_manager.py new file mode 100644 index 0000000..d744454 --- /dev/null +++ b/src/hub/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/hub/server_enrollment.py b/src/hub/server_enrollment.py new file mode 100644 index 0000000..202af53 --- /dev/null +++ b/src/hub/server_enrollment.py @@ -0,0 +1,1070 @@ +import os +import json +import ssl +import datetime +import ipaddress +import urllib.request +import urllib.error +import urllib.parse +import http.client +from typing import Tuple, List, Optional, Dict, Any +from cryptography import x509 +from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +# Custom Enterprise X.509 OIDs for LOGAR Licensing +OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1") +OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2") +OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3") +OID_LOGAR_FEATURES = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.4") + + + +def calculate_cert_fingerprint(cert_pem: str) -> str: + """Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate.""" + cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) + return cert.fingerprint(hashes.SHA256()).hex().upper() + + +def is_cert_expiring_soon(cert_pem: str, threshold_days: int = 30) -> bool: + """ + Checks if a PEM-encoded X.509 certificate expires within `threshold_days` (or is already expired). + Returns True if expiring soon or expired, False otherwise. + """ + try: + cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) + expiry = getattr(cert, "not_valid_after_utc", None) + if expiry is None: + expiry = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + now = datetime.datetime.now(datetime.timezone.utc) + return expiry <= (now + datetime.timedelta(days=threshold_days)) + except Exception: + return True + + +def generate_ca_if_needed( + cert_dir: str = "certs", + common_name: str = "LOGAR-Root-CA", + force_renew: bool = False, + threshold_days: int = 30 +) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]: + """ + Loads an existing Root CA or generates a self-signed Root CA certificate and private key. + If existing CA cert is expiring within threshold_days (or force_renew is True), regenerates it. + Returns (ca_cert_obj, ca_key_obj, ca_cert_pem, ca_key_pem). + """ + os.makedirs(cert_dir, exist_ok=True) + ca_cert_path = os.path.join(cert_dir, "ca.crt") + ca_key_path = os.path.join(cert_dir, "ca.key") + + if not force_renew and os.path.exists(ca_cert_path) and os.path.exists(ca_key_path): + with open(ca_cert_path, "r", encoding="utf-8") as f: + ca_cert_pem = f.read() + with open(ca_key_path, "r", encoding="utf-8") as f: + ca_key_pem = f.read() + try: + ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode("utf-8")) + ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None) + if not is_cert_expiring_soon(ca_cert_pem, threshold_days=threshold_days): + return ca_cert, ca_key, ca_cert_pem, ca_key_pem + except Exception: + pass + + # Create timestamped backup of previous CA if present + if os.path.exists(ca_cert_path): + try: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + os.replace(ca_cert_path, f"{ca_cert_path}.{timestamp}.bak") + if os.path.exists(ca_key_path): + os.replace(ca_key_path, f"{ca_key_path}.{timestamp}.bak") + except Exception: + pass + + # Generate RSA 4096 private key for Root CA + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ]) + + now = datetime.datetime.now(datetime.timezone.utc) + ca_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False + ) + .sign(ca_key, hashes.SHA256()) + ) + + ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + ca_key_pem = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + + with open(ca_cert_path, "w", encoding="utf-8") as f: + f.write(ca_cert_pem) + with open(ca_key_path, "w", encoding="utf-8") as f: + f.write(ca_key_pem) + + try: + os.chmod(ca_key_path, 0o600) + except Exception: + pass + + return ca_cert, ca_key, ca_cert_pem, ca_key_pem + + +def generate_server_cert_if_needed( + ca_cert: x509.Certificate, + ca_key: rsa.RSAPrivateKey, + hostnames: Optional[List[str]] = None, + cert_dir: str = "certs", + days_valid: int = 825, + force_renew: bool = False, + threshold_days: int = 30 +) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]: + """ + Loads an existing server certificate or generates a new server TLS certificate signed by the Root CA. + If existing server cert is expiring within threshold_days (or force_renew is True), regenerates it. + Includes SANs for localhost, 127.0.0.1, and specified hostnames. + """ + os.makedirs(cert_dir, exist_ok=True) + server_cert_path = os.path.join(cert_dir, "server.crt") + server_key_path = os.path.join(cert_dir, "server.key") + + if not force_renew and os.path.exists(server_cert_path) and os.path.exists(server_key_path): + with open(server_cert_path, "r", encoding="utf-8") as f: + server_cert_pem = f.read() + with open(server_key_path, "r", encoding="utf-8") as f: + server_key_pem = f.read() + try: + srv_cert = x509.load_pem_x509_certificate(server_cert_pem.encode("utf-8")) + srv_key = serialization.load_pem_private_key(server_key_pem.encode("utf-8"), password=None) + if not is_cert_expiring_soon(server_cert_pem, threshold_days=threshold_days): + return srv_cert, srv_key, server_cert_pem, server_key_pem + except Exception: + pass + + # Create timestamped backup of previous server cert if present + if os.path.exists(server_cert_path): + try: + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + os.replace(server_cert_path, f"{server_cert_path}.{timestamp}.bak") + if os.path.exists(server_key_path): + os.replace(server_key_path, f"{server_key_path}.{timestamp}.bak") + except Exception: + pass + + server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), + x509.NameAttribute(NameOID.COMMON_NAME, "LOGAR-Server-Hub"), + ]) + + san_list = [ + x509.DNSName("localhost"), + x509.DNSName("LOGAR-Server-Hub"), + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + x509.IPAddress(ipaddress.IPv6Address("::1")), + ] + + if hostnames: + for host in hostnames: + if not host: + continue + try: + ip_obj = ipaddress.ip_address(host) + san_list.append(x509.IPAddress(ip_obj)) + except ValueError: + san_list.append(x509.DNSName(host)) + + now = datetime.datetime.now(datetime.timezone.utc) + server_cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=days_valid)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), + critical=False + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False + ) + .add_extension(x509.SubjectAlternativeName(san_list), critical=False) + .sign(ca_key, hashes.SHA256()) + ) + + server_cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + server_key_pem = server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + + # Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain + full_server_cert_pem = server_cert_pem + ca_cert_path = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_cert_path): + with open(ca_cert_path, "r", encoding="utf-8") as f: + ca_pem = f.read().strip() + if ca_pem and ca_pem not in full_server_cert_pem: + full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem + + upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_ca_path): + with open(upstream_ca_path, "r", encoding="utf-8") as f: + upstream_pem = f.read().strip() + if upstream_pem and upstream_pem not in full_server_cert_pem: + full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem + + with open(server_cert_path, "w", encoding="utf-8") as f: + f.write(full_server_cert_pem) + with open(server_key_path, "w", encoding="utf-8") as f: + f.write(server_key_pem) + + try: + os.chmod(server_key_path, 0o600) + except Exception: + pass + + return server_cert, server_key, server_cert_pem, server_key_pem + + +def issue_client_cert( + client_id: str, + ca_cert: x509.Certificate, + ca_key: rsa.RSAPrivateKey, + days_valid: int = 365 +) -> Tuple[str, str]: + """ + Generates a 2048-bit RSA private key and signs an X.509 client certificate + with Common Name set to client_id. + Returns (cert_pem, key_pem). + """ + client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), + x509.NameAttribute(NameOID.COMMON_NAME, client_id), + ]) + + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(client_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=days_valid)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + key_cert_sign=False, + crl_sign=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()), + critical=False + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False + ) + .sign(ca_key, hashes.SHA256()) + ) + + cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + key_pem = client_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + + return cert_pem, key_pem + + +def check_and_renew_hub_pki( + cert_dir: str = "certs", + hostnames: Optional[List[str]] = None, + threshold_days: int = 30 +) -> Tuple[bool, bool]: + """ + Evaluates expiration status of Root CA and Server TLS certificates. + If CA certificate is expiring within threshold_days (or missing): + - Regenerates Root CA. + - Automatically regenerates Server TLS certificate (since CA issuer changed). + - Returns (ca_renewed=True, server_renewed=True) + Else if Server TLS certificate is expiring within threshold_days (or missing): + - Regenerates Server TLS certificate signed by existing Root CA. + - Returns (ca_renewed=False, server_renewed=True) + Otherwise: + - Returns (False, False) + """ + os.makedirs(cert_dir, exist_ok=True) + ca_cert_path = os.path.join(cert_dir, "ca.crt") + server_cert_path = os.path.join(cert_dir, "server.crt") + + renew_ca = False + renew_server = False + + if not os.path.exists(ca_cert_path): + renew_ca = True + else: + try: + with open(ca_cert_path, "r", encoding="utf-8") as f: + ca_pem = f.read() + if is_cert_expiring_soon(ca_pem, threshold_days=threshold_days): + renew_ca = True + except Exception: + renew_ca = True + + if renew_ca: + ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=True) + generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True) + return True, True + + if not os.path.exists(server_cert_path): + renew_server = True + else: + try: + with open(server_cert_path, "r", encoding="utf-8") as f: + srv_pem = f.read() + if is_cert_expiring_soon(srv_pem, threshold_days=threshold_days): + renew_server = True + except Exception: + renew_server = True + + if renew_server: + ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) + generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True) + return False, True + + return False, False + + +def verify_certificate_signature(cert_pem: Any, issuer_ca_pem: Any) -> bool: + """ + Cryptographically verifies that cert_pem was signed by the private key corresponding to issuer_ca_pem. + """ + try: + from cryptography.hazmat.primitives.asymmetric import padding + cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) if isinstance(cert_pem, str) else cert_pem + issuer = x509.load_pem_x509_certificate(issuer_ca_pem.encode("utf-8")) if isinstance(issuer_ca_pem, str) else issuer_ca_pem + issuer.public_key().verify( + cert.signature, + cert.tbs_certificate_bytes, + padding.PKCS1v15(), + cert.signature_hash_algorithm + ) + return True + except Exception: + return False + + +def extract_license_info_from_cert(cert_pem: str, upstream_ca_pem: Optional[str] = None) -> Dict[str, Any]: + """ + Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate. + Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs. + Cryptographically verifies the signature if upstream_ca_pem is provided. + """ + try: + cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) + except Exception as e: + return {"valid": False, "error": f"Invalid certificate format: {e}"} + + seats = None + license_id = None + issuer_url = None + + try: + ext_seats = cert.extensions.get_extension_for_oid(OID_LOGAR_SEATS) + if ext_seats and ext_seats.value: + raw_val = ext_seats.value.value if hasattr(ext_seats.value, "value") else str(ext_seats.value) + if isinstance(raw_val, bytes): + seats = int(raw_val.decode("utf-8").strip()) + else: + seats = int(str(raw_val).strip()) + except (x509.ExtensionNotFound, ValueError, Exception): + seats = None + + try: + ext_lic = cert.extensions.get_extension_for_oid(OID_LOGAR_LICENSE_ID) + if ext_lic and ext_lic.value: + raw_val = ext_lic.value.value if hasattr(ext_lic.value, "value") else str(ext_lic.value) + license_id = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() + except (x509.ExtensionNotFound, Exception): + license_id = None + + try: + ext_url = cert.extensions.get_extension_for_oid(OID_LOGAR_ISSUER_URL) + if ext_url and ext_url.value: + raw_val = ext_url.value.value if hasattr(ext_url.value, "value") else str(ext_url.value) + issuer_url = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() + except (x509.ExtensionNotFound, Exception): + issuer_url = None + + features = [] + try: + ext_feat = cert.extensions.get_extension_for_oid(OID_LOGAR_FEATURES) + if ext_feat and ext_feat.value: + raw_val = ext_feat.value.value if hasattr(ext_feat.value, "value") else str(ext_feat.value) + feat_str = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() + if feat_str.startswith("["): + features = json.loads(feat_str) + else: + features = [f.strip() for f in feat_str.split(",") if f.strip()] + except Exception: + features = [] + + not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) + not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) + now = datetime.datetime.now(datetime.timezone.utc) + + is_expired = now > not_after + days_remaining = max(0, (not_after - now).days) if not is_expired else 0 + + is_ca = False + try: + bc_ext = cert.extensions.get_extension_for_oid(x509.OID_BASIC_CONSTRAINTS) + if bc_ext and bc_ext.value: + is_ca = bc_ext.value.ca + except Exception: + pass + + subject_cn = "" + for attr in cert.subject: + if attr.oid == NameOID.COMMON_NAME: + subject_cn = attr.value + break + + issuer_cn = "" + for attr in cert.issuer: + if attr.oid == NameOID.COMMON_NAME: + issuer_cn = attr.value + break + + is_self_signed = (cert.subject == cert.issuer) + signature_verified = False + + if upstream_ca_pem: + signature_verified = verify_certificate_signature(cert, upstream_ca_pem) + elif is_self_signed: + signature_verified = verify_certificate_signature(cert, cert) + + return { + "valid": True, + "seats": seats, + "license_id": license_id, + "issuer_url": issuer_url, + "features": features, + "subject_cn": subject_cn, + "issuer_cn": issuer_cn, + "is_ca": is_ca, + "is_self_signed": is_self_signed, + "signature_verified": signature_verified, + "fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(), + "not_valid_before": not_before.isoformat(), + "not_valid_after": not_after.isoformat(), + "is_expired": is_expired, + "days_remaining": days_remaining + } + + +def generate_ca_key_if_needed(cert_dir: str = "certs") -> Tuple[rsa.RSAPrivateKey, str]: + """Ensures ca.key exists in cert_dir, generating a 4096-bit RSA key if not.""" + os.makedirs(cert_dir, exist_ok=True) + ca_key_path = os.path.join(cert_dir, "ca.key") + if os.path.exists(ca_key_path): + with open(ca_key_path, "r", encoding="utf-8") as f: + key_pem = f.read() + try: + ca_key = serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None) + return ca_key, key_pem + except Exception: + pass + + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + key_pem = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + with open(ca_key_path, "w", encoding="utf-8") as f: + f.write(key_pem) + return ca_key, key_pem + + +def generate_ca_csr( + cert_dir: str = "certs", + common_name: str = "LOGAR-Sub-CA", + organization: str = "LOGAR", + country: str = "AT" +) -> Tuple[str, str]: + """ + Generates a Certificate Signing Request (CSR) for a subordinate CA using ca.key. + Returns (csr_pem, ca_key_pem). + """ + ca_key, key_pem = generate_ca_key_if_needed(cert_dir) + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ]) + + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(subject) + .add_extension( + x509.BasicConstraints(ca=True, path_length=0), + critical=True + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .sign(ca_key, hashes.SHA256()) + ) + + csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") + csr_path = os.path.join(cert_dir, "ca.csr") + with open(csr_path, "w", encoding="utf-8") as f: + f.write(csr_pem) + + return csr_pem, key_pem + + +def create_license_request_lic( + cert_dir: str = "certs", + license_server: str = "https://logar.eibl.tech", + server_fingerprint: str = "", + hostname: str = "", + requested_seats: int = 10, + common_name: str = "LOGAR-Sub-CA" +) -> Tuple[Dict[str, Any], str]: + """ + Packages a CA CSR and server metadata into an exportable .lic file dictionary and JSON string. + """ + csr_pem, _ = generate_ca_csr(cert_dir=cert_dir, common_name=common_name) + lic_data = { + "format": "LOGAR_LICENSE_REQUEST", + "version": "1.0", + "license_server": license_server, + "server_fingerprint": server_fingerprint, + "hostname": hostname, + "requested_seats": requested_seats, + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "csr": csr_pem + } + lic_json = json.dumps(lic_data, indent=2) + lic_path = os.path.join(cert_dir, "logar_server.lic") + with open(lic_path, "w", encoding="utf-8") as f: + f.write(lic_json) + return lic_data, lic_json + + +def sign_ca_csr_with_seats( + csr_pem: str, + master_ca_cert_pem: str, + master_ca_key_pem: str, + seats: int, + days_valid: int = 365, + license_id: Optional[str] = None, + license_server_url: str = "https://logar.eibl.tech", + features: Optional[List[str]] = None +) -> str: + """ + Signs a subordinate CA CSR using the master license authority Root CA. + Embeds the approved seat count, license ID, and optional enterprise features via custom X.509 extensions. + Returns the signed PEM certificate. + """ + csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8")) + if not csr.is_signature_valid: + raise ValueError("Invalid CSR signature") + + master_ca_cert = x509.load_pem_x509_certificate(master_ca_cert_pem.encode("utf-8")) + master_ca_key = serialization.load_pem_private_key(master_ca_key_pem.encode("utf-8"), password=None) + + now = datetime.datetime.now(datetime.timezone.utc) + if not license_id: + license_id = f"LIC-{csr.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo).hex()[:12].upper()}" + + builder = ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(master_ca_cert.subject) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=days_valid)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), + critical=False + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(master_ca_key.public_key()), + critical=False + ) + .add_extension( + x509.UnrecognizedExtension(OID_LOGAR_SEATS, str(seats).encode("utf-8")), + critical=False + ) + .add_extension( + x509.UnrecognizedExtension(OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")), + critical=False + ) + .add_extension( + x509.UnrecognizedExtension(OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")), + critical=False + ) + ) + + if features: + feat_str = json.dumps(features) if isinstance(features, list) else str(features) + builder = builder.add_extension( + x509.UnrecognizedExtension(OID_LOGAR_FEATURES, feat_str.encode("utf-8")), + critical=False + ) + + signed_cert = builder.sign(master_ca_key, hashes.SHA256()) + return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + + +def enroll_and_sync_license_ws( + cert_dir: str = "certs", + license_id: Optional[str] = None, + license_authority_ws_url: str = "wss://logar.eibl.tech/ws/license", + server_fingerprint: str = "", + hostname: str = "", + comm_cert_pem: Optional[str] = None, + master_root_ca_pem: Optional[str] = None, + seats_taken: Optional[int] = None, + timeout: int = 15, + verify_tls: bool = True +) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """ + Connects to the Master License Authority via WebSocket protocol, authenticating and + submitting the server Sub-CA CSR to receive a cryptographically signed Sub-CA license + certificate with embedded seat quotas. + """ + os.makedirs(cert_dir, exist_ok=True) + ca_key_path = os.path.join(cert_dir, "ca.key") + ca_cert_path = os.path.join(cert_dir, "ca.crt") + + # 1. Load or generate client Sub-CA private key + if os.path.exists(ca_key_path): + with open(ca_key_path, "r", encoding="utf-8") as f: + ca_key_pem = f.read() + ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None) + else: + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + ca_key_pem = ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + ).decode("utf-8") + with open(ca_key_path, "w", encoding="utf-8") as f: + f.write(ca_key_pem) + + # 2. Build Sub-CA CSR + csr_common_name = f"LOGAR-Server-CA-{license_id or 'Node'}" + subject = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Subordinate Node"), + x509.NameAttribute(NameOID.COMMON_NAME, csr_common_name), + ]) + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(subject) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False + ), + critical=True + ) + .sign(ca_key, hashes.SHA256()) + ) + csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") + + # 3. Connect via WebSocket + try: + from websockets.sync.client import connect as ws_connect + except ImportError: + return False, "websockets library not available", None + + ssl_context = None + if license_authority_ws_url.startswith("wss://"): + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + if not verify_tls: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + elif master_root_ca_pem: + ssl_context.load_verify_locations(cadata=master_root_ca_pem) + else: + upstream_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_path): + ssl_context.load_verify_locations(cafile=upstream_path) + + try: + with ws_connect( + license_authority_ws_url, + ssl=ssl_context, + open_timeout=timeout, + close_timeout=timeout + ) as ws: + payload = { + "action": "enroll_or_sync", + "license_id": license_id, + "communication_cert": comm_cert_pem, + "server_csr": csr_pem, + "server_fingerprint": server_fingerprint, + "hostname": hostname + } + if seats_taken is not None: + payload["seats_taken"] = seats_taken + ws.send(json.dumps(payload)) + raw_reply = ws.recv(timeout=timeout) + reply = json.loads(raw_reply) + + if reply.get("status") != "success": + return False, f"WebSocket licensing error: {reply.get('message', raw_reply)}", None + + new_ca_pem = reply.get("ca_cert") + if not new_ca_pem: + return False, "License response missing 'ca_cert'", None + + # Backup old ca.crt if present + if os.path.exists(ca_cert_path): + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + try: + os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak") + except Exception: + pass + + with open(ca_cert_path, "w", encoding="utf-8") as f: + f.write(new_ca_pem) + + upstream_ca = reply.get("master_root_ca") or master_root_ca_pem + if upstream_ca: + with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: + f.write(upstream_ca) + + lic_info = extract_license_info_from_cert(new_ca_pem) + return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { + "license_id": license_id, + "seats": reply.get("seats"), + "ca_cert_pem": new_ca_pem, + "master_root_ca_pem": upstream_ca, + "license_info": lic_info + } + + except Exception as e: + return False, f"WebSocket enrollment failed: {str(e)}", None + + +def import_license_package( + lic_content_or_path: str, + cert_dir: str = "certs", + server_fingerprint: str = "", + hostname: str = "" +) -> Dict[str, Any]: + """ + Imports and validates a license package or certificate. + Supports: + 1. Bootstrap JSON .lic package (auto-enrolls via WebSocket to receive signed CA cert) + 2. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca' + 3. Raw PEM Subordinate CA certificate + 4. File path pointing to any of the above + Saves the signed CA certificate to cert_dir/ca.crt (backing up any old ca.crt), + and if 'master_root_ca' is present, saves it to cert_dir/upstream_ca.crt for mTLS trust. + Returns extracted license attributes and PEM contents. + """ + os.makedirs(cert_dir, exist_ok=True) + raw = lic_content_or_path + if os.path.exists(lic_content_or_path) and os.path.isfile(lic_content_or_path): + with open(lic_content_or_path, "r", encoding="utf-8") as f: + raw = f.read() + + ca_pem = None + master_root_ca_pem = None + + trimmed = raw.strip() + if trimmed.startswith("{"): + try: + data = json.loads(trimmed) + except Exception as e: + raise ValueError(f"Failed to parse JSON .lic package: {e}") + + # If this is a bootstrap license package, auto-enroll over WebSocket + if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")): + lic_id = data.get("license_id") + ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") + comm_cert = data.get("communication_cert") + master_ca = data.get("master_root_ca") + + if master_ca: + with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: + f.write(master_ca) + + ok, msg, res = enroll_and_sync_license_ws( + cert_dir=cert_dir, + license_id=lic_id, + license_authority_ws_url=ws_url, + server_fingerprint=server_fingerprint or data.get("server_fingerprint", ""), + hostname=hostname or data.get("hostname", ""), + comm_cert_pem=comm_cert, + master_root_ca_pem=master_ca + ) + if not ok or not res: + raise ValueError(msg) + return res + + ca_pem = data.get("ca_cert") or data.get("cert_pem") + master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") + elif "-----BEGIN CERTIFICATE-----" in trimmed: + ca_pem = trimmed + + if not ca_pem: + raise ValueError("Provided license package does not contain a valid 'ca_cert' PEM certificate.") + + info = extract_license_info_from_cert(ca_pem) + if not info.get("valid"): + raise ValueError(f"Invalid CA certificate in license: {info.get('error')}") + + # Backup existing ca.crt if present + ca_path = os.path.join(cert_dir, "ca.crt") + if os.path.exists(ca_path): + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + try: + os.replace(ca_path, f"{ca_path}.{ts}.bak") + except Exception: + pass + + with open(ca_path, "w", encoding="utf-8") as f: + f.write(ca_pem) + + # Save upstream_ca.crt if master_root_ca is present + if master_root_ca_pem: + upstream_path = os.path.join(cert_dir, "upstream_ca.crt") + with open(upstream_path, "w", encoding="utf-8") as f: + f.write(master_root_ca_pem) + + return { + "ca_cert_pem": ca_pem, + "master_root_ca_pem": master_root_ca_pem, + "license_info": info, + "seats": info.get("seats"), + "features": info.get("features", []), + "license_id": info.get("license_id"), + "issuer_url": info.get("issuer_url"), + "fingerprint": info.get("fingerprint"), + "expires_at": info.get("not_valid_after") + } + + +def renew_ca_cert_mtls( + cert_dir: str = "certs", + license_server_url: str = "https://logar.eibl.tech", + upstream_ca_pem: Optional[str] = None, + requested_seats: Optional[int] = None, + seat_increment: int = 0, + seats_taken: Optional[int] = None, + active_seats: Optional[int] = None, + hostname: Optional[str] = None, + server_fingerprint: Optional[str] = None, + timeout: int = 15, + verify_tls: bool = True +) -> Tuple[bool, str, Optional[str]]: + """ + Connects to the master License Authority using mutual TLS (mTLS), authenticating + with the server's current ca.crt and ca.key, and requests an automated renewal. + Supports requesting seat quota increases or increments during renewal and reporting active seat usage. + If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir. + Returns (success, message, new_ca_cert_pem). + """ + ca_cert_path = os.path.join(cert_dir, "ca.crt") + ca_key_path = os.path.join(cert_dir, "ca.key") + + if not os.path.exists(ca_cert_path) or not os.path.exists(ca_key_path): + return False, f"Missing ca.crt or ca.key in {cert_dir}", None + + renew_url = f"{license_server_url.rstrip('/')}/api/license/renew" + parsed = urllib.parse.urlparse(renew_url) + + try: + with open(ca_cert_path, "r", encoding="utf-8") as f: + current_pem = f.read() + info = extract_license_info_from_cert(current_pem) + + effective_seats_taken = seats_taken if seats_taken is not None else active_seats + payload_dict = { + "license_id": info.get("license_id"), + "fingerprint": info.get("fingerprint"), + "current_cert": current_pem, + "seats_taken": effective_seats_taken, + "active_seats": effective_seats_taken, + "hostname": hostname or "", + "server_fingerprint": server_fingerprint or "" + } + if requested_seats is not None: + payload_dict["requested_seats"] = requested_seats + if seat_increment: + payload_dict["seat_increment"] = seat_increment + + payload = json.dumps(payload_dict).encode("utf-8") + + path = parsed.path or "/api/license/renew" + headers = { + "Content-Type": "application/json", + "User-Agent": "LOGAR-Server-mTLS/2.0" + } + + if parsed.scheme == "https": + ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + if not verify_tls: + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + elif upstream_ca_pem: + ssl_ctx.load_verify_locations(cadata=upstream_ca_pem) + upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") + if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem: + ssl_ctx.load_verify_locations(cafile=upstream_ca_path) + + ssl_ctx.load_cert_chain(certfile=ca_cert_path, keyfile=ca_key_path) + conn = http.client.HTTPSConnection(parsed.netloc, context=ssl_ctx, timeout=timeout) + else: + conn = http.client.HTTPConnection(parsed.netloc, timeout=timeout) + + conn.request("POST", path, body=payload, headers=headers) + resp = conn.getresponse() + resp_data = resp.read().decode("utf-8") + conn.close() + + if resp.status != 200: + return False, f"License server returned HTTP {resp.status}: {resp_data}", None + + result = json.loads(resp_data) + new_cert_pem = result.get("ca_cert") + if not new_cert_pem: + return False, f"License server response missing 'ca_cert': {resp_data}", None + + # Backup old ca.crt + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") + os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak") + + # Write new ca.crt + with open(ca_cert_path, "w", encoding="utf-8") as f: + f.write(new_cert_pem) + + # Update upstream_ca.crt if master root CA certificate was provided + master_root_pem = result.get("master_root_ca") + if master_root_pem: + upstream_path = os.path.join(cert_dir, "upstream_ca.crt") + with open(upstream_path, "w", encoding="utf-8") as f: + f.write(master_root_pem) + + return True, "Certificate renewed successfully via mTLS", new_cert_pem + + except Exception as e: + return False, f"mTLS renewal exception: {str(e)}", None + + diff --git a/src/hub/server_models.py b/src/hub/server_models.py new file mode 100644 index 0000000..164347b --- /dev/null +++ b/src/hub/server_models.py @@ -0,0 +1,88 @@ +""" +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 + version: Optional[str] = None + + +class ClientUpdateRequest(BaseModel): + client_id: str + os: str + current_version: Optional[str] = None + arch: Optional[str] = "x86_64" + + +class ServerUpdateCheckRequest(BaseModel): + force: bool = False + + +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/hub/storage_engine.py b/src/hub/storage_engine.py new file mode 100644 index 0000000..5b23d86 --- /dev/null +++ b/src/hub/storage_engine.py @@ -0,0 +1,641 @@ +""" +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' + ) + """) + + def _ensure_columns(tbl: str, cols: List[Tuple[str, str]]): + try: + existing = {row[1] for row in conn.execute(f"PRAGMA table_info({tbl})").fetchall()} + for col, col_def in cols: + if col not in existing: + conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} {col_def}") + except Exception: + pass + + # Migrations for license_config + _ensure_columns("license_config", [ + ("license_id", "TEXT"), + ("license_server_url", "TEXT"), + ("issued_at", "TEXT"), + ("expires_at", "TEXT"), + ("license_status", "TEXT DEFAULT 'unlicensed'"), + ("features", "TEXT DEFAULT '[]'") + ]) + + # 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 + _ensure_columns("clients", [ + ("metrics_json", "TEXT DEFAULT '{}'"), + ("cpu_percent", "REAL DEFAULT 0.0"), + ("memory_percent", "REAL DEFAULT 0.0"), + ("disk_percent_free", "REAL DEFAULT 0.0"), + ("client_version", "TEXT DEFAULT ''"), + ("update_status", "TEXT DEFAULT 'up_to_date'"), + ("last_update_check", "TEXT") + ]) + + # 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) + _ensure_columns("users", [ + ("totp_secret", "TEXT"), + ("totp_enabled", "INTEGER DEFAULT 0") + ]) + + # 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"), + ("client_version", "VARCHAR(50) DEFAULT ''"), + ("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"), + ("last_update_check", "VARCHAR(50)") + ]: + 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/hub/update_manager.py b/src/hub/update_manager.py new file mode 100644 index 0000000..2721591 --- /dev/null +++ b/src/hub/update_manager.py @@ -0,0 +1,362 @@ +""" +LOGAR - Central Server Update & Fleet Distribution Manager +Handles upstream release checking, update alerting, local client package hosting, +and client auto-update check/download endpoints. +""" + +import os +import sys +import json +import hashlib +import urllib.request +import urllib.error +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional, Tuple + +try: + from src.version import LOGAR_VERSION, is_newer_version, compare_versions +except ImportError: + from version import LOGAR_VERSION, is_newer_version, compare_versions + + +class ServerUpdateManager: + DEFAULT_GITEA_RELEASE_URL = "https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest" + + def __init__( + self, + update_dir: Optional[str] = None, + upstream_url: Optional[str] = None, + check_enabled: bool = True + ): + self.update_dir = update_dir or os.path.join(os.getcwd(), "updates") + os.makedirs(self.update_dir, exist_ok=True) + self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL + self.check_enabled = check_enabled + + # Cached upstream release state + self.last_checked_utc: Optional[str] = None + self.latest_upstream_version: Optional[str] = None + self.server_update_available: bool = False + self.release_title: Optional[str] = None + self.release_notes: Optional[str] = None + self.release_url: Optional[str] = None + self.release_assets: List[Dict[str, Any]] = [] + self.last_error: Optional[str] = None + + # Hosted client packages cache + self.hosted_client_version: str = LOGAR_VERSION + self._sha256_cache: Dict[str, Tuple[float, int, str]] = {} + self._package_cache: Dict[str, Dict[str, Any]] = {} + self._package_cache_mtimes: Dict[str, float] = {} + self._ensure_package_structure() + + def _ensure_package_structure(self): + """Initializes default subdirectories for OS-specific client packages.""" + for os_name in ["windows", "linux"]: + os.makedirs(os.path.join(self.update_dir, os_name), exist_ok=True) + + @classmethod + def calculate_sha256(cls, filepath: str) -> str: + """Computes the SHA-256 digest of a local file with stat-based memoization.""" + if not os.path.exists(filepath): + return "" + try: + stat = os.stat(filepath) + mtime = stat.st_mtime + size = stat.st_size + if hasattr(cls, "_global_sha256_cache"): + cached = cls._global_sha256_cache.get(filepath) + if cached and cached[0] == mtime and cached[1] == size: + return cached[2] + except Exception: + mtime = 0.0 + size = 0 + + h = hashlib.sha256() + with open(filepath, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + digest = h.hexdigest().lower() + + if not hasattr(cls, "_global_sha256_cache"): + cls._global_sha256_cache = {} + cls._global_sha256_cache[filepath] = (mtime, size, digest) + return digest + + def set_hosted_client_version(self, version_str: str): + """Sets the version tag for currently hosted client packages.""" + if version_str: + self.hosted_client_version = version_str.strip().lstrip("v") + self._package_cache.clear() + + def invalidate_package_cache(self, os_type: Optional[str] = None): + """Clears in-memory package cache.""" + if os_type: + self._package_cache.pop(os_type.lower(), None) + self._package_cache_mtimes.pop(os_type.lower(), None) + else: + self._package_cache.clear() + self._package_cache_mtimes.clear() + + def register_client_package( + self, + os_type: str, + file_path: str, + version: Optional[str] = None, + filename: Optional[str] = None + ) -> Dict[str, Any]: + """ + Saves/copies a client binary package into the local update repository. + Computes SHA-256 checksum and updates metadata. + """ + norm_os = os_type.lower() + if norm_os not in ["windows", "linux"]: + raise ValueError(f"Unsupported OS type: {os_type}") + + if not os.path.exists(file_path): + raise FileNotFoundError(f"Package file not found: {file_path}") + + target_dir = os.path.join(self.update_dir, norm_os) + os.makedirs(target_dir, exist_ok=True) + + raw_name = filename or os.path.basename(file_path) + target_name = os.path.basename(raw_name) + dest_path = os.path.join(target_dir, target_name) + + if os.path.abspath(file_path) != os.path.abspath(dest_path): + with open(file_path, "rb") as src_f, open(dest_path, "wb") as dst_f: + while chunk := src_f.read(65536): + dst_f.write(chunk) + + sha256 = self.calculate_sha256(dest_path) + pkg_version = version or self.hosted_client_version or LOGAR_VERSION + self.set_hosted_client_version(pkg_version) + + # Write manifest file next to package + manifest = { + "os": norm_os, + "filename": target_name, + "version": pkg_version, + "sha256": sha256, + "size_bytes": os.path.getsize(dest_path), + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + } + manifest_path = os.path.join(target_dir, "manifest.json") + with open(manifest_path, "w", encoding="utf-8") as mf: + json.dump(manifest, mf, indent=2) + + # Update cache directly + cached_entry = dict(manifest) + cached_entry["file_path"] = dest_path + cached_entry["exists"] = True + self._package_cache[norm_os] = cached_entry + self._package_cache_mtimes[norm_os] = os.path.getmtime(dest_path) + + return manifest + + def get_hosted_packages(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """ + Returns info on currently hosted client packages for Windows and Linux. + Uses in-memory cache validated against file modification times to minimize disk I/O. + """ + result: Dict[str, Dict[str, Any]] = {} + for os_name in ["windows", "linux"]: + manifest_path = os.path.join(self.update_dir, os_name, "manifest.json") + target_file = None + + if not force_refresh and os_name in self._package_cache: + cached = self._package_cache[os_name] + tf = cached.get("file_path") + if tf and os.path.exists(tf): + try: + cur_mtime = os.path.getmtime(tf) + if cur_mtime == self._package_cache_mtimes.get(os_name): + result[os_name] = cached + continue + except Exception: + pass + + if os.path.exists(manifest_path): + try: + with open(manifest_path, "r", encoding="utf-8") as mf: + info = json.load(mf) + target_file = os.path.join(self.update_dir, os_name, info.get("filename", "")) + if os.path.exists(target_file): + info["exists"] = True + info["file_path"] = target_file + result[os_name] = info + self._package_cache[os_name] = info + self._package_cache_mtimes[os_name] = os.path.getmtime(target_file) + continue + except Exception: + pass + + # Fallback scan in update_dir/{os_name} + os_dir = os.path.join(self.update_dir, os_name) + found_pkg = None + if os.path.exists(os_dir): + for f in os.listdir(os_dir): + if f.endswith((".exe", ".bin", ".zip", ".tar.gz", ".sh")) and not f.startswith("."): + found_pkg = os.path.join(os_dir, f) + break + + # Secondary fallback: search project dist/ + if not found_pkg: + dist_dir = os.path.join(os.path.dirname(self.update_dir), "dist") + if os.path.exists(dist_dir): + patterns = [ + "LOGAR_Client_Setup.exe", "LOGAR_Client.exe", "Win_Client.exe" + ] if os_name == "windows" else [ + "Linux_Client.bin", "LOGAR_Client_Linux", "install_linux_client.sh" + ] + for pat in patterns: + candidate = os.path.join(dist_dir, pat) + if os.path.exists(candidate): + found_pkg = candidate + break + + if found_pkg and os.path.exists(found_pkg): + sha256 = self.calculate_sha256(found_pkg) + pkg_data = { + "os": os_name, + "filename": os.path.basename(found_pkg), + "version": self.hosted_client_version or LOGAR_VERSION, + "sha256": sha256, + "size_bytes": os.path.getsize(found_pkg), + "file_path": found_pkg, + "exists": True, + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + } + result[os_name] = pkg_data + self._package_cache[os_name] = pkg_data + self._package_cache_mtimes[os_name] = os.path.getmtime(found_pkg) + else: + pkg_data = { + "os": os_name, + "filename": None, + "version": self.hosted_client_version or LOGAR_VERSION, + "sha256": "", + "size_bytes": 0, + "file_path": None, + "exists": False + } + result[os_name] = pkg_data + self._package_cache[os_name] = pkg_data + self._package_cache_mtimes[os_name] = 0.0 + + return result + + def get_client_package_path(self, os_type: str) -> Optional[Tuple[str, str, str]]: + """ + Returns (file_path, filename, sha256) for downloading a client update package. + """ + packages = self.get_hosted_packages() + pkg_info = packages.get(os_type.lower()) + if pkg_info and pkg_info.get("exists") and pkg_info.get("file_path"): + return pkg_info["file_path"], pkg_info["filename"], pkg_info["sha256"] + return None + + def check_client_update( + self, + client_id: str, + os_type: str, + current_version: Optional[str], + arch: str = "x86_64" + ) -> Dict[str, Any]: + """ + Evaluates whether a client requires an update based on its reported version + and the server's hosted client package version. + """ + norm_os = (os_type or "windows").lower() + packages = self.get_hosted_packages() + pkg = packages.get(norm_os, {}) + + target_version = pkg.get("version") or self.hosted_client_version or LOGAR_VERSION + has_pkg = bool(pkg.get("exists")) + client_ver = (current_version or "0.0.0.0").strip().lstrip("v") + + # An update is available if the hosted version is strictly newer than the client version + update_available = has_pkg and is_newer_version(target_version, client_ver) + + return { + "update_available": update_available, + "client_id": client_id, + "os": norm_os, + "current_version": client_ver, + "latest_version": target_version, + "sha256": pkg.get("sha256", "") if update_available else "", + "download_url": f"/api/client/update/download?os={norm_os}" if update_available else "", + "size_bytes": pkg.get("size_bytes", 0) if update_available else 0, + "filename": pkg.get("filename", "") if update_available else "", + "release_notes": self.release_notes or f"LOGAR {target_version} update for {norm_os.capitalize()}.", + "mandatory": False + } + + def check_upstream_updates(self, force: bool = False, timeout: int = 8) -> Dict[str, Any]: + """ + Queries the upstream repository release API (Gitea / GitHub / JSON manifest) + to check if a newer server or client release is available. + """ + if not self.check_enabled and not force: + return self.get_status() + + now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + self.last_checked_utc = now_utc + self.last_error = None + + try: + req = urllib.request.Request( + self.upstream_url, + headers={ + "User-Agent": f"LOGAR-Server/{LOGAR_VERSION}", + "Accept": "application/json" + } + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode("utf-8")) + raw_tag = data.get("tag_name") or data.get("name") or data.get("version") or "" + upstream_ver = raw_tag.strip().lstrip("v") + + self.latest_upstream_version = upstream_ver + self.release_title = data.get("name") or raw_tag + self.release_notes = data.get("body") or data.get("notes") or "" + self.release_url = data.get("html_url") or data.get("url") or "" + + # Process assets list + assets = [] + for a in data.get("assets", []): + assets.append({ + "name": a.get("name"), + "size": a.get("size"), + "download_url": a.get("browser_download_url") or a.get("download_url") + }) + self.release_assets = assets + + # Compare version against current LOGAR_VERSION + self.server_update_available = is_newer_version(upstream_ver, LOGAR_VERSION) + if self.server_update_available: + print(f"[!] 🔔 LOGAR Server Update Available: Current v{LOGAR_VERSION} -> Upstream v{upstream_ver}") + else: + self.last_error = f"Upstream returned HTTP status {resp.status}" + except Exception as e: + self.last_error = f"Failed to check upstream updates: {e}" + + return self.get_status() + + def get_status(self) -> Dict[str, Any]: + """Returns structured update status for WebUI and REST APIs.""" + return { + "current_server_version": LOGAR_VERSION, + "latest_upstream_version": self.latest_upstream_version or LOGAR_VERSION, + "server_update_available": self.server_update_available, + "hosted_client_version": self.hosted_client_version or LOGAR_VERSION, + "release_title": self.release_title or "", + "release_notes": self.release_notes or "", + "release_url": self.release_url or "", + "release_assets": self.release_assets, + "last_checked_utc": self.last_checked_utc, + "last_error": self.last_error, + "check_enabled": self.check_enabled, + "hosted_packages": self.get_hosted_packages() + } diff --git a/src/hub/web_ui.py b/src/hub/web_ui.py new file mode 100644 index 0000000..d8c6848 --- /dev/null +++ b/src/hub/web_ui.py @@ -0,0 +1,1883 @@ +""" +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 +try: + from src.version import LOGAR_VERSION, is_newer_version +except ImportError: + from version import LOGAR_VERSION, is_newer_version + + +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", {}) + + update_mgr = state.get("update_manager") + update_status = update_mgr.get_status() if update_mgr else {} + hosted_client_ver = update_status.get("hosted_client_version") or LOGAR_VERSION + hosted_packages = update_status.get("hosted_packages", {}) + + # Fetch clients safely + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date') FROM clients ORDER BY last_seen DESC") + clients = c.fetchall() + except sqlite3.OperationalError: + c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") + clients = [r + ("", "up_to_date") for r in c.fetchall()] + + # 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, version badges, and seat management actions + client_rows = [] + for cl in clients: + cid, host, ost, fp, st, first, last, cver, up_st = cl[:9] + 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)}
" + + # Client Version Display + if not cver: + ver_display = f'v{LOGAR_VERSION}' + elif is_newer_version(hosted_client_ver, cver): + ver_display = f'v{html.escape(cver)} (Outdated)' + else: + ver_display = f'v{html.escape(cver)}' + + 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()} + {ver_display} + {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.
+
+
+
+ +
+
+ """ + + server_update_banner_html = "" + update_tab_badge = "" + if update_status.get("server_update_available"): + up_ver = html.escape(str(update_status.get("latest_upstream_version", ""))) + update_tab_badge = ' NEW' + server_update_banner_html = f""" +
+
+
🔔
+
+
LOGAR Server Update Available: v{up_ver} (Current: v{LOGAR_VERSION})
+
A newer release of LOGAR is available. Deployed server packages automatically upgrade edge clients during their next forwarder check.
+
+
+
+ +
+
+ """ + + 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 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+
🌐 Server Version & Upstream Release Status
+
Automatic upstream release checking via Gitea production channels
+
+ {('UPDATE AVAILABLE' if update_status.get("server_update_available") else 'UP TO DATE')} +
+
+
+
Current Server Version
+
v{LOGAR_VERSION}
+
+
+
Latest Upstream Release
+
v{update_status.get('latest_upstream_version') or LOGAR_VERSION}
+
+
+
Last Checked
+
{update_status.get('last_checked_utc') or 'Never'}
+
+
+
+ + {(f'🌐 View Release on Gitea' if update_status.get("release_url") else '')} +
+
+ +
+
+
+
📦 Client Package Distribution Repository
+
Hosted binaries for edge forwarders to automatically download and hot-upgrade
+
+ Target: v{hosted_client_ver} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Target OSPackage FileVersionSizeSHA-256 ChecksumStatusActions
WINDOWS{html.escape(hosted_packages.get('windows', {}).get('filename') or 'LOGAR_Client_Setup.exe')}v{html.escape(str(hosted_packages.get('windows', {}).get('version') or hosted_client_ver))}{((hosted_packages.get('windows', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('windows', {}).get('exists') else 0)} KB{(hosted_packages.get('windows', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('windows', {}).get('sha256') else 'N/A'}{('Available' if hosted_packages.get('windows', {}).get('exists') else 'Not Uploaded')} + 📥 Download +
LINUX{html.escape(hosted_packages.get('linux', {}).get('filename') or 'Linux_Client.bin')}v{html.escape(str(hosted_packages.get('linux', {}).get('version') or hosted_client_ver))}{((hosted_packages.get('linux', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('linux', {}).get('exists') else 0)} KB{(hosted_packages.get('linux', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('linux', {}).get('sha256') else 'N/A'}{('Available' if hosted_packages.get('linux', {}).get('exists') else 'Not Uploaded')} + 📥 Download +
+
+ +
+
📤 Upload Client Binary Package
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ """ + + 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} + {server_update_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 IdentifierOSVersionCert 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 diff --git a/src/ingest_queue.py b/src/ingest_queue.py index 9abddb1..bb551f9 100644 --- a/src/ingest_queue.py +++ b/src/ingest_queue.py @@ -1,114 +1 @@ -""" -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) - } +from src.hub.ingest_queue import * \ No newline at end of file diff --git a/src/license_manager.py b/src/license_manager.py index 7f03513..0abc8f2 100644 --- a/src/license_manager.py +++ b/src/license_manager.py @@ -1,260 +1 @@ -""" -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 verified X.509 certificate extensions or the persistent database license config. - """ - features = set() - state = server_state or {} - cert_dir = state.get("config", {}).get("cert_dir", "certs") - upstream_pem = None - upstream_file = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_file): - try: - with open(upstream_file, "r", encoding="utf-8") as f: - upstream_pem = f.read() - except Exception: - pass - - # 1. Check in-memory / active Subordinate CA certificate - ca_pem = state.get("ca_cert_pem") - if not ca_pem: - 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 - - cert_is_verified = False - if ca_pem: - try: - info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem) - cert_is_verified = info.get("signature_verified", False) or not info.get("is_self_signed", False) - 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, verifies cryptographic signatures, 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") - upstream_path = os.path.join(cert_dir, "upstream_ca.crt") - target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE) - - upstream_pem = None - if os.path.exists(upstream_path): - try: - with open(upstream_path, "r", encoding="utf-8") as f: - upstream_pem = f.read().strip() - except Exception: - pass - - db_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: - db_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) - - cert_seats = None - 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, upstream_ca_pem=upstream_pem) - if info.get("seats") is not None: - cert_seats = info["seats"] - except Exception: - info = {"valid": False} - else: - info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0} - - # Anti-tampering reconciliation: If database max_seats diverged from certified quota, enforce certified quota and update DB - if cert_seats is not None: - max_seats = cert_seats - if db_max_seats != cert_seats and os.path.exists(target_db): - try: - conn = sqlite3.connect(target_db) - conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (cert_seats,)) - conn.commit() - conn.close() - except Exception: - pass - else: - max_seats = db_max_seats - - 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), - "signature_verified": info.get("signature_verified", 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() +from src.hub.license_manager import * \ No newline at end of file diff --git a/src/log_collector.py b/src/log_collector.py index 47f3e98..37137ad 100644 --- a/src/log_collector.py +++ b/src/log_collector.py @@ -1,679 +1 @@ -""" -LOGAR - Unified Cross-Platform Log Collector & State Management -Provides modular log collection classes and state management for Windows and Linux, -reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux). -""" - -import os -import sys -import json -import socket -import shutil -import ctypes -import subprocess -import warnings -from abc import ABC, abstractmethod -from datetime import datetime, timezone, timedelta -from typing import Optional, List, Dict, Any, Set - -# Suppress cryptography / pgpy deprecation notices -warnings.filterwarnings("ignore") - -try: - import win32evtlog -except ImportError: - win32evtlog = None - -CONFIG_FILE_NAME = "client_config.json" -STATE_FILE_NAME = "client_state.json" - - -def collect_system_metrics() -> Dict[str, Any]: - """ - Collects cross-platform host health and resource telemetry without requiring external wheels. - Returns: - { - "cpu_percent": float, - "memory_percent": float, - "memory_total_mb": int, - "memory_free_mb": int, - "disk_percent_free": float, - "disk_total_gb": float, - "disk_free_gb": float - } - """ - metrics = { - "cpu_percent": 0.0, - "memory_percent": 0.0, - "memory_total_mb": 0, - "memory_free_mb": 0, - "disk_percent_free": 0.0, - "disk_total_gb": 0.0, - "disk_free_gb": 0.0 - } - # 1. Disk metrics - try: - path = "C:\\" if sys.platform == "win32" else "/" - total, used, free = shutil.disk_usage(path) - metrics["disk_total_gb"] = round(total / (1024 ** 3), 2) - metrics["disk_free_gb"] = round(free / (1024 ** 3), 2) - if total > 0: - metrics["disk_percent_free"] = round((free / total) * 100, 1) - except Exception: - pass - - # 2. Memory metrics - try: - if sys.platform == "win32": - class MEMORYSTATUSEX(ctypes.Structure): - _fields_ = [ - ("dwLength", ctypes.c_ulong), - ("dwMemoryLoad", ctypes.c_ulong), - ("ullTotalPhys", ctypes.c_ulonglong), - ("ullAvailPhys", ctypes.c_ulonglong), - ("ullTotalPageFile", ctypes.c_ulonglong), - ("ullAvailPageFile", ctypes.c_ulonglong), - ("ullTotalVirtual", ctypes.c_ulonglong), - ("ullAvailVirtual", ctypes.c_ulonglong), - ("sullAvailExtendedVirtual", ctypes.c_ulonglong), - ] - stat = MEMORYSTATUSEX() - stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX) - if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)): - metrics["memory_percent"] = float(stat.dwMemoryLoad) - metrics["memory_total_mb"] = int(stat.ullTotalPhys / (1024 * 1024)) - metrics["memory_free_mb"] = int(stat.ullAvailPhys / (1024 * 1024)) - else: - # Linux: read /proc/meminfo - if os.path.exists("/proc/meminfo"): - mem_total = 0 - mem_avail = 0 - with open("/proc/meminfo", "r", encoding="utf-8") as f: - for line in f: - parts = line.split(":") - if len(parts) == 2: - k, v = parts[0].strip(), parts[1].strip().split()[0] - if k == "MemTotal": - mem_total = int(v) - elif k in ("MemAvailable", "MemFree") and mem_avail == 0: - mem_avail = int(v) - if mem_total > 0: - metrics["memory_total_mb"] = mem_total // 1024 - metrics["memory_free_mb"] = mem_avail // 1024 - metrics["memory_percent"] = round(((mem_total - mem_avail) / mem_total) * 100, 1) - except Exception: - pass - - # 3. CPU metrics - try: - if hasattr(os, "getloadavg"): - load1, _, _ = os.getloadavg() - cpu_count = os.cpu_count() or 1 - metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1) - else: - try: - import psutil - metrics["cpu_percent"] = float(psutil.cpu_percent(interval=None)) - except ImportError: - metrics["cpu_percent"] = 0.0 - except Exception: - pass - - return metrics - - -def is_routine_service_noise(signature: str, message: str, severity: str) -> bool: - """ - Identifies high-frequency benign background service start/stop cycles - (e.g., sppsvc, edgeupdate, trustedinstaller, wuauserv, backgroundTaskHost) - to prevent false-positive anomaly fatigue and consecutive_run metric bloat. - """ - if str(severity).upper() != "INFO": - return False - sig_low = (signature or "").lower() - msg_low = (message or "").lower() - routine_services = [ - "sppsvc", - "edgeupdate", - "trustedinstaller", - "wuauserv", - "backgroundtaskhost", - "service control manager", - "systemd", - "cron" - ] - is_routine_svc = any(s in sig_low or s in msg_low for s in routine_services) - if is_routine_svc: - lifecycle_phrases = [ - "entered the running state", - "entered the stopped state", - "entered the start pending state", - "entered the stop pending state", - "started successfully", - "stopped successfully", - "service stopped", - "service started", - "session opened", - "session closed", - "clean", - "reloaded" - ] - if any(p in msg_low for p in lifecycle_phrases): - return True - return False - - -def get_machine_identifier() -> str: - """ - Returns the fully qualified hostname of the machine sending/collecting logs. - Cross-platform resolution: - 1. FQDN (if non-local) - 2. Windows USERDNSDOMAIN (on Windows) - 3. Linux /etc/resolv.conf domain/search directive (on Linux) - 4. Reverse DNS lookup - 5. socket.gethostname() fallback - """ - fqdn = socket.getfqdn() - if fqdn and "." in fqdn and not fqdn.startswith("localhost"): - return fqdn - - hostname = socket.gethostname() - - # Windows DNS domain - if sys.platform == "win32": - user_dns_domain = os.environ.get("USERDNSDOMAIN") - if user_dns_domain and user_dns_domain.lower() != hostname.lower(): - return f"{hostname}.{user_dns_domain.lower()}" - - # Linux resolv.conf search / domain - if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"): - try: - with open("/etc/resolv.conf", "r", encoding="utf-8") as f: - for line in f: - parts = line.strip().split() - if parts and parts[0] in ["domain", "search"] and len(parts) > 1: - domain = parts[1] - if domain and not domain.startswith("."): - return f"{hostname}.{domain}" - except Exception: - pass - - # Reverse DNS lookup - try: - host_ip = socket.gethostbyname(hostname) - canonical_name = socket.gethostbyaddr(host_ip)[0] - if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"): - return canonical_name - except Exception: - pass - - return hostname - - -def is_path_writable(filepath: str) -> bool: - """Checks if a file or directory path is actually writable by the current process.""" - try: - if os.path.exists(filepath): - if os.path.isdir(filepath): - test_f = os.path.join(filepath, f".perm_{os.getpid()}") - with open(test_f, "w") as f: - f.write("1") - os.remove(test_f) - return True - else: - with open(filepath, "a") as f: - pass - return True - else: - parent = os.path.dirname(os.path.abspath(filepath)) or "." - os.makedirs(parent, exist_ok=True) - test_f = os.path.join(parent, f".perm_{os.getpid()}") - with open(test_f, "w") as f: - f.write("1") - os.remove(test_f) - return True - except Exception: - return False - - -def get_default_data_dir(app_name: str = "LOGAR") -> str: - """Returns a suitable writable application data directory.""" - if sys.platform == "win32": - # 1. ProgramData (C:\ProgramData\LOGAR) - 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) - 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) - temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name) - if is_path_writable(temp_dir): - return temp_dir - else: - # Linux / Unix candidates: /var/lib/, /tmp/, ~/.local/share/ - var_lib = f"/var/lib/{app_name.lower()}" - if is_path_writable(var_lib): - return var_lib - user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}") - if is_path_writable(user_share): - return user_share - - cwd = os.getcwd() - if is_path_writable(cwd): - return cwd - return os.path.expanduser("~") - - -def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str: - """Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory.""" - if not path: - path = default_filename - - if os.path.isabs(path) and is_path_writable(path): - return path - - if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()): - return os.path.abspath(path) - - data_dir = get_default_data_dir(app_name) - fname = os.path.basename(path) - return os.path.join(data_dir, fname) - - -def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str: - if custom_state_path: - return custom_state_path - config_dir = os.path.dirname(os.path.abspath(config_path)) - if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)): - return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME) - return os.path.join(config_dir, STATE_FILE_NAME) - - -def load_state(state_path: str) -> dict: - """Loads state tracking dictionary from disk.""" - if os.path.exists(state_path): - try: - with open(state_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception as e: - print(f"[!] Warning: Failed to read state file '{state_path}': {e}") - return {} - return {} - - -def save_state(state_path: str, state: dict): - """Atomically saves state dictionary to disk.""" - try: - os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True) - temp_path = f"{state_path}.tmp" - with open(temp_path, "w", encoding="utf-8") as f: - json.dump(state, f, indent=2) - os.replace(temp_path, state_path) - except Exception as e: - print(f"[!] Warning: Could not save client state to '{state_path}': {e}") - - -def commit_state(state: dict, state_path: str): - """ - Commits staged cursor/record state keys to persistent fields and writes to state_path. - Supports both Windows record numbers and Linux cursors/timestamps. - """ - if "new_last_record_number" in state: - val = state.pop("new_last_record_number") - if val: - state["last_record_number"] = val - if "new_sent_record_ids" in state: - state["sent_record_ids"] = state.pop("new_sent_record_ids") - if "new_last_cursor" in state: - val = state.pop("new_last_cursor") - if val: - state["last_cursor"] = val - if "new_last_timestamp_us" in state: - val = state.pop("new_last_timestamp_us") - if val: - state["last_timestamp_us"] = val - if "new_sent_cursors" in state: - state["sent_cursors"] = state.pop("new_sent_cursors") - save_state(state_path, state) - - -class BaseLogCollector(ABC): - """ - Abstract base class for platform-specific edge and hub log collectors. - Manages state persistence, machine identifiers, lookback windows, and normalization. - """ - - def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None): - self.state = state if state is not None else {} - self.machine_id = machine_id or get_machine_identifier() - - @abstractmethod - def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: - """ - Collects normalized logs within the given lookback window. - Returns a list of dicts: - [ - { - "server": str, - "os_type": "windows"|"linux", - "signature": str, - "severity": "ERROR"|"WARNING"|"INFO", - "message": str - }, - ... - ] - """ - pass - - def commit(self, state_path: Optional[str] = None): - """Commits staged state keys and optionally writes to disk.""" - if state_path: - commit_state(self.state, state_path) - else: - if "new_last_record_number" in self.state: - val = self.state.pop("new_last_record_number") - if val: - self.state["last_record_number"] = val - if "new_sent_record_ids" in self.state: - self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids") - if "new_last_cursor" in self.state: - val = self.state.pop("new_last_cursor") - if val: - self.state["last_cursor"] = val - if "new_last_timestamp_us" in self.state: - val = self.state.pop("new_last_timestamp_us") - if val: - self.state["last_timestamp_us"] = val - if "new_sent_cursors" in self.state: - self.state["sent_cursors"] = self.state.pop("new_sent_cursors") - - -class WindowsLogCollector(BaseLogCollector): - """ - Windows Event Log Collector. - Reads Application and System (or custom channels) backwards. - Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise. - State Tracking: Skips events older than lookback window and already sent records. - """ - - def __init__( - self, - state: Optional[dict] = None, - machine_id: Optional[str] = None, - channels: Optional[List[str]] = None - ): - super().__init__(state=state, machine_id=machine_id) - self.channels = channels or ["Application"] - - def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: - if win32evtlog is None: - return [] - - logs = [] - cutoff_time = datetime.now() - timedelta(hours=hours) - - last_record_number = 0 - sent_record_ids: Set[str] = set() - if self.state: - last_record_number = int(self.state.get("last_record_number", 0)) - sent_record_ids = set(self.state.get("sent_record_ids", [])) - - # Windows Event Log EventTypes: - # 1: EVENTLOG_ERROR_TYPE -> ERROR - # 2: EVENTLOG_WARNING_TYPE -> WARNING - # 4: EVENTLOG_INFORMATION_TYPE -> INFO - sev_map = { - 1: "ERROR", - 2: "WARNING", - 4: "INFO" - } - - newest_record_number = 0 - collected_record_ids = [] - - flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ - - for log_type in self.channels: - try: - hand = win32evtlog.OpenEventLog("localhost", log_type) - except Exception as e: - continue - - while True: - try: - events = win32evtlog.ReadEventLog(hand, flags, 0) - except Exception: - break - if not events: - break - - for event in events: - rec_num = int(event.RecordNumber) - if newest_record_number == 0: - newest_record_number = rec_num - - # 1. Skip entries older than lookback window - if event.TimeGenerated < cutoff_time: - break - - # 2. Skip already sent events - if last_record_number > 0 and newest_record_number >= last_record_number: - if rec_num <= last_record_number: - break - - rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}" - if rec_id in sent_record_ids: - continue - - # Filter: upload everything from INFO to ERROR - if event.EventType in sev_map: - msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry" - sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event") - logs.append({ - "server": self.machine_id, - "os_type": "windows", - "signature": sig, - "severity": sev_map[event.EventType], - "message": msg[:2048] - }) - collected_record_ids.append(rec_id) - - if events[-1].TimeGenerated < cutoff_time: - break - if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number: - break - - try: - win32evtlog.CloseEventLog(hand) - except Exception: - pass - - if self.state is not None: - target_rec = max(newest_record_number, last_record_number) - self.state["new_last_record_number"] = target_rec - self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:] - # Also update direct keys for immediate server-side ingestion - self.state["last_record_number"] = target_rec - self.state["sent_record_ids"] = self.state["new_sent_record_ids"] - self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat() - - return logs - - -class LinuxLogCollector(BaseLogCollector): - """ - Linux Systemd Journal / Syslog Log Collector. - Collects info, warnings, and errors from journalctl (or syslog fallback). - Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise. - State Tracking: Skips events older than lookback window and already sent cursors. - """ - - def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None): - super().__init__(state=state, machine_id=machine_id) - - def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]: - last_cursor = None - last_timestamp_us = 0.0 - sent_cursors: Set[str] = set() - if self.state: - last_cursor = self.state.get("last_cursor") - try: - last_timestamp_us = float(self.state.get("last_timestamp_us", 0)) - except (ValueError, TypeError): - last_timestamp_us = 0.0 - sent_cursors = set(self.state.get("sent_cursors", [])) - - cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"] - result = None - - if last_cursor: - cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"] - try: - res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False) - if res.returncode == 0: - result = res - except Exception: - pass - - if result is None: - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=False) - except FileNotFoundError: - # journalctl not present, attempt fallback to /var/log/syslog or messages - return self._collect_syslog_fallback(hours=hours) - except Exception as e: - print(f"[!] Error running journalctl: {e}") - return [] - - if not result or result.returncode != 0: - return [] - - logs = [] - cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000 - - newest_cursor = None - newest_timestamp_us = last_timestamp_us - collected_cursors = [] - - for line in result.stdout.splitlines(): - line_str = line.strip() - if not line_str: - continue - try: - entry = json.loads(line_str) - entry_cursor = entry.get("__CURSOR") - entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP") - - entry_ts_us = 0.0 - if entry_ts_us_raw: - try: - entry_ts_us = float(entry_ts_us_raw) - except (ValueError, TypeError): - pass - - # 1. Skip entries older than lookback window - if entry_ts_us and entry_ts_us < cutoff_epoch_us: - continue - - # 2. Skip already sent events - if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor): - continue - if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us: - continue - - # Advance tracking - if entry_cursor: - newest_cursor = entry_cursor - collected_cursors.append(entry_cursor) - if entry_ts_us > newest_timestamp_us: - newest_timestamp_us = entry_ts_us - - priority = int(entry.get("PRIORITY", "6")) - if priority > 6: - continue - - if priority <= 3: - sev = "ERROR" - elif priority in (4, 5): - sev = "WARNING" - else: - sev = "INFO" - - logs.append({ - "server": self.machine_id, - "os_type": "linux", - "signature": entry.get("SYSLOG_IDENTIFIER", "unknown"), - "severity": sev, - "message": entry.get("MESSAGE", "")[:2048] - }) - except (json.JSONDecodeError, ValueError): - continue - - if self.state is not None: - self.state["new_last_cursor"] = newest_cursor or last_cursor - self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us) - self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:] - # Also update direct keys for immediate server-side ingestion - self.state["last_cursor"] = self.state["new_last_cursor"] - self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"] - self.state["sent_cursors"] = self.state["new_sent_cursors"] - self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat() - - return logs - - def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]: - """Fallback log reader for non-systemd Linux environments.""" - candidates = ["/var/log/syslog", "/var/log/messages"] - target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None) - if not target: - return [] - - logs = [] - try: - with open(target, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines()[-500:] # Read recent 500 lines - for line in lines: - line_str = line.strip() - if not line_str: - continue - sev = "INFO" - lower_l = line_str.lower() - if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]): - sev = "ERROR" - elif any(w in lower_l for w in ["warn", "notice"]): - sev = "WARNING" - - logs.append({ - "server": self.machine_id, - "os_type": "linux", - "signature": "syslog", - "severity": sev, - "message": line_str[:2048] - }) - except Exception: - pass - return logs - - -def get_log_collector( - os_type: Optional[str] = None, - state: Optional[dict] = None, - machine_id: Optional[str] = None, - channels: Optional[List[str]] = None -) -> BaseLogCollector: - """ - Factory helper that returns the appropriate BaseLogCollector instance. - If os_type is omitted, auto-detects based on sys.platform. - """ - target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux") - if target_os == "windows": - chans = channels or (["Application", "System"] if channels is None else ["Application"]) - return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans) - else: - return LinuxLogCollector(state=state, machine_id=machine_id) +from src.clients.log_collector import * \ No newline at end of file diff --git a/src/pki_manager.py b/src/pki_manager.py index d744454..12684a9 100644 --- a/src/pki_manager.py +++ b/src/pki_manager.py @@ -1,141 +1 @@ -""" -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 +from src.hub.pki_manager import * \ No newline at end of file diff --git a/src/server_enrollment.py b/src/server_enrollment.py index 202af53..c7d2c38 100644 --- a/src/server_enrollment.py +++ b/src/server_enrollment.py @@ -1,1070 +1 @@ -import os -import json -import ssl -import datetime -import ipaddress -import urllib.request -import urllib.error -import urllib.parse -import http.client -from typing import Tuple, List, Optional, Dict, Any -from cryptography import x509 -from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa - -# Custom Enterprise X.509 OIDs for LOGAR Licensing -OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1") -OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2") -OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3") -OID_LOGAR_FEATURES = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.4") - - - -def calculate_cert_fingerprint(cert_pem: str) -> str: - """Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate.""" - cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) - return cert.fingerprint(hashes.SHA256()).hex().upper() - - -def is_cert_expiring_soon(cert_pem: str, threshold_days: int = 30) -> bool: - """ - Checks if a PEM-encoded X.509 certificate expires within `threshold_days` (or is already expired). - Returns True if expiring soon or expired, False otherwise. - """ - try: - cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) - expiry = getattr(cert, "not_valid_after_utc", None) - if expiry is None: - expiry = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) - now = datetime.datetime.now(datetime.timezone.utc) - return expiry <= (now + datetime.timedelta(days=threshold_days)) - except Exception: - return True - - -def generate_ca_if_needed( - cert_dir: str = "certs", - common_name: str = "LOGAR-Root-CA", - force_renew: bool = False, - threshold_days: int = 30 -) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]: - """ - Loads an existing Root CA or generates a self-signed Root CA certificate and private key. - If existing CA cert is expiring within threshold_days (or force_renew is True), regenerates it. - Returns (ca_cert_obj, ca_key_obj, ca_cert_pem, ca_key_pem). - """ - os.makedirs(cert_dir, exist_ok=True) - ca_cert_path = os.path.join(cert_dir, "ca.crt") - ca_key_path = os.path.join(cert_dir, "ca.key") - - if not force_renew and os.path.exists(ca_cert_path) and os.path.exists(ca_key_path): - with open(ca_cert_path, "r", encoding="utf-8") as f: - ca_cert_pem = f.read() - with open(ca_key_path, "r", encoding="utf-8") as f: - ca_key_pem = f.read() - try: - ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode("utf-8")) - ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None) - if not is_cert_expiring_soon(ca_cert_pem, threshold_days=threshold_days): - return ca_cert, ca_key, ca_cert_pem, ca_key_pem - except Exception: - pass - - # Create timestamped backup of previous CA if present - if os.path.exists(ca_cert_path): - try: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") - os.replace(ca_cert_path, f"{ca_cert_path}.{timestamp}.bak") - if os.path.exists(ca_key_path): - os.replace(ca_key_path, f"{ca_key_path}.{timestamp}.bak") - except Exception: - pass - - # Generate RSA 4096 private key for Root CA - ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), - x509.NameAttribute(NameOID.COMMON_NAME, common_name), - ]) - - now = datetime.datetime.now(datetime.timezone.utc) - ca_cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=5)) - .not_valid_after(now + datetime.timedelta(days=3650)) - .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), - critical=False - ) - .sign(ca_key, hashes.SHA256()) - ) - - ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") - ca_key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode("utf-8") - - with open(ca_cert_path, "w", encoding="utf-8") as f: - f.write(ca_cert_pem) - with open(ca_key_path, "w", encoding="utf-8") as f: - f.write(ca_key_pem) - - try: - os.chmod(ca_key_path, 0o600) - except Exception: - pass - - return ca_cert, ca_key, ca_cert_pem, ca_key_pem - - -def generate_server_cert_if_needed( - ca_cert: x509.Certificate, - ca_key: rsa.RSAPrivateKey, - hostnames: Optional[List[str]] = None, - cert_dir: str = "certs", - days_valid: int = 825, - force_renew: bool = False, - threshold_days: int = 30 -) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]: - """ - Loads an existing server certificate or generates a new server TLS certificate signed by the Root CA. - If existing server cert is expiring within threshold_days (or force_renew is True), regenerates it. - Includes SANs for localhost, 127.0.0.1, and specified hostnames. - """ - os.makedirs(cert_dir, exist_ok=True) - server_cert_path = os.path.join(cert_dir, "server.crt") - server_key_path = os.path.join(cert_dir, "server.key") - - if not force_renew and os.path.exists(server_cert_path) and os.path.exists(server_key_path): - with open(server_cert_path, "r", encoding="utf-8") as f: - server_cert_pem = f.read() - with open(server_key_path, "r", encoding="utf-8") as f: - server_key_pem = f.read() - try: - srv_cert = x509.load_pem_x509_certificate(server_cert_pem.encode("utf-8")) - srv_key = serialization.load_pem_private_key(server_key_pem.encode("utf-8"), password=None) - if not is_cert_expiring_soon(server_cert_pem, threshold_days=threshold_days): - return srv_cert, srv_key, server_cert_pem, server_key_pem - except Exception: - pass - - # Create timestamped backup of previous server cert if present - if os.path.exists(server_cert_path): - try: - timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") - os.replace(server_cert_path, f"{server_cert_path}.{timestamp}.bak") - if os.path.exists(server_key_path): - os.replace(server_key_path, f"{server_key_path}.{timestamp}.bak") - except Exception: - pass - - server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), - x509.NameAttribute(NameOID.COMMON_NAME, "LOGAR-Server-Hub"), - ]) - - san_list = [ - x509.DNSName("localhost"), - x509.DNSName("LOGAR-Server-Hub"), - x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), - x509.IPAddress(ipaddress.IPv6Address("::1")), - ] - - if hostnames: - for host in hostnames: - if not host: - continue - try: - ip_obj = ipaddress.ip_address(host) - san_list.append(x509.IPAddress(ip_obj)) - except ValueError: - san_list.append(x509.DNSName(host)) - - now = datetime.datetime.now(datetime.timezone.utc) - server_cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(ca_cert.subject) - .public_key(server_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=5)) - .not_valid_after(now + datetime.timedelta(days=days_valid)) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_cert_sign=False, - crl_sign=False, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), - critical=False - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), - critical=False - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False - ) - .add_extension(x509.SubjectAlternativeName(san_list), critical=False) - .sign(ca_key, hashes.SHA256()) - ) - - server_cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") - server_key_pem = server_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode("utf-8") - - # Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain - full_server_cert_pem = server_cert_pem - ca_cert_path = os.path.join(cert_dir, "ca.crt") - if os.path.exists(ca_cert_path): - with open(ca_cert_path, "r", encoding="utf-8") as f: - ca_pem = f.read().strip() - if ca_pem and ca_pem not in full_server_cert_pem: - full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem - - upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_ca_path): - with open(upstream_ca_path, "r", encoding="utf-8") as f: - upstream_pem = f.read().strip() - if upstream_pem and upstream_pem not in full_server_cert_pem: - full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem - - with open(server_cert_path, "w", encoding="utf-8") as f: - f.write(full_server_cert_pem) - with open(server_key_path, "w", encoding="utf-8") as f: - f.write(server_key_pem) - - try: - os.chmod(server_key_path, 0o600) - except Exception: - pass - - return server_cert, server_key, server_cert_pem, server_key_pem - - -def issue_client_cert( - client_id: str, - ca_cert: x509.Certificate, - ca_key: rsa.RSAPrivateKey, - days_valid: int = 365 -) -> Tuple[str, str]: - """ - Generates a 2048-bit RSA private key and signs an X.509 client certificate - with Common Name set to client_id. - Returns (cert_pem, key_pem). - """ - client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - - subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"), - x509.NameAttribute(NameOID.COMMON_NAME, client_id), - ]) - - now = datetime.datetime.now(datetime.timezone.utc) - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(ca_cert.subject) - .public_key(client_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=5)) - .not_valid_after(now + datetime.timedelta(days=days_valid)) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - key_cert_sign=False, - crl_sign=False, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=False - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()), - critical=False - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False - ) - .sign(ca_key, hashes.SHA256()) - ) - - cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") - key_pem = client_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode("utf-8") - - return cert_pem, key_pem - - -def check_and_renew_hub_pki( - cert_dir: str = "certs", - hostnames: Optional[List[str]] = None, - threshold_days: int = 30 -) -> Tuple[bool, bool]: - """ - Evaluates expiration status of Root CA and Server TLS certificates. - If CA certificate is expiring within threshold_days (or missing): - - Regenerates Root CA. - - Automatically regenerates Server TLS certificate (since CA issuer changed). - - Returns (ca_renewed=True, server_renewed=True) - Else if Server TLS certificate is expiring within threshold_days (or missing): - - Regenerates Server TLS certificate signed by existing Root CA. - - Returns (ca_renewed=False, server_renewed=True) - Otherwise: - - Returns (False, False) - """ - os.makedirs(cert_dir, exist_ok=True) - ca_cert_path = os.path.join(cert_dir, "ca.crt") - server_cert_path = os.path.join(cert_dir, "server.crt") - - renew_ca = False - renew_server = False - - if not os.path.exists(ca_cert_path): - renew_ca = True - else: - try: - with open(ca_cert_path, "r", encoding="utf-8") as f: - ca_pem = f.read() - if is_cert_expiring_soon(ca_pem, threshold_days=threshold_days): - renew_ca = True - except Exception: - renew_ca = True - - if renew_ca: - ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=True) - generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True) - return True, True - - if not os.path.exists(server_cert_path): - renew_server = True - else: - try: - with open(server_cert_path, "r", encoding="utf-8") as f: - srv_pem = f.read() - if is_cert_expiring_soon(srv_pem, threshold_days=threshold_days): - renew_server = True - except Exception: - renew_server = True - - if renew_server: - ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=False) - generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True) - return False, True - - return False, False - - -def verify_certificate_signature(cert_pem: Any, issuer_ca_pem: Any) -> bool: - """ - Cryptographically verifies that cert_pem was signed by the private key corresponding to issuer_ca_pem. - """ - try: - from cryptography.hazmat.primitives.asymmetric import padding - cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) if isinstance(cert_pem, str) else cert_pem - issuer = x509.load_pem_x509_certificate(issuer_ca_pem.encode("utf-8")) if isinstance(issuer_ca_pem, str) else issuer_ca_pem - issuer.public_key().verify( - cert.signature, - cert.tbs_certificate_bytes, - padding.PKCS1v15(), - cert.signature_hash_algorithm - ) - return True - except Exception: - return False - - -def extract_license_info_from_cert(cert_pem: str, upstream_ca_pem: Optional[str] = None) -> Dict[str, Any]: - """ - Extracts embedded licensing attributes, validity, and metadata from a PEM X.509 certificate. - Supports decoding seat quota and license IDs embedded via LOGAR custom enterprise OIDs. - Cryptographically verifies the signature if upstream_ca_pem is provided. - """ - try: - cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) - except Exception as e: - return {"valid": False, "error": f"Invalid certificate format: {e}"} - - seats = None - license_id = None - issuer_url = None - - try: - ext_seats = cert.extensions.get_extension_for_oid(OID_LOGAR_SEATS) - if ext_seats and ext_seats.value: - raw_val = ext_seats.value.value if hasattr(ext_seats.value, "value") else str(ext_seats.value) - if isinstance(raw_val, bytes): - seats = int(raw_val.decode("utf-8").strip()) - else: - seats = int(str(raw_val).strip()) - except (x509.ExtensionNotFound, ValueError, Exception): - seats = None - - try: - ext_lic = cert.extensions.get_extension_for_oid(OID_LOGAR_LICENSE_ID) - if ext_lic and ext_lic.value: - raw_val = ext_lic.value.value if hasattr(ext_lic.value, "value") else str(ext_lic.value) - license_id = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() - except (x509.ExtensionNotFound, Exception): - license_id = None - - try: - ext_url = cert.extensions.get_extension_for_oid(OID_LOGAR_ISSUER_URL) - if ext_url and ext_url.value: - raw_val = ext_url.value.value if hasattr(ext_url.value, "value") else str(ext_url.value) - issuer_url = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() - except (x509.ExtensionNotFound, Exception): - issuer_url = None - - features = [] - try: - ext_feat = cert.extensions.get_extension_for_oid(OID_LOGAR_FEATURES) - if ext_feat and ext_feat.value: - raw_val = ext_feat.value.value if hasattr(ext_feat.value, "value") else str(ext_feat.value) - feat_str = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip() - if feat_str.startswith("["): - features = json.loads(feat_str) - else: - features = [f.strip() for f in feat_str.split(",") if f.strip()] - except Exception: - features = [] - - not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) - not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) - now = datetime.datetime.now(datetime.timezone.utc) - - is_expired = now > not_after - days_remaining = max(0, (not_after - now).days) if not is_expired else 0 - - is_ca = False - try: - bc_ext = cert.extensions.get_extension_for_oid(x509.OID_BASIC_CONSTRAINTS) - if bc_ext and bc_ext.value: - is_ca = bc_ext.value.ca - except Exception: - pass - - subject_cn = "" - for attr in cert.subject: - if attr.oid == NameOID.COMMON_NAME: - subject_cn = attr.value - break - - issuer_cn = "" - for attr in cert.issuer: - if attr.oid == NameOID.COMMON_NAME: - issuer_cn = attr.value - break - - is_self_signed = (cert.subject == cert.issuer) - signature_verified = False - - if upstream_ca_pem: - signature_verified = verify_certificate_signature(cert, upstream_ca_pem) - elif is_self_signed: - signature_verified = verify_certificate_signature(cert, cert) - - return { - "valid": True, - "seats": seats, - "license_id": license_id, - "issuer_url": issuer_url, - "features": features, - "subject_cn": subject_cn, - "issuer_cn": issuer_cn, - "is_ca": is_ca, - "is_self_signed": is_self_signed, - "signature_verified": signature_verified, - "fingerprint": cert.fingerprint(hashes.SHA256()).hex().upper(), - "not_valid_before": not_before.isoformat(), - "not_valid_after": not_after.isoformat(), - "is_expired": is_expired, - "days_remaining": days_remaining - } - - -def generate_ca_key_if_needed(cert_dir: str = "certs") -> Tuple[rsa.RSAPrivateKey, str]: - """Ensures ca.key exists in cert_dir, generating a 4096-bit RSA key if not.""" - os.makedirs(cert_dir, exist_ok=True) - ca_key_path = os.path.join(cert_dir, "ca.key") - if os.path.exists(ca_key_path): - with open(ca_key_path, "r", encoding="utf-8") as f: - key_pem = f.read() - try: - ca_key = serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None) - return ca_key, key_pem - except Exception: - pass - - ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) - key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode("utf-8") - with open(ca_key_path, "w", encoding="utf-8") as f: - f.write(key_pem) - return ca_key, key_pem - - -def generate_ca_csr( - cert_dir: str = "certs", - common_name: str = "LOGAR-Sub-CA", - organization: str = "LOGAR", - country: str = "AT" -) -> Tuple[str, str]: - """ - Generates a Certificate Signing Request (CSR) for a subordinate CA using ca.key. - Returns (csr_pem, ca_key_pem). - """ - ca_key, key_pem = generate_ca_key_if_needed(cert_dir) - subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, country), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization), - x509.NameAttribute(NameOID.COMMON_NAME, common_name), - ]) - - csr = ( - x509.CertificateSigningRequestBuilder() - .subject_name(subject) - .add_extension( - x509.BasicConstraints(ca=True, path_length=0), - critical=True - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .sign(ca_key, hashes.SHA256()) - ) - - csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") - csr_path = os.path.join(cert_dir, "ca.csr") - with open(csr_path, "w", encoding="utf-8") as f: - f.write(csr_pem) - - return csr_pem, key_pem - - -def create_license_request_lic( - cert_dir: str = "certs", - license_server: str = "https://logar.eibl.tech", - server_fingerprint: str = "", - hostname: str = "", - requested_seats: int = 10, - common_name: str = "LOGAR-Sub-CA" -) -> Tuple[Dict[str, Any], str]: - """ - Packages a CA CSR and server metadata into an exportable .lic file dictionary and JSON string. - """ - csr_pem, _ = generate_ca_csr(cert_dir=cert_dir, common_name=common_name) - lic_data = { - "format": "LOGAR_LICENSE_REQUEST", - "version": "1.0", - "license_server": license_server, - "server_fingerprint": server_fingerprint, - "hostname": hostname, - "requested_seats": requested_seats, - "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "csr": csr_pem - } - lic_json = json.dumps(lic_data, indent=2) - lic_path = os.path.join(cert_dir, "logar_server.lic") - with open(lic_path, "w", encoding="utf-8") as f: - f.write(lic_json) - return lic_data, lic_json - - -def sign_ca_csr_with_seats( - csr_pem: str, - master_ca_cert_pem: str, - master_ca_key_pem: str, - seats: int, - days_valid: int = 365, - license_id: Optional[str] = None, - license_server_url: str = "https://logar.eibl.tech", - features: Optional[List[str]] = None -) -> str: - """ - Signs a subordinate CA CSR using the master license authority Root CA. - Embeds the approved seat count, license ID, and optional enterprise features via custom X.509 extensions. - Returns the signed PEM certificate. - """ - csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8")) - if not csr.is_signature_valid: - raise ValueError("Invalid CSR signature") - - master_ca_cert = x509.load_pem_x509_certificate(master_ca_cert_pem.encode("utf-8")) - master_ca_key = serialization.load_pem_private_key(master_ca_key_pem.encode("utf-8"), password=None) - - now = datetime.datetime.now(datetime.timezone.utc) - if not license_id: - license_id = f"LIC-{csr.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo).hex()[:12].upper()}" - - builder = ( - x509.CertificateBuilder() - .subject_name(csr.subject) - .issuer_name(master_ca_cert.subject) - .public_key(csr.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(minutes=5)) - .not_valid_after(now + datetime.timedelta(days=days_valid)) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), - critical=False - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(master_ca_key.public_key()), - critical=False - ) - .add_extension( - x509.UnrecognizedExtension(OID_LOGAR_SEATS, str(seats).encode("utf-8")), - critical=False - ) - .add_extension( - x509.UnrecognizedExtension(OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")), - critical=False - ) - .add_extension( - x509.UnrecognizedExtension(OID_LOGAR_ISSUER_URL, license_server_url.encode("utf-8")), - critical=False - ) - ) - - if features: - feat_str = json.dumps(features) if isinstance(features, list) else str(features) - builder = builder.add_extension( - x509.UnrecognizedExtension(OID_LOGAR_FEATURES, feat_str.encode("utf-8")), - critical=False - ) - - signed_cert = builder.sign(master_ca_key, hashes.SHA256()) - return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") - - -def enroll_and_sync_license_ws( - cert_dir: str = "certs", - license_id: Optional[str] = None, - license_authority_ws_url: str = "wss://logar.eibl.tech/ws/license", - server_fingerprint: str = "", - hostname: str = "", - comm_cert_pem: Optional[str] = None, - master_root_ca_pem: Optional[str] = None, - seats_taken: Optional[int] = None, - timeout: int = 15, - verify_tls: bool = True -) -> Tuple[bool, str, Optional[Dict[str, Any]]]: - """ - Connects to the Master License Authority via WebSocket protocol, authenticating and - submitting the server Sub-CA CSR to receive a cryptographically signed Sub-CA license - certificate with embedded seat quotas. - """ - os.makedirs(cert_dir, exist_ok=True) - ca_key_path = os.path.join(cert_dir, "ca.key") - ca_cert_path = os.path.join(cert_dir, "ca.crt") - - # 1. Load or generate client Sub-CA private key - if os.path.exists(ca_key_path): - with open(ca_key_path, "r", encoding="utf-8") as f: - ca_key_pem = f.read() - ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None) - else: - ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096) - ca_key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption() - ).decode("utf-8") - with open(ca_key_path, "w", encoding="utf-8") as f: - f.write(ca_key_pem) - - # 2. Build Sub-CA CSR - csr_common_name = f"LOGAR-Server-CA-{license_id or 'Node'}" - subject = x509.Name([ - x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Subordinate Node"), - x509.NameAttribute(NameOID.COMMON_NAME, csr_common_name), - ]) - csr = ( - x509.CertificateSigningRequestBuilder() - .subject_name(subject) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False - ), - critical=True - ) - .sign(ca_key, hashes.SHA256()) - ) - csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8") - - # 3. Connect via WebSocket - try: - from websockets.sync.client import connect as ws_connect - except ImportError: - return False, "websockets library not available", None - - ssl_context = None - if license_authority_ws_url.startswith("wss://"): - ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - if not verify_tls: - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - elif master_root_ca_pem: - ssl_context.load_verify_locations(cadata=master_root_ca_pem) - else: - upstream_path = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_path): - ssl_context.load_verify_locations(cafile=upstream_path) - - try: - with ws_connect( - license_authority_ws_url, - ssl=ssl_context, - open_timeout=timeout, - close_timeout=timeout - ) as ws: - payload = { - "action": "enroll_or_sync", - "license_id": license_id, - "communication_cert": comm_cert_pem, - "server_csr": csr_pem, - "server_fingerprint": server_fingerprint, - "hostname": hostname - } - if seats_taken is not None: - payload["seats_taken"] = seats_taken - ws.send(json.dumps(payload)) - raw_reply = ws.recv(timeout=timeout) - reply = json.loads(raw_reply) - - if reply.get("status") != "success": - return False, f"WebSocket licensing error: {reply.get('message', raw_reply)}", None - - new_ca_pem = reply.get("ca_cert") - if not new_ca_pem: - return False, "License response missing 'ca_cert'", None - - # Backup old ca.crt if present - if os.path.exists(ca_cert_path): - ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") - try: - os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak") - except Exception: - pass - - with open(ca_cert_path, "w", encoding="utf-8") as f: - f.write(new_ca_pem) - - upstream_ca = reply.get("master_root_ca") or master_root_ca_pem - if upstream_ca: - with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: - f.write(upstream_ca) - - lic_info = extract_license_info_from_cert(new_ca_pem) - return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", { - "license_id": license_id, - "seats": reply.get("seats"), - "ca_cert_pem": new_ca_pem, - "master_root_ca_pem": upstream_ca, - "license_info": lic_info - } - - except Exception as e: - return False, f"WebSocket enrollment failed: {str(e)}", None - - -def import_license_package( - lic_content_or_path: str, - cert_dir: str = "certs", - server_fingerprint: str = "", - hostname: str = "" -) -> Dict[str, Any]: - """ - Imports and validates a license package or certificate. - Supports: - 1. Bootstrap JSON .lic package (auto-enrolls via WebSocket to receive signed CA cert) - 2. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca' - 3. Raw PEM Subordinate CA certificate - 4. File path pointing to any of the above - Saves the signed CA certificate to cert_dir/ca.crt (backing up any old ca.crt), - and if 'master_root_ca' is present, saves it to cert_dir/upstream_ca.crt for mTLS trust. - Returns extracted license attributes and PEM contents. - """ - os.makedirs(cert_dir, exist_ok=True) - raw = lic_content_or_path - if os.path.exists(lic_content_or_path) and os.path.isfile(lic_content_or_path): - with open(lic_content_or_path, "r", encoding="utf-8") as f: - raw = f.read() - - ca_pem = None - master_root_ca_pem = None - - trimmed = raw.strip() - if trimmed.startswith("{"): - try: - data = json.loads(trimmed) - except Exception as e: - raise ValueError(f"Failed to parse JSON .lic package: {e}") - - # If this is a bootstrap license package, auto-enroll over WebSocket - if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")): - lic_id = data.get("license_id") - ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license") - comm_cert = data.get("communication_cert") - master_ca = data.get("master_root_ca") - - if master_ca: - with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f: - f.write(master_ca) - - ok, msg, res = enroll_and_sync_license_ws( - cert_dir=cert_dir, - license_id=lic_id, - license_authority_ws_url=ws_url, - server_fingerprint=server_fingerprint or data.get("server_fingerprint", ""), - hostname=hostname or data.get("hostname", ""), - comm_cert_pem=comm_cert, - master_root_ca_pem=master_ca - ) - if not ok or not res: - raise ValueError(msg) - return res - - ca_pem = data.get("ca_cert") or data.get("cert_pem") - master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca") - elif "-----BEGIN CERTIFICATE-----" in trimmed: - ca_pem = trimmed - - if not ca_pem: - raise ValueError("Provided license package does not contain a valid 'ca_cert' PEM certificate.") - - info = extract_license_info_from_cert(ca_pem) - if not info.get("valid"): - raise ValueError(f"Invalid CA certificate in license: {info.get('error')}") - - # Backup existing ca.crt if present - ca_path = os.path.join(cert_dir, "ca.crt") - if os.path.exists(ca_path): - ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") - try: - os.replace(ca_path, f"{ca_path}.{ts}.bak") - except Exception: - pass - - with open(ca_path, "w", encoding="utf-8") as f: - f.write(ca_pem) - - # Save upstream_ca.crt if master_root_ca is present - if master_root_ca_pem: - upstream_path = os.path.join(cert_dir, "upstream_ca.crt") - with open(upstream_path, "w", encoding="utf-8") as f: - f.write(master_root_ca_pem) - - return { - "ca_cert_pem": ca_pem, - "master_root_ca_pem": master_root_ca_pem, - "license_info": info, - "seats": info.get("seats"), - "features": info.get("features", []), - "license_id": info.get("license_id"), - "issuer_url": info.get("issuer_url"), - "fingerprint": info.get("fingerprint"), - "expires_at": info.get("not_valid_after") - } - - -def renew_ca_cert_mtls( - cert_dir: str = "certs", - license_server_url: str = "https://logar.eibl.tech", - upstream_ca_pem: Optional[str] = None, - requested_seats: Optional[int] = None, - seat_increment: int = 0, - seats_taken: Optional[int] = None, - active_seats: Optional[int] = None, - hostname: Optional[str] = None, - server_fingerprint: Optional[str] = None, - timeout: int = 15, - verify_tls: bool = True -) -> Tuple[bool, str, Optional[str]]: - """ - Connects to the master License Authority using mutual TLS (mTLS), authenticating - with the server's current ca.crt and ca.key, and requests an automated renewal. - Supports requesting seat quota increases or increments during renewal and reporting active seat usage. - If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir. - Returns (success, message, new_ca_cert_pem). - """ - ca_cert_path = os.path.join(cert_dir, "ca.crt") - ca_key_path = os.path.join(cert_dir, "ca.key") - - if not os.path.exists(ca_cert_path) or not os.path.exists(ca_key_path): - return False, f"Missing ca.crt or ca.key in {cert_dir}", None - - renew_url = f"{license_server_url.rstrip('/')}/api/license/renew" - parsed = urllib.parse.urlparse(renew_url) - - try: - with open(ca_cert_path, "r", encoding="utf-8") as f: - current_pem = f.read() - info = extract_license_info_from_cert(current_pem) - - effective_seats_taken = seats_taken if seats_taken is not None else active_seats - payload_dict = { - "license_id": info.get("license_id"), - "fingerprint": info.get("fingerprint"), - "current_cert": current_pem, - "seats_taken": effective_seats_taken, - "active_seats": effective_seats_taken, - "hostname": hostname or "", - "server_fingerprint": server_fingerprint or "" - } - if requested_seats is not None: - payload_dict["requested_seats"] = requested_seats - if seat_increment: - payload_dict["seat_increment"] = seat_increment - - payload = json.dumps(payload_dict).encode("utf-8") - - path = parsed.path or "/api/license/renew" - headers = { - "Content-Type": "application/json", - "User-Agent": "LOGAR-Server-mTLS/2.0" - } - - if parsed.scheme == "https": - ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - if not verify_tls: - ssl_ctx.check_hostname = False - ssl_ctx.verify_mode = ssl.CERT_NONE - elif upstream_ca_pem: - ssl_ctx.load_verify_locations(cadata=upstream_ca_pem) - upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt") - if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem: - ssl_ctx.load_verify_locations(cafile=upstream_ca_path) - - ssl_ctx.load_cert_chain(certfile=ca_cert_path, keyfile=ca_key_path) - conn = http.client.HTTPSConnection(parsed.netloc, context=ssl_ctx, timeout=timeout) - else: - conn = http.client.HTTPConnection(parsed.netloc, timeout=timeout) - - conn.request("POST", path, body=payload, headers=headers) - resp = conn.getresponse() - resp_data = resp.read().decode("utf-8") - conn.close() - - if resp.status != 200: - return False, f"License server returned HTTP {resp.status}: {resp_data}", None - - result = json.loads(resp_data) - new_cert_pem = result.get("ca_cert") - if not new_cert_pem: - return False, f"License server response missing 'ca_cert': {resp_data}", None - - # Backup old ca.crt - ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S") - os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak") - - # Write new ca.crt - with open(ca_cert_path, "w", encoding="utf-8") as f: - f.write(new_cert_pem) - - # Update upstream_ca.crt if master root CA certificate was provided - master_root_pem = result.get("master_root_ca") - if master_root_pem: - upstream_path = os.path.join(cert_dir, "upstream_ca.crt") - with open(upstream_path, "w", encoding="utf-8") as f: - f.write(master_root_pem) - - return True, "Certificate renewed successfully via mTLS", new_cert_pem - - except Exception as e: - return False, f"mTLS renewal exception: {str(e)}", None - - +from src.hub.server_enrollment import * \ No newline at end of file diff --git a/src/server_models.py b/src/server_models.py index 164347b..1adeb05 100644 --- a/src/server_models.py +++ b/src/server_models.py @@ -1,88 +1 @@ -""" -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 - version: Optional[str] = None - - -class ClientUpdateRequest(BaseModel): - client_id: str - os: str - current_version: Optional[str] = None - arch: Optional[str] = "x86_64" - - -class ServerUpdateCheckRequest(BaseModel): - force: bool = False - - -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 +from src.hub.server_models import * \ No newline at end of file diff --git a/src/storage_engine.py b/src/storage_engine.py index 5b23d86..56166d3 100644 --- a/src/storage_engine.py +++ b/src/storage_engine.py @@ -1,641 +1 @@ -""" -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' - ) - """) - - def _ensure_columns(tbl: str, cols: List[Tuple[str, str]]): - try: - existing = {row[1] for row in conn.execute(f"PRAGMA table_info({tbl})").fetchall()} - for col, col_def in cols: - if col not in existing: - conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} {col_def}") - except Exception: - pass - - # Migrations for license_config - _ensure_columns("license_config", [ - ("license_id", "TEXT"), - ("license_server_url", "TEXT"), - ("issued_at", "TEXT"), - ("expires_at", "TEXT"), - ("license_status", "TEXT DEFAULT 'unlicensed'"), - ("features", "TEXT DEFAULT '[]'") - ]) - - # 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 - _ensure_columns("clients", [ - ("metrics_json", "TEXT DEFAULT '{}'"), - ("cpu_percent", "REAL DEFAULT 0.0"), - ("memory_percent", "REAL DEFAULT 0.0"), - ("disk_percent_free", "REAL DEFAULT 0.0"), - ("client_version", "TEXT DEFAULT ''"), - ("update_status", "TEXT DEFAULT 'up_to_date'"), - ("last_update_check", "TEXT") - ]) - - # 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) - _ensure_columns("users", [ - ("totp_secret", "TEXT"), - ("totp_enabled", "INTEGER DEFAULT 0") - ]) - - # 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"), - ("client_version", "VARCHAR(50) DEFAULT ''"), - ("update_status", "VARCHAR(50) DEFAULT 'up_to_date'"), - ("last_update_check", "VARCHAR(50)") - ]: - 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 - }) - } +from src.hub.storage_engine import * \ No newline at end of file diff --git a/src/update_manager.py b/src/update_manager.py index 2721591..5275f97 100644 --- a/src/update_manager.py +++ b/src/update_manager.py @@ -1,362 +1 @@ -""" -LOGAR - Central Server Update & Fleet Distribution Manager -Handles upstream release checking, update alerting, local client package hosting, -and client auto-update check/download endpoints. -""" - -import os -import sys -import json -import hashlib -import urllib.request -import urllib.error -from datetime import datetime, timezone -from typing import Dict, Any, List, Optional, Tuple - -try: - from src.version import LOGAR_VERSION, is_newer_version, compare_versions -except ImportError: - from version import LOGAR_VERSION, is_newer_version, compare_versions - - -class ServerUpdateManager: - DEFAULT_GITEA_RELEASE_URL = "https://gitea.eibl.tech/api/v1/repos/me0nline/LOGAR/releases/latest" - - def __init__( - self, - update_dir: Optional[str] = None, - upstream_url: Optional[str] = None, - check_enabled: bool = True - ): - self.update_dir = update_dir or os.path.join(os.getcwd(), "updates") - os.makedirs(self.update_dir, exist_ok=True) - self.upstream_url = upstream_url or self.DEFAULT_GITEA_RELEASE_URL - self.check_enabled = check_enabled - - # Cached upstream release state - self.last_checked_utc: Optional[str] = None - self.latest_upstream_version: Optional[str] = None - self.server_update_available: bool = False - self.release_title: Optional[str] = None - self.release_notes: Optional[str] = None - self.release_url: Optional[str] = None - self.release_assets: List[Dict[str, Any]] = [] - self.last_error: Optional[str] = None - - # Hosted client packages cache - self.hosted_client_version: str = LOGAR_VERSION - self._sha256_cache: Dict[str, Tuple[float, int, str]] = {} - self._package_cache: Dict[str, Dict[str, Any]] = {} - self._package_cache_mtimes: Dict[str, float] = {} - self._ensure_package_structure() - - def _ensure_package_structure(self): - """Initializes default subdirectories for OS-specific client packages.""" - for os_name in ["windows", "linux"]: - os.makedirs(os.path.join(self.update_dir, os_name), exist_ok=True) - - @classmethod - def calculate_sha256(cls, filepath: str) -> str: - """Computes the SHA-256 digest of a local file with stat-based memoization.""" - if not os.path.exists(filepath): - return "" - try: - stat = os.stat(filepath) - mtime = stat.st_mtime - size = stat.st_size - if hasattr(cls, "_global_sha256_cache"): - cached = cls._global_sha256_cache.get(filepath) - if cached and cached[0] == mtime and cached[1] == size: - return cached[2] - except Exception: - mtime = 0.0 - size = 0 - - h = hashlib.sha256() - with open(filepath, "rb") as f: - while chunk := f.read(65536): - h.update(chunk) - digest = h.hexdigest().lower() - - if not hasattr(cls, "_global_sha256_cache"): - cls._global_sha256_cache = {} - cls._global_sha256_cache[filepath] = (mtime, size, digest) - return digest - - def set_hosted_client_version(self, version_str: str): - """Sets the version tag for currently hosted client packages.""" - if version_str: - self.hosted_client_version = version_str.strip().lstrip("v") - self._package_cache.clear() - - def invalidate_package_cache(self, os_type: Optional[str] = None): - """Clears in-memory package cache.""" - if os_type: - self._package_cache.pop(os_type.lower(), None) - self._package_cache_mtimes.pop(os_type.lower(), None) - else: - self._package_cache.clear() - self._package_cache_mtimes.clear() - - def register_client_package( - self, - os_type: str, - file_path: str, - version: Optional[str] = None, - filename: Optional[str] = None - ) -> Dict[str, Any]: - """ - Saves/copies a client binary package into the local update repository. - Computes SHA-256 checksum and updates metadata. - """ - norm_os = os_type.lower() - if norm_os not in ["windows", "linux"]: - raise ValueError(f"Unsupported OS type: {os_type}") - - if not os.path.exists(file_path): - raise FileNotFoundError(f"Package file not found: {file_path}") - - target_dir = os.path.join(self.update_dir, norm_os) - os.makedirs(target_dir, exist_ok=True) - - raw_name = filename or os.path.basename(file_path) - target_name = os.path.basename(raw_name) - dest_path = os.path.join(target_dir, target_name) - - if os.path.abspath(file_path) != os.path.abspath(dest_path): - with open(file_path, "rb") as src_f, open(dest_path, "wb") as dst_f: - while chunk := src_f.read(65536): - dst_f.write(chunk) - - sha256 = self.calculate_sha256(dest_path) - pkg_version = version or self.hosted_client_version or LOGAR_VERSION - self.set_hosted_client_version(pkg_version) - - # Write manifest file next to package - manifest = { - "os": norm_os, - "filename": target_name, - "version": pkg_version, - "sha256": sha256, - "size_bytes": os.path.getsize(dest_path), - "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - } - manifest_path = os.path.join(target_dir, "manifest.json") - with open(manifest_path, "w", encoding="utf-8") as mf: - json.dump(manifest, mf, indent=2) - - # Update cache directly - cached_entry = dict(manifest) - cached_entry["file_path"] = dest_path - cached_entry["exists"] = True - self._package_cache[norm_os] = cached_entry - self._package_cache_mtimes[norm_os] = os.path.getmtime(dest_path) - - return manifest - - def get_hosted_packages(self, force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: - """ - Returns info on currently hosted client packages for Windows and Linux. - Uses in-memory cache validated against file modification times to minimize disk I/O. - """ - result: Dict[str, Dict[str, Any]] = {} - for os_name in ["windows", "linux"]: - manifest_path = os.path.join(self.update_dir, os_name, "manifest.json") - target_file = None - - if not force_refresh and os_name in self._package_cache: - cached = self._package_cache[os_name] - tf = cached.get("file_path") - if tf and os.path.exists(tf): - try: - cur_mtime = os.path.getmtime(tf) - if cur_mtime == self._package_cache_mtimes.get(os_name): - result[os_name] = cached - continue - except Exception: - pass - - if os.path.exists(manifest_path): - try: - with open(manifest_path, "r", encoding="utf-8") as mf: - info = json.load(mf) - target_file = os.path.join(self.update_dir, os_name, info.get("filename", "")) - if os.path.exists(target_file): - info["exists"] = True - info["file_path"] = target_file - result[os_name] = info - self._package_cache[os_name] = info - self._package_cache_mtimes[os_name] = os.path.getmtime(target_file) - continue - except Exception: - pass - - # Fallback scan in update_dir/{os_name} - os_dir = os.path.join(self.update_dir, os_name) - found_pkg = None - if os.path.exists(os_dir): - for f in os.listdir(os_dir): - if f.endswith((".exe", ".bin", ".zip", ".tar.gz", ".sh")) and not f.startswith("."): - found_pkg = os.path.join(os_dir, f) - break - - # Secondary fallback: search project dist/ - if not found_pkg: - dist_dir = os.path.join(os.path.dirname(self.update_dir), "dist") - if os.path.exists(dist_dir): - patterns = [ - "LOGAR_Client_Setup.exe", "LOGAR_Client.exe", "Win_Client.exe" - ] if os_name == "windows" else [ - "Linux_Client.bin", "LOGAR_Client_Linux", "install_linux_client.sh" - ] - for pat in patterns: - candidate = os.path.join(dist_dir, pat) - if os.path.exists(candidate): - found_pkg = candidate - break - - if found_pkg and os.path.exists(found_pkg): - sha256 = self.calculate_sha256(found_pkg) - pkg_data = { - "os": os_name, - "filename": os.path.basename(found_pkg), - "version": self.hosted_client_version or LOGAR_VERSION, - "sha256": sha256, - "size_bytes": os.path.getsize(found_pkg), - "file_path": found_pkg, - "exists": True, - "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - } - result[os_name] = pkg_data - self._package_cache[os_name] = pkg_data - self._package_cache_mtimes[os_name] = os.path.getmtime(found_pkg) - else: - pkg_data = { - "os": os_name, - "filename": None, - "version": self.hosted_client_version or LOGAR_VERSION, - "sha256": "", - "size_bytes": 0, - "file_path": None, - "exists": False - } - result[os_name] = pkg_data - self._package_cache[os_name] = pkg_data - self._package_cache_mtimes[os_name] = 0.0 - - return result - - def get_client_package_path(self, os_type: str) -> Optional[Tuple[str, str, str]]: - """ - Returns (file_path, filename, sha256) for downloading a client update package. - """ - packages = self.get_hosted_packages() - pkg_info = packages.get(os_type.lower()) - if pkg_info and pkg_info.get("exists") and pkg_info.get("file_path"): - return pkg_info["file_path"], pkg_info["filename"], pkg_info["sha256"] - return None - - def check_client_update( - self, - client_id: str, - os_type: str, - current_version: Optional[str], - arch: str = "x86_64" - ) -> Dict[str, Any]: - """ - Evaluates whether a client requires an update based on its reported version - and the server's hosted client package version. - """ - norm_os = (os_type or "windows").lower() - packages = self.get_hosted_packages() - pkg = packages.get(norm_os, {}) - - target_version = pkg.get("version") or self.hosted_client_version or LOGAR_VERSION - has_pkg = bool(pkg.get("exists")) - client_ver = (current_version or "0.0.0.0").strip().lstrip("v") - - # An update is available if the hosted version is strictly newer than the client version - update_available = has_pkg and is_newer_version(target_version, client_ver) - - return { - "update_available": update_available, - "client_id": client_id, - "os": norm_os, - "current_version": client_ver, - "latest_version": target_version, - "sha256": pkg.get("sha256", "") if update_available else "", - "download_url": f"/api/client/update/download?os={norm_os}" if update_available else "", - "size_bytes": pkg.get("size_bytes", 0) if update_available else 0, - "filename": pkg.get("filename", "") if update_available else "", - "release_notes": self.release_notes or f"LOGAR {target_version} update for {norm_os.capitalize()}.", - "mandatory": False - } - - def check_upstream_updates(self, force: bool = False, timeout: int = 8) -> Dict[str, Any]: - """ - Queries the upstream repository release API (Gitea / GitHub / JSON manifest) - to check if a newer server or client release is available. - """ - if not self.check_enabled and not force: - return self.get_status() - - now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") - self.last_checked_utc = now_utc - self.last_error = None - - try: - req = urllib.request.Request( - self.upstream_url, - headers={ - "User-Agent": f"LOGAR-Server/{LOGAR_VERSION}", - "Accept": "application/json" - } - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status == 200: - data = json.loads(resp.read().decode("utf-8")) - raw_tag = data.get("tag_name") or data.get("name") or data.get("version") or "" - upstream_ver = raw_tag.strip().lstrip("v") - - self.latest_upstream_version = upstream_ver - self.release_title = data.get("name") or raw_tag - self.release_notes = data.get("body") or data.get("notes") or "" - self.release_url = data.get("html_url") or data.get("url") or "" - - # Process assets list - assets = [] - for a in data.get("assets", []): - assets.append({ - "name": a.get("name"), - "size": a.get("size"), - "download_url": a.get("browser_download_url") or a.get("download_url") - }) - self.release_assets = assets - - # Compare version against current LOGAR_VERSION - self.server_update_available = is_newer_version(upstream_ver, LOGAR_VERSION) - if self.server_update_available: - print(f"[!] 🔔 LOGAR Server Update Available: Current v{LOGAR_VERSION} -> Upstream v{upstream_ver}") - else: - self.last_error = f"Upstream returned HTTP status {resp.status}" - except Exception as e: - self.last_error = f"Failed to check upstream updates: {e}" - - return self.get_status() - - def get_status(self) -> Dict[str, Any]: - """Returns structured update status for WebUI and REST APIs.""" - return { - "current_server_version": LOGAR_VERSION, - "latest_upstream_version": self.latest_upstream_version or LOGAR_VERSION, - "server_update_available": self.server_update_available, - "hosted_client_version": self.hosted_client_version or LOGAR_VERSION, - "release_title": self.release_title or "", - "release_notes": self.release_notes or "", - "release_url": self.release_url or "", - "release_assets": self.release_assets, - "last_checked_utc": self.last_checked_utc, - "last_error": self.last_error, - "check_enabled": self.check_enabled, - "hosted_packages": self.get_hosted_packages() - } +from src.hub.update_manager import * \ No newline at end of file diff --git a/src/version.py b/src/version.py index aa2f276..3d4490b 100644 --- a/src/version.py +++ b/src/version.py @@ -1,67 +1 @@ -""" -LOGAR - Unified Versioning and Semantic Comparison Module -Provides canonical application version constants and semantic version comparison utilities. -""" - -import os -import re -from typing import Tuple, Optional - - -LOGAR_VERSION = "0.3.5.2" -__version__ = LOGAR_VERSION - - -def parse_version(version_str: Optional[str]) -> Tuple[int, ...]: - """ - Parses a version string into a comparable tuple of integers. - Strips leading 'v' or 'V' and non-numeric suffixes. - e.g. 'v0.3.5.2' -> (0, 3, 5, 2), '0.3.5-beta' -> (0, 3, 5, 0) - """ - if not version_str: - return (0, 0, 0, 0) - - clean = str(version_str).strip() - if clean.lower().startswith("v"): - clean = clean[1:].strip() - - # Extract digit sequences separated by dots - match = re.match(r"^(\d+(?:\.\d+)*)", clean) - if not match: - return (0, 0, 0, 0) - - parts = [int(p) for p in match.group(1).split(".")] - # Pad to at least 4 components for consistent 4-part semantic version comparison - while len(parts) < 4: - parts.append(0) - return tuple(parts) - - -def compare_versions(v1: Optional[str], v2: Optional[str]) -> int: - """ - Compares two version strings. - Returns: - 1 if v1 > v2 (v1 is newer than v2) - 0 if v1 == v2 - -1 if v1 < v2 (v1 is older than v2) - """ - t1 = parse_version(v1) - t2 = parse_version(v2) - if t1 > t2: - return 1 - elif t1 < t2: - return -1 - return 0 - - -def is_newer_version(candidate_version: Optional[str], current_version: Optional[str] = None) -> bool: - """ - Returns True if candidate_version is strictly newer than current_version (or LOGAR_VERSION). - """ - base = current_version or LOGAR_VERSION - return compare_versions(candidate_version, base) > 0 - - -def get_current_version() -> str: - """Returns the canonical current version string.""" - return LOGAR_VERSION +from src.common.version import * \ No newline at end of file diff --git a/src/web_ui.py b/src/web_ui.py index d8c6848..4e1f7cc 100644 --- a/src/web_ui.py +++ b/src/web_ui.py @@ -1,1883 +1 @@ -""" -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 -try: - from src.version import LOGAR_VERSION, is_newer_version -except ImportError: - from version import LOGAR_VERSION, is_newer_version - - -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", {}) - - update_mgr = state.get("update_manager") - update_status = update_mgr.get_status() if update_mgr else {} - hosted_client_ver = update_status.get("hosted_client_version") or LOGAR_VERSION - hosted_packages = update_status.get("hosted_packages", {}) - - # Fetch clients safely - conn = sqlite3.connect(db_path) - c = conn.cursor() - try: - c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, COALESCE(client_version, ''), COALESCE(update_status, 'up_to_date') FROM clients ORDER BY last_seen DESC") - clients = c.fetchall() - except sqlite3.OperationalError: - c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC") - clients = [r + ("", "up_to_date") for r in c.fetchall()] - - # 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, version badges, and seat management actions - client_rows = [] - for cl in clients: - cid, host, ost, fp, st, first, last, cver, up_st = cl[:9] - 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)}
" - - # Client Version Display - if not cver: - ver_display = f'v{LOGAR_VERSION}' - elif is_newer_version(hosted_client_ver, cver): - ver_display = f'v{html.escape(cver)} (Outdated)' - else: - ver_display = f'v{html.escape(cver)}' - - 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()} - {ver_display} - {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.
-
-
-
- -
-
- """ - - server_update_banner_html = "" - update_tab_badge = "" - if update_status.get("server_update_available"): - up_ver = html.escape(str(update_status.get("latest_upstream_version", ""))) - update_tab_badge = ' NEW' - server_update_banner_html = f""" -
-
-
🔔
-
-
LOGAR Server Update Available: v{up_ver} (Current: v{LOGAR_VERSION})
-
A newer release of LOGAR is available. Deployed server packages automatically upgrade edge clients during their next forwarder check.
-
-
-
- -
-
- """ - - 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 -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - -
- -
-
-
- - -
-
-
-
-
🌐 Server Version & Upstream Release Status
-
Automatic upstream release checking via Gitea production channels
-
- {('UPDATE AVAILABLE' if update_status.get("server_update_available") else 'UP TO DATE')} -
-
-
-
Current Server Version
-
v{LOGAR_VERSION}
-
-
-
Latest Upstream Release
-
v{update_status.get('latest_upstream_version') or LOGAR_VERSION}
-
-
-
Last Checked
-
{update_status.get('last_checked_utc') or 'Never'}
-
-
-
- - {(f'🌐 View Release on Gitea' if update_status.get("release_url") else '')} -
-
- -
-
-
-
📦 Client Package Distribution Repository
-
Hosted binaries for edge forwarders to automatically download and hot-upgrade
-
- Target: v{hosted_client_ver} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Target OSPackage FileVersionSizeSHA-256 ChecksumStatusActions
WINDOWS{html.escape(hosted_packages.get('windows', {}).get('filename') or 'LOGAR_Client_Setup.exe')}v{html.escape(str(hosted_packages.get('windows', {}).get('version') or hosted_client_ver))}{((hosted_packages.get('windows', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('windows', {}).get('exists') else 0)} KB{(hosted_packages.get('windows', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('windows', {}).get('sha256') else 'N/A'}{('Available' if hosted_packages.get('windows', {}).get('exists') else 'Not Uploaded')} - 📥 Download -
LINUX{html.escape(hosted_packages.get('linux', {}).get('filename') or 'Linux_Client.bin')}v{html.escape(str(hosted_packages.get('linux', {}).get('version') or hosted_client_ver))}{((hosted_packages.get('linux', {}).get('size_bytes', 0) // 1024) if hosted_packages.get('linux', {}).get('exists') else 0)} KB{(hosted_packages.get('linux', {}).get('sha256', 'N/A')[:16] + '...') if hosted_packages.get('linux', {}).get('sha256') else 'N/A'}{('Available' if hosted_packages.get('linux', {}).get('exists') else 'Not Uploaded')} - 📥 Download -
-
- -
-
📤 Upload Client Binary Package
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
- """ - - 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} - {server_update_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 IdentifierOSVersionCert 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 +from src.hub.web_ui import * \ No newline at end of file diff --git a/tests/test_clients_comprehensive.py b/tests/test_clients_comprehensive.py new file mode 100644 index 0000000..6529fdb --- /dev/null +++ b/tests/test_clients_comprehensive.py @@ -0,0 +1,178 @@ +""" +LOGAR - Comprehensive Unit Tests for Edge Clients & Forwarders +Covers log_collector, client_transport, client_updater, Win_Client, and Linux_Client. +""" + +import os +import sys +import json +import time +import tempfile +import unittest +import socket +import ssl +from unittest.mock import MagicMock, patch + +from src.clients.log_collector import ( + get_machine_identifier, + is_path_writable, + get_default_data_dir, + resolve_writable_path, + get_state_path, + load_state, + save_state, + commit_state, + WindowsLogCollector, + LinuxLogCollector, + BaseLogCollector, + get_log_collector, + is_routine_service_noise, + collect_system_metrics +) +from src.clients.client_updater import ClientUpdater +from src.clients.client_transport import ( + is_cert_expiring_soon, + enroll_client_if_needed, + load_config, + ensure_enrolled, + send_encrypted_logs_over_socket, + check_and_apply_auto_update +) +import src.clients.Win_Client as Win_Client +import src.clients.Linux_Client as Linux_Client + + +class TestClientsComprehensive(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + if os.path.exists(self.test_dir): + try: + shutil.rmtree(self.test_dir) + except Exception: + pass + + def test_log_collector_utilities(self): + """Tests machine identifier, path resolution, and state persistence.""" + mid = get_machine_identifier() + self.assertTrue(len(mid) > 0) + + data_dir = get_default_data_dir("TEST-LOGAR") + self.assertTrue(isinstance(data_dir, str)) + + resolved = resolve_writable_path(os.path.join(self.test_dir, "test.db"), "TEST-LOGAR") + self.assertEqual(os.path.abspath(resolved), os.path.abspath(os.path.join(self.test_dir, "test.db"))) + + state_path = get_state_path("test_state.json", "TEST-LOGAR") + self.assertTrue(isinstance(state_path, str)) + + # State load/save/commit + st_file = os.path.join(self.test_dir, "st.json") + st_data = load_state(st_file) + self.assertIsInstance(st_data, dict) + st_data["processed_records"] = 42 + st_data["new_last_record_number"] = 100 + save_state(st_file, st_data) + loaded = load_state(st_file) + self.assertEqual(loaded["processed_records"], 42) + + commit_state(loaded, st_file) + loaded2 = load_state(st_file) + self.assertEqual(loaded2["last_record_number"], 100) + + def test_metrics_collection_and_noise_filter(self): + """Tests system metrics telemetry and routine noise filtering.""" + metrics = collect_system_metrics() + self.assertIn("cpu_percent", metrics) + self.assertIn("memory_percent", metrics) + self.assertIn("disk_percent_free", metrics) + + # Routine noise filter: signature, message, severity + self.assertTrue(is_routine_service_noise("Service Control Manager", "Windows Error Reporting Service entered the stopped state.", "INFO")) + self.assertTrue(is_routine_service_noise("Service Control Manager", "Background Intelligent Transfer Service entered the running state.", "INFO")) + self.assertFalse(is_routine_service_noise("NTFS", "Fatal disk corruption error detected on C:", "ERROR")) + + def test_log_collector_classes(self): + """Tests WindowsLogCollector and LinuxLogCollector implementations.""" + # Linux Log Collector + linux_col = LinuxLogCollector() + self.assertIsNotNone(linux_col) + + # Windows Log Collector + win_col = WindowsLogCollector() + self.assertEqual(win_col.channels, ["Application"]) + + factory_col = get_log_collector() + self.assertIsInstance(factory_col, BaseLogCollector) + + def test_client_updater_lifecycle(self): + """Tests ClientUpdater download, local hash match, and update application routines.""" + updater = ClientUpdater(config={"server_host": "127.0.0.1", "hermes_port": 8443}, os_type="linux") + self.assertEqual(updater.os_type, "linux") + base_url = updater.get_server_base_url() + self.assertEqual(base_url, "http://127.0.0.1:8443") + + # Linux apply update mock + dummy_bin = os.path.join(self.test_dir, "dummy_new_bin") + dummy_target = os.path.join(self.test_dir, "dummy_target_bin") + with open(dummy_bin, "wb") as f: + f.write(b"NEW_BINARY_CONTENT") + with open(dummy_target, "wb") as f: + f.write(b"OLD_BINARY_CONTENT") + + ok = updater.apply_update_linux(dummy_bin, dummy_target) + self.assertTrue(ok) + with open(dummy_target, "rb") as f: + self.assertEqual(f.read(), b"NEW_BINARY_CONTENT") + + # Windows updater batch generation + updater_win = ClientUpdater(config={"server_host": "127.0.0.1", "hermes_port": 8443}, os_type="windows") + with patch("subprocess.Popen") as mock_popen: + win_bin = os.path.join(self.test_dir, "win_new.exe") + win_target = os.path.join(self.test_dir, "win_target.exe") + with open(win_bin, "wb") as f: + f.write(b"WIN_EXE") + with open(win_target, "wb") as f: + f.write(b"WIN_TARGET") + res = updater_win.apply_update_windows(win_bin, win_target) + self.assertTrue(res) + self.assertTrue(mock_popen.called) + + def test_client_transport_config_and_enrollment(self): + """Tests loading client config, cert expiration check, and fallback.""" + cfg_file = os.path.join(self.test_dir, "client_config.json") + cert_dir = os.path.join(self.test_dir, "certs") + os.makedirs(cert_dir, exist_ok=True) + cfg_data = { + "server_host": "127.0.0.1", + "server_port": 9443, + "hermes_host": "127.0.0.1", + "hermes_port": 8443, + "enrollment_secret": "test_sec", + "cert_dir": cert_dir, + "server_fingerprint": "FP123", + "server_public_key": "", + "auth_token": "token123" + } + with open(cfg_file, "w", encoding="utf-8") as f: + json.dump(cfg_data, f) + + loaded_cfg = load_config(cfg_file) + self.assertEqual(loaded_cfg["server_host"], "127.0.0.1") + + # Expiry check + self.assertTrue(is_cert_expiring_soon(os.path.join(cert_dir, "nonexistent.crt"))) + + def test_client_main_helpers(self): + """Tests Win_Client and Linux_Client config loading and log retrieval fallbacks.""" + logs = Win_Client.get_recent_windows_logs(hours=1, state={}) + self.assertIsInstance(logs, list) + + logs_l = Linux_Client.get_recent_linux_logs(hours=1, state={}) + self.assertIsInstance(logs_l, list) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_clients_deep.py b/tests/test_clients_deep.py new file mode 100644 index 0000000..94fe351 --- /dev/null +++ b/tests/test_clients_deep.py @@ -0,0 +1,109 @@ +""" +Comprehensive unit tests for LOGAR Client Transport, Windows & Linux forwarders, and update checks. +""" + +import os +import sys +import json +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from src.clients import client_transport +from src.clients import Win_Client, Linux_Client + + +class TestClientsDeep(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.cert_dir = os.path.join(self.temp_dir, "certs") + os.makedirs(self.cert_dir, exist_ok=True) + self.config_path = os.path.join(self.temp_dir, "client_config.json") + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_client_transport_config_loader_and_helpers(self): + """Tests client configuration loading, writable directory fallback, and path resolution.""" + sample_config = { + "server_host": "127.0.0.1", + "server_port": 9443, + "hermes_port": 8443, + "cert_dir": self.cert_dir, + "enrollment_secret": "test_enroll_secret_2026", + "auth_token": "token_123", + "ca_cert": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n" + } + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(sample_config, f) + + cfg = client_transport.load_config(self.config_path) + self.assertEqual(cfg["server_host"], "127.0.0.1") + self.assertEqual(cfg["server_port"], 9443) + self.assertTrue(os.path.exists(os.path.join(cfg["cert_dir"], "ca.crt"))) + + # Test cert expiry check on non-existent cert + self.assertTrue(client_transport.is_cert_expiring_soon(os.path.join(self.cert_dir, "nonexistent.crt"))) + + def test_client_transport_ensure_enrolled(self): + """Tests proactive client enrollment bootstrap check.""" + cfg = { + "server_host": "127.0.0.1", + "server_port": 9443, + "hermes_host": "127.0.0.1", + "hermes_port": 8443, + "cert_dir": self.cert_dir, + "enrollment_secret": "secret" + } + + with patch("src.clients.client_transport.enroll_client_if_needed") as mock_enroll: + mock_enroll.return_value = True + ok = client_transport.ensure_enrolled(cfg, "machine_01", os_type="windows") + self.assertTrue(ok) + mock_enroll.assert_called_once() + + def test_client_transport_openpgp_fallback_transmission(self): + """Tests fallback to OpenPGP envelope over plain TCP socket when certs are absent.""" + from src.hub.storage_engine import StorageCryptoManager + priv, pub, fp = StorageCryptoManager.generate_server_keypair("Hub Test Key") + + cfg = { + "server_host": "127.0.0.1", + "server_port": 9443, + "cert_dir": os.path.join(self.temp_dir, "empty_certs"), + "server_public_key": pub, + "server_fingerprint": fp, + "auth_token": "client_token_abc" + } + + mock_socket = MagicMock() + # Mock server returning 4-byte big-endian length + JSON response + resp_data = json.dumps({"status": "success", "processed": 5}).encode("utf-8") + resp_len_header = len(resp_data).to_bytes(4, byteorder="big") + + mock_socket.recv.side_effect = [resp_len_header, resp_data, b""] + + with patch("socket.socket") as mock_sock_cls: + mock_sock_cls.return_value.__enter__.return_value = mock_socket + logs = [{"timestamp": "2026-01-01T00:00:00Z", "event_id": 4625, "message": "Failed logon"}] + res = client_transport.send_encrypted_logs_over_socket(cfg, logs, machine_id="test_win_node") + self.assertEqual(res.get("status"), "success") + self.assertEqual(res.get("processed"), 5) + + def test_win_client_and_linux_client_entrypoints(self): + """Tests command-line parsing, help options, and diagnostic checks.""" + # Test help flag execution + with self.assertRaises(SystemExit) as cm: + with patch("sys.argv", ["Win_Client.py", "--help"]): + Win_Client.main() + self.assertEqual(cm.exception.code, 0) + + with self.assertRaises(SystemExit) as cm: + with patch("sys.argv", ["Linux_Client.py", "--help"]): + Linux_Client.main() + self.assertEqual(cm.exception.code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_common_version.py b/tests/test_common_version.py new file mode 100644 index 0000000..3191460 --- /dev/null +++ b/tests/test_common_version.py @@ -0,0 +1,52 @@ +""" +LOGAR - Comprehensive Unit Tests for Common Version Module +100% test coverage for semantic versioning, version parsing, comparisons, and edge cases. +""" + +import unittest +from src.common.version import ( + LOGAR_VERSION, + parse_version, + compare_versions, + is_newer_version +) + + +class TestCommonVersion(unittest.TestCase): + def test_version_constants(self): + self.assertIsInstance(LOGAR_VERSION, str) + self.assertTrue(len(LOGAR_VERSION) > 0) + + def test_parse_version_valid(self): + self.assertEqual(parse_version("1.2.3.4"), (1, 2, 3, 4)) + self.assertEqual(parse_version("v1.2.3.4"), (1, 2, 3, 4)) + self.assertEqual(parse_version("V1.2.3"), (1, 2, 3, 0)) + self.assertEqual(parse_version("1.2"), (1, 2, 0, 0)) + self.assertEqual(parse_version("1"), (1, 0, 0, 0)) + self.assertEqual(parse_version("1.2.3.4.5"), (1, 2, 3, 4, 5)) + + def test_parse_version_invalid_and_edge_cases(self): + self.assertEqual(parse_version(None), (0, 0, 0, 0)) + self.assertEqual(parse_version(""), (0, 0, 0, 0)) + self.assertEqual(parse_version(" "), (0, 0, 0, 0)) + self.assertEqual(parse_version("invalid"), (0, 0, 0, 0)) + self.assertEqual(parse_version("v1.alpha.2"), (1, 0, 0, 0)) + + def test_compare_versions(self): + self.assertEqual(compare_versions("1.0.0.0", "1.0.0.0"), 0) + self.assertEqual(compare_versions("1.0.0.1", "1.0.0.0"), 1) + self.assertEqual(compare_versions("1.0.0.0", "1.0.0.1"), -1) + self.assertEqual(compare_versions("2.0.0.0", "1.9.9.9"), 1) + self.assertEqual(compare_versions("0.3.5.2", "0.3.5.2"), 0) + self.assertEqual(compare_versions("0.3.6.0", "0.3.5.2"), 1) + self.assertEqual(compare_versions("0.3.5.1", "0.3.5.2"), -1) + + def test_is_newer_version(self): + self.assertTrue(is_newer_version("1.0.0.1", "1.0.0.0")) + self.assertFalse(is_newer_version("1.0.0.0", "1.0.0.0")) + self.assertFalse(is_newer_version("0.9.9.9", "1.0.0.0")) + self.assertTrue(is_newer_version("v0.3.6.0", "0.3.5.2")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_comprehensive.py b/tests/test_hub_comprehensive.py new file mode 100644 index 0000000..137da8c --- /dev/null +++ b/tests/test_hub_comprehensive.py @@ -0,0 +1,201 @@ +""" +LOGAR - Comprehensive Unit Tests for Central Server Hub +Covers License_Authority, auth_manager, ingest_queue, license_manager, pki_manager, +server_enrollment, server_models, storage_engine, update_manager, web_ui, and Server endpoints. +""" + +import os +import sys +import json +import uuid +import time +import tempfile +import unittest +import sqlite3 +from unittest.mock import MagicMock, patch + +from src.hub.server_models import ( + ClientEnrollRequest, + ClientActionRequest, + ClientUpdateRequest, + LicenseImportRequest, + HermesTokenCreateRequest, + HermesTokenRevokeRequest, + ClientProfileExportRequest, + ChangePasswordRequest, + DbConnectionTestRequest, + DbSwitchBackendRequest, + DbFullConfigRequest +) +from src.hub.auth_manager import ( + PasswordManager, + SessionManager, + RateLimiter, + TOTPManager, + HermesTokenManager +) +from src.hub.ingest_queue import IngestQueueManager +from src.hub.license_manager import LicenseManager +from src.hub.pki_manager import PKIManager +from src.hub.storage_engine import StorageEngine +from src.hub.update_manager import ServerUpdateManager +from src.hub.web_ui import WebUIRenderer +import src.hub.Server as Server +import src.hub.server_enrollment as enrollment + + +class TestHubComprehensive(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.test_db = os.path.join(self.test_dir, f"test_hub_{uuid.uuid4().hex[:8]}.db") + self.cert_dir = os.path.join(self.test_dir, "certs") + self.updates_dir = os.path.join(self.test_dir, "updates") + + StorageEngine.init_db(self.test_db, max_seats=10) + self.update_mgr = ServerUpdateManager(update_dir=self.updates_dir, check_enabled=False) + Server.SERVER_STATE["update_manager"] = self.update_mgr + Server.SERVER_STATE["config"] = { + "db_path": self.test_db, + "cert_dir": self.cert_dir, + "server_name": "LOGAR-Test-Hub", + "server_fingerprint": "TEST_FP_123", + "tcp_host": "127.0.0.1", + "tcp_port": 9443, + "hermes_port": 8443, + "auth_token": "test_auth_token_secret" + } + + def tearDown(self): + import shutil + if os.path.exists(self.test_dir): + try: + shutil.rmtree(self.test_dir) + except Exception: + pass + + def test_server_models(self): + """Tests Pydantic validation on all Server Hub data models.""" + enr = ClientEnrollRequest(client_id="c1", hostname="host1", os="windows", enrollment_secret="sec") + self.assertEqual(enr.client_id, "c1") + + act = ClientActionRequest(client_id="c1") + self.assertEqual(act.client_id, "c1") + + upd = ClientUpdateRequest(client_id="c1", os="windows", current_version="0.3.5.0") + self.assertEqual(upd.current_version, "0.3.5.0") + + lic_imp = LicenseImportRequest(ca_cert="PEM_CERT_DATA") + self.assertEqual(lic_imp.ca_cert, "PEM_CERT_DATA") + + htc = HermesTokenCreateRequest(name="agent1") + self.assertEqual(htc.name, "agent1") + + htr = HermesTokenRevokeRequest(token="tok123") + self.assertEqual(htr.token, "tok123") + + cpe = ClientProfileExportRequest(server_host="10.0.0.1") + self.assertEqual(cpe.server_host, "10.0.0.1") + + cp = ChangePasswordRequest(old_password="old", new_password="new") + self.assertEqual(cp.new_password, "new") + + dct = DbConnectionTestRequest(host="localhost", port=5432, database="logar") + self.assertEqual(dct.port, 5432) + + dfc = DbFullConfigRequest(storage_engine="sqlite", cache_mode="direct") + self.assertEqual(dfc.storage_engine, "sqlite") + + def test_auth_manager_edge_cases(self): + """Tests password hashing, TOTP secret generation, and rate limiting edge cases.""" + # Password + h, s = PasswordManager.hash_password("admin_password") + self.assertTrue(PasswordManager.verify_password("admin_password", s, h)) + self.assertFalse(PasswordManager.verify_password("wrong_password", s, h)) + + # Session token + tok = SessionManager.generate_session_token("admin", db_path=self.test_db) + self.assertEqual(SessionManager.verify_session_token(tok, db_path=self.test_db), "admin") + self.assertIsNone(SessionManager.verify_session_token("invalid_token", db_path=self.test_db)) + + # TOTP Manager + sec = TOTPManager.generate_totp_secret(length=20) + self.assertTrue(len(sec) >= 20) + code = TOTPManager.get_totp_token(sec) + self.assertTrue(len(code) == 6) + self.assertTrue(TOTPManager.verify_totp_code(sec, code)) + uri = TOTPManager.get_totp_uri("admin", sec) + self.assertIn("otpauth://totp/", uri) + + # Hermes Token Manager + ht = HermesTokenManager.create_hermes_token("Agent 1", db_path=self.test_db) + self.assertIn("token", ht) + self.assertTrue(HermesTokenManager.verify_hermes_token(ht["token"], db_path=self.test_db)) + tokens = HermesTokenManager.list_hermes_tokens(db_path=self.test_db) + self.assertTrue(len(tokens) >= 1) + HermesTokenManager.revoke_hermes_token(ht["token"], db_path=self.test_db) + self.assertFalse(HermesTokenManager.verify_hermes_token(ht["token"], db_path=self.test_db)) + HermesTokenManager.delete_hermes_token(ht["token"], db_path=self.test_db) + + def test_pki_and_server_enrollment(self): + """Tests dynamic CA, Server, and client certificate issuance and verification.""" + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=self.cert_dir) + self.assertIsNotNone(ca_cert) + self.assertTrue(os.path.exists(os.path.join(self.cert_dir, "ca.crt"))) + + # Issue server cert + srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed( + ca_cert, ca_key, + hostnames=["127.0.0.1", "localhost"], + cert_dir=self.cert_dir + ) + self.assertIsNotNone(srv_cert) + + # Issue client cert + cl_pem, cl_key_pem = enrollment.issue_client_cert( + client_id="client-hub-test", + ca_cert=ca_cert, + ca_key=ca_key + ) + self.assertIn("BEGIN CERTIFICATE", cl_pem) + self.assertIn("BEGIN RSA PRIVATE KEY", cl_key_pem) + + # Validate renewal logic + ca_ren, srv_ren = enrollment.check_and_renew_hub_pki(cert_dir=self.cert_dir, hostnames=["127.0.0.1"]) + self.assertFalse(ca_ren) + self.assertFalse(srv_ren) + rotated = PKIManager.check_and_rotate_server_certs(cert_dir=self.cert_dir, hostnames=["127.0.0.1"]) + self.assertFalse(rotated) + + def test_storage_engine_sqlite_and_migrations(self): + """Tests StorageEngine table initialization, column migrations, and data persistence.""" + db_path = StorageEngine.init_db(self.test_db) + self.assertTrue(os.path.exists(db_path)) + + # Check tables + conn = sqlite3.connect(db_path) + tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + conn.close() + + self.assertIn("active_issues", tables) + self.assertIn("clients", tables) + self.assertIn("license_config", tables) + self.assertIn("users", tables) + self.assertIn("hermes_tokens", tables) + + def test_web_ui_renderer_all_tabs(self): + """Tests rendering of all WebUI tabs: Fleet, Hermes, Anomalies, PKI, DB, Updates.""" + html = WebUIRenderer.render_dashboard_html( + server_state=Server.SERVER_STATE, + ingest_queue=Server.INGEST_QUEUE + ) + self.assertIn("LOGAR Fleet Hub", html) + self.assertIn("tabPane-fleet", html) + self.assertIn("tabPane-hermes", html) + self.assertIn("tabPane-anomalies", html) + self.assertIn("tabPane-pki", html) + self.assertIn("tabPane-db", html) + self.assertIn("tabPane-updates", html) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_enrollment_pki_deep.py b/tests/test_hub_enrollment_pki_deep.py new file mode 100644 index 0000000..2124f42 --- /dev/null +++ b/tests/test_hub_enrollment_pki_deep.py @@ -0,0 +1,109 @@ +""" +Comprehensive unit tests for dynamic PKI, Sub-CA issuance, offline CSR activation, and LicenseAuthority. +""" + +import os +import shutil +import tempfile +import sqlite3 +import unittest +from unittest.mock import MagicMock, patch + +from src.hub import server_enrollment as enrollment +from src.hub.pki_manager import PKIManager +from src.hub import License_Authority + + +class TestHubEnrollmentPKIDeep(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.cert_dir = os.path.join(self.temp_dir, "certs") + os.makedirs(self.cert_dir, exist_ok=True) + self.db_path = os.path.join(self.temp_dir, "logar_test.db") + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_pki_lifecycle_and_extensions(self): + """Tests CA generation, Server TLS cert generation, client cert issuance, and license OID parsing.""" + # 1. CA Generation + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=self.cert_dir) + self.assertIsNotNone(ca_cert) + self.assertTrue(os.path.exists(os.path.join(self.cert_dir, "ca.crt"))) + self.assertTrue(os.path.exists(os.path.join(self.cert_dir, "ca.key"))) + + # 2. Server TLS Certificate Generation + srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed( + ca_cert=ca_cert, + ca_key=ca_key, + hostnames=["127.0.0.1", "localhost"], + cert_dir=self.cert_dir + ) + self.assertIsNotNone(srv_cert) + self.assertTrue(os.path.exists(os.path.join(self.cert_dir, "server.crt"))) + self.assertTrue(os.path.exists(os.path.join(self.cert_dir, "server.key"))) + + # 3. Client Certificate Issuance + cl_pem, cl_key_pem = enrollment.issue_client_cert( + client_id="forwarder-node-01", + ca_cert=ca_cert, + ca_key=ca_key, + days_valid=365 + ) + self.assertIn("BEGIN CERTIFICATE", cl_pem) + self.assertIn("BEGIN RSA PRIVATE KEY", cl_key_pem) + + # 4. Extract license info + info = enrollment.extract_license_info_from_cert(ca_pem) + self.assertIn("is_self_signed", info) + + # 5. Check cert expiry helper + self.assertFalse(enrollment.is_cert_expiring_soon(ca_pem, threshold_days=30)) + + # 6. Initialize mTLS Server context + ctx = PKIManager.init_mtls_server_context(cert_dir=self.cert_dir) + self.assertIsNotNone(ctx) + + # 7. Reload mTLS context + PKIManager.reload_mtls_context(ctx, cert_dir=self.cert_dir) + + def test_offline_csr_and_activation(self): + """Tests export of offline license request CSR and activation bundle unpack.""" + csr_pem, csr_key_pem = enrollment.generate_ca_csr(cert_dir=self.cert_dir) + self.assertIn("BEGIN CERTIFICATE REQUEST", csr_pem) + + # Sign the CSR locally as Sub-CA for test + ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=self.cert_dir) + sub_ca_pem = enrollment.sign_ca_csr_with_seats( + csr_pem=csr_pem, + master_ca_cert_pem=ca_pem, + master_ca_key_pem=ca_key_pem, + seats=25, + days_valid=365, + license_id="LIC-TEST-2026", + features=["postgres", "realtime_alerting"] + ) + self.assertIn("BEGIN CERTIFICATE", sub_ca_pem) + + # Verify signed Sub-CA contains license extensions + info = enrollment.extract_license_info_from_cert(sub_ca_pem) + self.assertEqual(info.get("seats"), 25) + self.assertEqual(info.get("license_id"), "LIC-TEST-2026") + self.assertIn("postgres", info.get("features", [])) + + # Test create_license_request_lic + lic_data, lic_json = enrollment.create_license_request_lic(cert_dir=self.cert_dir) + self.assertEqual(lic_data["format"], "LOGAR_LICENSE_REQUEST") + self.assertIn("csr", lic_data) + + def test_license_authority_cli_dispatch(self): + """Tests License Authority helper and CLI options.""" + # Test help flag execution + with self.assertRaises(SystemExit) as cm: + with patch("sys.argv", ["License_Authority.py", "--help"]): + License_Authority.main() + self.assertEqual(cm.exception.code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_server_deep.py b/tests/test_hub_server_deep.py new file mode 100644 index 0000000..738a8d5 --- /dev/null +++ b/tests/test_hub_server_deep.py @@ -0,0 +1,149 @@ +""" +Comprehensive unit tests for LOGAR Server FastAPI endpoints, state management, and log processing. +""" + +import os +import sys +import json +import shutil +import tempfile +import sqlite3 +import unittest +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient + +from src.hub import Server +from src.hub.auth_manager import SessionManager + + +class TestHubServerDeep(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.cert_dir = os.path.join(self.temp_dir, "certs") + os.makedirs(self.cert_dir, exist_ok=True) + self.db_path = os.path.join(self.temp_dir, "test_server.db") + self.config_path = os.path.join(self.temp_dir, "server_config.json") + + # Initialize test state + Server.StorageEngine.init_db(self.db_path, enrollment_secret="test_secret", max_seats=50) + Server.SERVER_STATE["config"] = { + "db_path": self.db_path, + "enrollment_secret": "test_secret", + "max_seats": 50, + "cert_dir": self.cert_dir, + "encrypt_at_rest": False + } + # Generate and load CA + ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=self.cert_dir) + Server.SERVER_STATE["ca_cert"] = ca_cert + Server.SERVER_STATE["ca_key"] = ca_key + Server.SERVER_STATE["ca_cert_pem"] = ca_pem + + self.session_token = SessionManager.generate_session_token("admin", db_path=self.db_path) + self.client = TestClient(Server.app, cookies={SessionManager.SESSION_COOKIE_NAME: self.session_token}) + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_server_status_and_update_routes(self): + """Tests /api/update/status and /api/server/update/status routes.""" + resp = self.client.get("/api/update/status") + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn("hosted_packages", data) + + def test_client_enrollment_and_action_endpoints(self): + """Tests /api/client/enroll and /api/client/revoke/approve/delete endpoints.""" + # Ensure license in DB is marked active with seats + conn = sqlite3.connect(self.db_path) + conn.execute("UPDATE license_config SET max_seats = 50, license_status = 'active' WHERE id = 1") + conn.commit() + conn.close() + + # Enroll client + enroll_payload = { + "client_id": "test_agent_deep_01", + "hostname": "workstation-01", + "os": "windows", + "enrollment_secret": "test_secret" + } + resp = self.client.post("/api/client/enroll", json=enroll_payload) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data.get("status"), "success") + self.assertIn("client_cert", data) + self.assertIn("client_key", data) + + # Check client list + resp_list = self.client.get("/api/clients") + self.assertEqual(resp_list.status_code, 200) + clients_data = resp_list.json() + self.assertGreaterEqual(clients_data.get("active_seats", 0), 1) + + # Client action: revoke + resp_rev = self.client.post("/api/client/revoke", json={"client_id": "test_agent_deep_01"}) + self.assertEqual(resp_rev.status_code, 200) + self.assertEqual(resp_rev.json().get("status"), "revoked") + + # Reactivate + resp_app = self.client.post("/api/client/approve", json={"client_id": "test_agent_deep_01"}) + self.assertEqual(resp_app.status_code, 200) + self.assertEqual(resp_app.json().get("status"), "active") + + # Delete + resp_del = self.client.post("/api/client/delete", json={"client_id": "test_agent_deep_01"}) + self.assertEqual(resp_del.status_code, 200) + + def test_hermes_tokens_and_auth_routes(self): + """Tests Hermes API token creation, listing, and revoking.""" + # Create token + tok_payload = {"name": "Test Integration Agent"} + resp = self.client.post("/api/hermes/tokens/create", json=tok_payload) + self.assertEqual(resp.status_code, 200) + tok_data = resp.json() + token_val = tok_data.get("token") + self.assertIsNotNone(token_val) + + # Revoke token + resp_rev = self.client.post("/api/hermes/tokens/revoke", json={"token": token_val}) + self.assertEqual(resp_rev.status_code, 200) + + def test_process_ingested_logs_and_temporal_tracking(self): + """Tests processing ingested log entries, deduplication, and anomaly promotion.""" + payload = { + "server": "web-prod-01", + "timestamp": "2026-01-01T12:00:00Z", + "logs": [ + { + "site_name": "Default-Site", + "server": "web-prod-01", + "signature": "SECURITY_4625_BRUTEFORCE", + "severity": "CRITICAL", + "message": "Repeated failed logon attempts detected from 10.0.0.99", + "os_type": "windows", + "timestamp": "2026-01-01T12:00:00Z" + } + ] + } + res = Server.process_ingested_logs( + payload=payload, + db_path=self.db_path, + window_hours=24, + min_runs=1 + ) + self.assertEqual(res.get("processed"), 1) + + # Query database directly to confirm issue recorded + conn = sqlite3.connect(self.db_path) + c = conn.cursor() + c.execute("SELECT signature, severity, run_count FROM active_issues") + row = c.fetchone() + conn.close() + self.assertIsNotNone(row) + self.assertEqual(row[0], "SECURITY_4625_BRUTEFORCE") + self.assertEqual(row[1], "CRITICAL") + self.assertEqual(row[2], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_storage_deep.py b/tests/test_hub_storage_deep.py new file mode 100644 index 0000000..379b0ea --- /dev/null +++ b/tests/test_hub_storage_deep.py @@ -0,0 +1,141 @@ +""" +Comprehensive unit tests for LOGAR StorageEngine, StorageCryptoManager, and Multi-Backend Migrations. +""" + +import os +import sys +import json +import shutil +import tempfile +import sqlite3 +import unittest +from unittest.mock import MagicMock, patch + +from src.hub.storage_engine import StorageEngine, StorageCryptoManager + + +class TestStorageEngineDeep(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.db_path = os.path.join(self.temp_dir, "test_logar.db") + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_storage_crypto_manager_keypair_and_encryption(self): + """Tests keypair generation, field encryption, decryption, and error resilience.""" + priv, pub, fp = StorageCryptoManager.generate_server_keypair("Test Server Key") + self.assertIn("BEGIN PGP PRIVATE KEY", priv) + self.assertIn("BEGIN PGP PUBLIC KEY", pub) + self.assertTrue(len(fp) > 0) + + server_state = { + "config": { + "encrypt_at_rest": True, + "public_key": pub, + "private_key": priv + } + } + + # Test empty or non-string inputs + self.assertEqual(StorageCryptoManager.encrypt_field_for_storage("", server_state=server_state, is_unlocked=True), "") + self.assertEqual(StorageCryptoManager.encrypt_field_for_storage(None, server_state=server_state, is_unlocked=True), None) + self.assertEqual(StorageCryptoManager.decrypt_field_from_storage("", server_state=server_state), "") + self.assertEqual(StorageCryptoManager.decrypt_field_from_storage(None, server_state=server_state), None) + + # Test non-encrypted plain string decrypt passthrough + plain_msg = "Security Audit Message 2026" + self.assertEqual(StorageCryptoManager.decrypt_field_from_storage(plain_msg, server_state=server_state), plain_msg) + + # Encrypt when unlocked + encrypted = StorageCryptoManager.encrypt_field_for_storage(plain_msg, server_state=server_state, is_unlocked=True) + self.assertTrue(encrypted.startswith("-----BEGIN PGP MESSAGE-----")) + + # Encrypt idempotence (should not double-encrypt) + double_enc = StorageCryptoManager.encrypt_field_for_storage(encrypted, server_state=server_state, is_unlocked=True) + self.assertEqual(double_enc, encrypted) + + # Decrypt + decrypted = StorageCryptoManager.decrypt_field_from_storage(encrypted, server_state=server_state) + self.assertEqual(decrypted, plain_msg) + + # Decrypt with state missing private_key_obj (reloads from blob) + clean_state = {"config": {"private_key": priv}} + decrypted_from_clean = StorageCryptoManager.decrypt_field_from_storage(encrypted, server_state=clean_state) + self.assertEqual(decrypted_from_clean, plain_msg) + + # Encrypt when locked / encrypt_at_rest is false + unenc_state = {"config": {"encrypt_at_rest": False, "public_key": pub}} + res_plain = StorageCryptoManager.encrypt_field_for_storage(plain_msg, server_state=unenc_state, is_unlocked=False) + self.assertEqual(res_plain, plain_msg) + + def test_storage_engine_sqlite_init_and_telemetry(self): + """Tests SQLite database initialization, WAL settings, column migrations, and telemetry.""" + db = StorageEngine.init_db(self.db_path, enrollment_secret="sec-test-123", max_seats=50) + self.assertEqual(db, self.db_path) + self.assertTrue(os.path.exists(db)) + + # Re-running init_db executes migrations smoothly + db_reinit = StorageEngine.init_db(self.db_path, enrollment_secret="sec-test-123", max_seats=50) + self.assertEqual(db_reinit, self.db_path) + + # Check telemetry info + state = {"config": {"db_path": self.db_path, "db_type": "sqlite"}} + info = StorageEngine.get_storage_engine_info(self.db_path, server_state=state, is_unlocked=True) + self.assertEqual(info["engine"], "sqlite") + self.assertFalse(info["is_enterprise"]) + self.assertIn("active_issues", info["table_stats"]) + self.assertIn("clients", info["table_stats"]) + + def test_storage_engine_postgres_mocked(self): + """Tests Postgres schema initialization and SQLite-to-Postgres data migration with mocked drivers.""" + pg_cfg = { + "host": "127.0.0.1", + "port": 5432, + "database": "logar_test", + "user": "logar_user", + "password": "secret_password", + "sslmode": "disable" + } + + # Initialize SQLite DB with test records + StorageEngine.init_db(self.db_path, enrollment_secret="sec-migrate", max_seats=10) + conn = sqlite3.connect(self.db_path) + conn.execute(""" + INSERT INTO active_issues (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) + VALUES ('fp1', 'SiteA', 'srv1', 'Sig1', 'HIGH', 'Test warning message', 'linux', '2026-01-01', '2026-01-02', 1, 'active', 'run1') + """) + conn.commit() + conn.close() + + # Mock psycopg2 + mock_psycopg2 = MagicMock() + mock_conn = MagicMock() + mock_cur = MagicMock() + mock_conn.cursor.return_value.__enter__.return_value = mock_cur + mock_psycopg2.connect.return_value = mock_conn + + with patch.dict(sys.modules, {"psycopg2": mock_psycopg2}): + # Test Postgres DB init + ok, msg = StorageEngine.init_postgres_db(pg_cfg) + self.assertTrue(ok) + self.assertIn("successfully initialized", msg) + + # Test Migration + encrypt_mock = lambda x: f"ENC({x})" + ok, msg, counts = StorageEngine.migrate_sqlite_to_postgres(self.db_path, pg_cfg, encrypt_fn=encrypt_mock) + self.assertTrue(ok) + self.assertIn("active_issues", counts) + self.assertEqual(counts["active_issues"], 1) + + # Test Postgres info retrieval + mock_cur.fetchone.return_value = (10,) + state = {"config": {"db_type": "postgres", "postgres": pg_cfg}} + info = StorageEngine.get_storage_engine_info(None, server_state=state, is_unlocked=True) + self.assertEqual(info["engine"], "postgres") + self.assertTrue(info["is_enterprise"]) + self.assertEqual(info["table_stats"]["active_issues"], 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_update_auth_deep.py b/tests/test_hub_update_auth_deep.py new file mode 100644 index 0000000..807561c --- /dev/null +++ b/tests/test_hub_update_auth_deep.py @@ -0,0 +1,150 @@ +""" +Comprehensive unit tests for LOGAR UpdateManager, AuthManager, and IngestQueue. +""" + +import os +import sys +import time +import shutil +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from src.hub.update_manager import ServerUpdateManager +from src.hub.auth_manager import PasswordManager, TOTPManager, SessionManager, RateLimiter +from src.hub.ingest_queue import IngestQueueManager + + +class TestHubUpdateAuthDeep(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.update_dir = os.path.join(self.temp_dir, "updates") + self.db_path = os.path.join(self.temp_dir, "auth_test.db") + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_update_manager_caching_and_manifest(self): + """Tests package registration, SHA256 integrity, local caching, and download serving.""" + manager = ServerUpdateManager(update_dir=self.update_dir, check_enabled=False) + + # Create dummy binaries + win_bin = os.path.join(self.temp_dir, "Win_Client.exe") + linux_bin = os.path.join(self.temp_dir, "Linux_Client") + with open(win_bin, "wb") as f: + f.write(b"MZ_TEST_WIN_CLIENT_BINARY_2026") + with open(linux_bin, "wb") as f: + f.write(b"\x7fELF_TEST_LINUX_CLIENT_BINARY_2026") + + # Register packages + res_win = manager.register_client_package( + os_type="windows", + file_path=win_bin, + version="2.5.0", + filename="Win_Client.exe" + ) + self.assertEqual(res_win.get("version"), "2.5.0") + self.assertEqual(res_win.get("os"), "windows") + self.assertIsNotNone(res_win.get("sha256")) + + res_linux = manager.register_client_package( + os_type="linux", + file_path=linux_bin, + version="2.5.0", + filename="Linux_Client" + ) + self.assertEqual(res_linux.get("os"), "linux") + + # Get hosted packages + packages = manager.get_hosted_packages() + self.assertIn("windows", packages) + self.assertIn("linux", packages) + self.assertEqual(packages["windows"]["version"], "2.5.0") + + # Test download path resolution + pkg_res = manager.get_client_package_path(os_type="windows") + self.assertIsNotNone(pkg_res) + win_path, win_filename, win_sha = pkg_res + self.assertTrue(os.path.exists(win_path)) + self.assertEqual(win_filename, "Win_Client.exe") + + # Test update decision logic + decision = manager.check_client_update(client_id="node-win-01", current_version="1.0.0", os_type="windows") + self.assertTrue(decision.get("update_available")) + self.assertEqual(decision.get("latest_version"), "2.5.0") + + decision_uptodate = manager.check_client_update(client_id="node-win-01", current_version="2.5.0", os_type="windows") + self.assertFalse(decision_uptodate.get("update_available")) + + def test_auth_manager_components(self): + """Tests PasswordManager, TOTPManager, SessionManager, and RateLimiter.""" + # 1. PasswordManager + pwd = "Enterprise_Admin_Password_!2026" + pwd_hash, salt = PasswordManager.hash_password(pwd) + self.assertTrue(PasswordManager.verify_password(pwd, salt, pwd_hash)) + self.assertFalse(PasswordManager.verify_password("WrongPassword", salt, pwd_hash)) + + # 2. TOTPManager + secret = TOTPManager.generate_totp_secret() + self.assertTrue(len(secret) >= 16) + uri = TOTPManager.get_totp_uri("admin", secret, "LOGAR Fleet Hub") + self.assertIn("otpauth://totp/", uri) + + token = TOTPManager.get_totp_token(secret) + self.assertEqual(len(token), 6) + self.assertTrue(TOTPManager.verify_totp_code(secret, token)) + self.assertFalse(TOTPManager.verify_totp_code(secret, "000000" if token != "000000" else "111111")) + + # 3. SessionManager + session_token = SessionManager.generate_session_token("admin", db_path=self.db_path) + self.assertIsNotNone(session_token) + username = SessionManager.verify_session_token(session_token, db_path=self.db_path) + self.assertEqual(username, "admin") + self.assertIsNone(SessionManager.verify_session_token("invalid:token:format", db_path=self.db_path)) + + # 4. RateLimiter + limiter = RateLimiter() + ip = "192.168.1.100" + for _ in range(5): + self.assertTrue(limiter.check_ip_rate_limit(ip, max_attempts=5, window_seconds=60, db_path=self.db_path)) + limiter.record_failed_ip_attempt(ip, db_path=self.db_path) + # 6th attempt exceeded + self.assertFalse(limiter.check_ip_rate_limit(ip, max_attempts=5, window_seconds=60, db_path=self.db_path)) + + # Reset + limiter.reset_failed_ip_attempts(ip, db_path=self.db_path) + self.assertTrue(limiter.check_ip_rate_limit(ip, max_attempts=5, window_seconds=60, db_path=self.db_path)) + + def test_ingest_queue_lifecycle_and_batching(self): + """Tests IngestQueueManager push, stats, and flush callback.""" + processed_items = [] + + def mock_processor(payload, db_path, window_hours, min_runs): + processed_items.extend(payload.get("logs", [])) + return {"processed": len(payload.get("logs", [])), "promoted_verified": 0} + + queue = IngestQueueManager(flush_interval=0.05, batch_size=10, max_queue=100) + + # Enqueue items + for i in range(15): + pushed = queue.push({"server": "node1", "logs": [{"id": i, "data": f"log_{i}"}]}) + self.assertTrue(pushed) + + stats = queue.get_stats() + self.assertEqual(stats["queue_size"], 15) + self.assertEqual(stats["total_ingested"], 15) + + # Flush + flush_res = queue.flush(db_path=self.db_path, process_callback=mock_processor) + self.assertEqual(flush_res["status"], "success") + self.assertEqual(flush_res["flushed_runs"], 15) + self.assertEqual(flush_res["processed_logs"], 15) + self.assertEqual(len(processed_items), 15) + + stats_after = queue.get_stats() + self.assertEqual(stats_after["queue_size"], 0) + self.assertEqual(stats_after["total_flushed"], 15) + + +if __name__ == "__main__": + unittest.main()