CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m28s
2456 lines
99 KiB
Python
2456 lines
99 KiB
Python
"""
|
|
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() |