4874 lines
221 KiB
Python
4874 lines
221 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import uuid
|
|
import struct
|
|
import socket
|
|
import sqlite3
|
|
import argparse
|
|
import asyncio
|
|
import secrets
|
|
import hashlib
|
|
import hmac
|
|
import base64
|
|
import urllib.parse
|
|
import time
|
|
import html
|
|
import warnings
|
|
import threading
|
|
from datetime import datetime, timezone, timedelta
|
|
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
|
|
from pgpy.constants import (
|
|
PubKeyAlgorithm,
|
|
KeyFlags,
|
|
HashAlgorithm,
|
|
SymmetricKeyAlgorithm,
|
|
CompressionAlgorithm
|
|
)
|
|
import ssl
|
|
import subprocess
|
|
from pydantic import BaseModel
|
|
from fastapi import FastAPI, HTTPException, Request, Depends
|
|
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
|
import uvicorn
|
|
|
|
try:
|
|
import win32evtlog
|
|
except ImportError:
|
|
win32evtlog = None
|
|
|
|
try:
|
|
from src import server_enrollment as enrollment
|
|
except ImportError:
|
|
import server_enrollment as enrollment
|
|
|
|
try:
|
|
from src.log_collector import (
|
|
get_machine_identifier,
|
|
WindowsLogCollector,
|
|
LinuxLogCollector,
|
|
get_log_collector
|
|
)
|
|
except ImportError:
|
|
from log_collector import (
|
|
get_machine_identifier,
|
|
WindowsLogCollector,
|
|
LinuxLogCollector,
|
|
get_log_collector
|
|
)
|
|
|
|
CONFIG_FILE_NAME = "server_config.json"
|
|
DEFAULT_DB_FILE = "logar_state.db"
|
|
EVALUATION_WINDOW_HOURS = 12
|
|
RUN_THRESHOLD = 1
|
|
|
|
SESSION_COOKIE_NAME = "logar_server_session"
|
|
_CACHED_SESSION_SECRETS: Dict[str, str] = {}
|
|
|
|
|
|
def get_session_secret(db_path: Optional[str] = None) -> str:
|
|
"""Retrieves or persists a cryptographically secure session secret in the database."""
|
|
env_sec = os.environ.get("LOGAR_SESSION_SECRET")
|
|
if env_sec:
|
|
return env_sec
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
|
|
abs_db = os.path.abspath(target_db)
|
|
if abs_db in _CACHED_SESSION_SECRETS:
|
|
return _CACHED_SESSION_SECRETS[abs_db]
|
|
try:
|
|
conn = sqlite3.connect(target_db, timeout=5.0)
|
|
c = conn.cursor()
|
|
c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'")
|
|
row = c.fetchone()
|
|
if row and row[0]:
|
|
_CACHED_SESSION_SECRETS[abs_db] = row[0]
|
|
conn.close()
|
|
return row[0]
|
|
new_secret = secrets.token_hex(32)
|
|
c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,))
|
|
conn.commit()
|
|
conn.close()
|
|
_CACHED_SESSION_SECRETS[abs_db] = new_secret
|
|
return new_secret
|
|
except Exception:
|
|
fallback = secrets.token_hex(32)
|
|
_CACHED_SESSION_SECRETS[abs_db] = fallback
|
|
return fallback
|
|
|
|
|
|
SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET") or secrets.token_hex(32)
|
|
DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin")
|
|
DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!")
|
|
|
|
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="3.0.0")
|
|
|
|
# Global context holding server state
|
|
SERVER_STATE: Dict[str, Any] = {}
|
|
|
|
|
|
class ClientEnrollRequest(BaseModel):
|
|
client_id: str
|
|
hostname: str
|
|
os: str
|
|
enrollment_secret: str
|
|
|
|
|
|
class ClientActionRequest(BaseModel):
|
|
client_id: str
|
|
|
|
|
|
class LicenseImportRequest(BaseModel):
|
|
ca_cert: str
|
|
upstream_ca: Optional[str] = None
|
|
|
|
|
|
class HermesTokenCreateRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class HermesTokenRevokeRequest(BaseModel):
|
|
token: str
|
|
|
|
|
|
class ClientProfileExportRequest(BaseModel):
|
|
server_host: Optional[str] = None
|
|
server_port: Optional[int] = None
|
|
client_id: Optional[str] = None
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
old_password: str
|
|
new_password: str
|
|
|
|
|
|
class DbConnectionTestRequest(BaseModel):
|
|
host: str = "localhost"
|
|
port: int = 5432
|
|
database: str = "logar"
|
|
user: str = "postgres"
|
|
password: str = ""
|
|
sslmode: str = "prefer"
|
|
connect_timeout: int = 5
|
|
pool_size: int = 10
|
|
|
|
|
|
class DbSwitchBackendRequest(BaseModel):
|
|
db_type: str = "sqlite" # "sqlite" | "postgres"
|
|
host: Optional[str] = "localhost"
|
|
port: Optional[int] = 5432
|
|
database: Optional[str] = "logar"
|
|
user: Optional[str] = "postgres"
|
|
password: Optional[str] = ""
|
|
sslmode: Optional[str] = "prefer"
|
|
connect_timeout: Optional[int] = 5
|
|
pool_size: Optional[int] = 10
|
|
db_path: Optional[str] = None
|
|
|
|
|
|
class DbFullConfigRequest(BaseModel):
|
|
storage_engine: str = "sqlite" # "sqlite" | "postgres"
|
|
cache_mode: str = "direct" # "direct" | "buffered"
|
|
flush_interval: float = 2.0
|
|
batch_size: int = 100
|
|
max_queue: int = 50000
|
|
encrypt_at_rest: bool = False
|
|
pg_config: Optional[DbConnectionTestRequest] = None
|
|
|
|
|
|
class IngestQueueManager:
|
|
"""
|
|
High-throughput in-memory buffering and batch processor for incoming telemetry and log anomalies.
|
|
Eliminates SQLite disk write contention by decoupling TCP frame ingress from physical DB writes.
|
|
"""
|
|
def __init__(self, flush_interval: float = 1.0, batch_size: int = 100, max_queue: int = 50000):
|
|
self.flush_interval = flush_interval
|
|
self.batch_size = batch_size
|
|
self.max_queue = max_queue
|
|
self._lock = threading.Lock()
|
|
self._buffer: List[Dict[str, Any]] = []
|
|
self._total_ingested = 0
|
|
self._total_flushed = 0
|
|
self._dropped_count = 0
|
|
self._last_flush_time = "Never"
|
|
self._avg_flush_latency_ms = 0.0
|
|
self._flush_count = 0
|
|
|
|
def push(self, payload: Dict[str, Any]) -> bool:
|
|
with self._lock:
|
|
if len(self._buffer) >= self.max_queue:
|
|
self._dropped_count += 1
|
|
return False
|
|
self._buffer.append(payload)
|
|
self._total_ingested += len(payload.get("logs", []))
|
|
return True
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
with self._lock:
|
|
return {
|
|
"queue_size": len(self._buffer),
|
|
"total_ingested": self._total_ingested,
|
|
"total_flushed": self._total_flushed,
|
|
"dropped_count": self._dropped_count,
|
|
"last_flush_time": self._last_flush_time,
|
|
"avg_flush_latency_ms": round(self._avg_flush_latency_ms, 2),
|
|
"flush_count": self._flush_count,
|
|
"flush_interval_seconds": self.flush_interval,
|
|
"batch_size_threshold": self.batch_size
|
|
}
|
|
|
|
def flush(self, db_path: Optional[str] = None, window_hours: int = 12, min_runs: int = 1) -> Dict[str, Any]:
|
|
with self._lock:
|
|
if not self._buffer:
|
|
return {"status": "noop", "flushed_runs": 0, "processed_logs": 0}
|
|
items_to_process = list(self._buffer)
|
|
self._buffer.clear()
|
|
|
|
start_time = time.time()
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
|
|
total_processed = 0
|
|
promoted_total = 0
|
|
|
|
try:
|
|
for payload in items_to_process:
|
|
res = process_ingested_logs(
|
|
payload=payload,
|
|
db_path=target_db,
|
|
window_hours=window_hours,
|
|
min_runs=min_runs
|
|
)
|
|
total_processed += res.get("processed", 0)
|
|
promoted_total += res.get("promoted_verified", 0)
|
|
except Exception as e:
|
|
print(f"[!] Error during batch flush to DB: {e}")
|
|
|
|
elapsed_ms = (time.time() - start_time) * 1000.0
|
|
with self._lock:
|
|
self._total_flushed += total_processed
|
|
self._flush_count += 1
|
|
self._avg_flush_latency_ms = (
|
|
(self._avg_flush_latency_ms * (self._flush_count - 1) + elapsed_ms) / self._flush_count
|
|
)
|
|
self._last_flush_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
|
|
return {
|
|
"status": "success",
|
|
"flushed_runs": len(items_to_process),
|
|
"processed_logs": total_processed,
|
|
"promoted_verified": promoted_total,
|
|
"flush_latency_ms": round(elapsed_ms, 2)
|
|
}
|
|
|
|
|
|
INGEST_QUEUE = IngestQueueManager()
|
|
|
|
|
|
# --- Rate Limiting & Brute-Force Mitigation ---
|
|
|
|
_RATE_LIMIT_LOCK = threading.Lock()
|
|
_LOGIN_FAILURES: Dict[str, List[float]] = {}
|
|
_ENROLL_FAILURES: Dict[str, List[float]] = {}
|
|
|
|
|
|
def get_client_ip(request: Optional[Request] = None) -> str:
|
|
"""Extracts client IP address respecting reverse-proxy headers."""
|
|
if not request:
|
|
return "127.0.0.1"
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
real_ip = request.headers.get("x-real-ip")
|
|
if real_ip:
|
|
return real_ip.strip()
|
|
if request.client:
|
|
return request.client.host
|
|
return "127.0.0.1"
|
|
|
|
|
|
def check_ip_rate_limit(
|
|
ip: str,
|
|
store: Any = None,
|
|
max_attempts: int = 10,
|
|
window_seconds: int = 60,
|
|
scope: str = "login",
|
|
db_path: Optional[str] = None
|
|
) -> bool:
|
|
"""Checks whether the client IP has exceeded failed attempts within the sliding time window using persistent DB storage."""
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
cutoff = now - window_seconds
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
|
|
try:
|
|
conn = sqlite3.connect(target_db, timeout=5.0)
|
|
c = conn.cursor()
|
|
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
|
|
c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,))
|
|
c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff))
|
|
cnt = c.fetchone()[0]
|
|
conn.commit()
|
|
conn.close()
|
|
return cnt < max_attempts
|
|
except Exception:
|
|
with _RATE_LIMIT_LOCK:
|
|
if isinstance(store, dict):
|
|
history = store.get(ip, [])
|
|
history = [ts for ts in history if now - ts < window_seconds]
|
|
store[ip] = history
|
|
return len(history) < max_attempts
|
|
return True
|
|
|
|
|
|
def record_failed_ip_attempt(
|
|
ip: str,
|
|
store: Any = None,
|
|
scope: str = "login",
|
|
db_path: Optional[str] = None
|
|
):
|
|
"""Records a persistent failed attempt timestamp for the client IP and scope."""
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
|
|
try:
|
|
conn = sqlite3.connect(target_db, timeout=5.0)
|
|
c = conn.cursor()
|
|
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
|
|
c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
with _RATE_LIMIT_LOCK:
|
|
if isinstance(store, dict):
|
|
if ip not in store:
|
|
store[ip] = []
|
|
store[ip].append(now)
|
|
|
|
|
|
def reset_failed_ip_attempts(
|
|
ip: str,
|
|
store: Any = None,
|
|
scope: str = "login",
|
|
db_path: Optional[str] = None
|
|
):
|
|
"""Resets persistent failed attempt records for client IP upon successful authentication."""
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path") or DEFAULT_DB_FILE
|
|
try:
|
|
conn = sqlite3.connect(target_db, timeout=5.0)
|
|
c = conn.cursor()
|
|
c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope))
|
|
conn.commit()
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
with _RATE_LIMIT_LOCK:
|
|
if isinstance(store, dict) and ip in store:
|
|
del store[ip]
|
|
|
|
|
|
# --- Authentication & Password Hashing Utilities ---
|
|
|
|
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
|
|
"""Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations."""
|
|
if not salt:
|
|
salt = secrets.token_hex(16)
|
|
pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex()
|
|
return pwd_hash, salt
|
|
|
|
|
|
def verify_password(password: str, salt: str, password_hash: str) -> bool:
|
|
"""Verifies a password against the stored salt and PBKDF2 hash."""
|
|
test_hash, _ = hash_password(password, salt=salt)
|
|
return hmac.compare_digest(test_hash, password_hash)
|
|
|
|
|
|
def generate_session_token(username: str, db_path: Optional[str] = None) -> str:
|
|
"""Generates an HMAC-signed session cookie string."""
|
|
ts = int(datetime.now(timezone.utc).timestamp())
|
|
nonce = secrets.token_hex(8)
|
|
payload = f"{username}:{ts}:{nonce}"
|
|
secret = get_session_secret(db_path)
|
|
sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
return f"{payload}:{sig}"
|
|
|
|
|
|
def verify_session_token(token: Optional[str], max_age_hours: int = 24, db_path: Optional[str] = None) -> Optional[str]:
|
|
"""Verifies session token authenticity and age."""
|
|
if not token or ":" not in token:
|
|
return None
|
|
parts = token.split(":")
|
|
if len(parts) != 4:
|
|
return None
|
|
username, ts_str, nonce, sig = parts
|
|
payload = f"{username}:{ts_str}:{nonce}"
|
|
secret = get_session_secret(db_path)
|
|
expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(sig, expected_sig):
|
|
return None
|
|
try:
|
|
ts = int(ts_str)
|
|
if datetime.now(timezone.utc).timestamp() - ts > max_age_hours * 3600:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
return username
|
|
|
|
|
|
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:
|
|
"""Checks user credentials against SQLite users table."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,))
|
|
row = c.fetchone()
|
|
conn.close()
|
|
if not row:
|
|
return False
|
|
return verify_password(password, row[1], row[0])
|
|
|
|
|
|
def change_user_password(username: str, new_password: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Updates a user's password."""
|
|
pwd_hash, salt = hash_password(new_password)
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
# --- Two-Factor Authentication (TOTP / RFC 6238) ---
|
|
|
|
def generate_totp_secret(length: int = 20) -> str:
|
|
"""Generates a cryptographically random Base32-encoded TOTP secret key."""
|
|
return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "")
|
|
|
|
|
|
def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str:
|
|
"""Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238."""
|
|
if intervals_no is None:
|
|
intervals_no = int(time.time()) // 30
|
|
key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True)
|
|
msg = struct.pack(">Q", intervals_no)
|
|
h = hmac.new(key, msg, hashlib.sha1).digest()
|
|
o = h[19] & 15
|
|
h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000
|
|
return f"{h_int:06d}"
|
|
|
|
|
|
def verify_totp_code(secret: str, code: str, window: int = 1) -> bool:
|
|
"""Verifies a 6-digit TOTP code with standard drift window (+/- 30s)."""
|
|
if not secret or not code:
|
|
return False
|
|
code = str(code).strip().replace(" ", "")
|
|
if len(code) != 6 or not code.isdigit():
|
|
return False
|
|
now = int(time.time())
|
|
current_step = now // 30
|
|
for offset in range(-window, window + 1):
|
|
if hmac.compare_digest(get_totp_token(secret, current_step + offset), code):
|
|
return True
|
|
return False
|
|
|
|
|
|
def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str:
|
|
"""Generates standard otpauth URI for QR code generation."""
|
|
user_enc = urllib.parse.quote(username)
|
|
issuer_enc = urllib.parse.quote(issuer)
|
|
return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30"
|
|
|
|
|
|
def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
|
|
"""Retrieves TOTP configuration for a user from the SQLite database."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
try:
|
|
c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,))
|
|
row = c.fetchone()
|
|
if row:
|
|
return {"secret": row[0] or "", "enabled": bool(row[1])}
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
return {"secret": "", "enabled": False}
|
|
|
|
|
|
def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Enables and activates TOTP for a user in the SQLite database."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
try:
|
|
c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
return changed
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Disables TOTP for a user in the SQLite database."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
try:
|
|
c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
return changed
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# --- Hermes Token Management ---
|
|
|
|
def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
|
|
"""Creates or saves a named Hermes API token."""
|
|
if not token:
|
|
token = f"hermes_{secrets.token_hex(20)}"
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute(
|
|
"INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')",
|
|
(token, name, now_iso)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"token": token, "name": name, "created_at": now_iso, "status": "active"}
|
|
|
|
|
|
def list_hermes_tokens(db_path: str = DEFAULT_DB_FILE) -> List[Dict[str, Any]]:
|
|
"""Lists all registered Hermes access tokens."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC")
|
|
rows = [
|
|
{"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]}
|
|
for r in c.fetchall()
|
|
]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Revokes an active Hermes token."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Deletes a Hermes token."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Verifies a Hermes token and updates last_used timestamp."""
|
|
if not token:
|
|
return False
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,))
|
|
row = c.fetchone()
|
|
if row:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token))
|
|
conn.commit()
|
|
conn.close()
|
|
return row is not None
|
|
|
|
|
|
def require_hermes_or_admin_auth(request: Request) -> str:
|
|
"""
|
|
Authorizes Hermes API access via Bearer token, query parameter,
|
|
active admin session cookie, or legacy auth_token.
|
|
"""
|
|
# 1. Check if logged in admin user
|
|
user = get_current_user_optional(request)
|
|
if user:
|
|
return f"user:{user}"
|
|
|
|
# 2. Check Bearer token or token query param
|
|
token = None
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
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:
|
|
# Check against hermes_tokens table
|
|
if verify_hermes_token(token, db_path=db_path):
|
|
return f"hermes_token:{token[:8]}..."
|
|
# Check against config legacy auth_token
|
|
cfg_token = SERVER_STATE.get("config", {}).get("auth_token")
|
|
if cfg_token and secrets.compare_digest(str(token), str(cfg_token)):
|
|
return "config_auth_token"
|
|
|
|
# Backward compatibility: if no hermes tokens are defined in database yet, allow unauthenticated read
|
|
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")
|
|
|
|
|
|
# --- Client Profile Export & Helper Utilities ---
|
|
|
|
def generate_server_keypair(server_name: str):
|
|
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
|
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
|
uid = pgpy.PGPUID.new(server_name)
|
|
key.add_uid(
|
|
uid,
|
|
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
|
hashes=[HashAlgorithm.SHA256, HashAlgorithm.SHA512],
|
|
ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.AES128],
|
|
compression=[
|
|
CompressionAlgorithm.ZIP,
|
|
CompressionAlgorithm.ZLIB,
|
|
CompressionAlgorithm.BZ2,
|
|
CompressionAlgorithm.Uncompressed
|
|
]
|
|
)
|
|
private_key_armored = str(key)
|
|
public_key_armored = str(key.pubkey)
|
|
fingerprint = str(key.pubkey.fingerprint)
|
|
return private_key_armored, public_key_armored, fingerprint
|
|
|
|
|
|
def is_path_writable(filepath: str) -> bool:
|
|
"""Checks if a file or directory path is actually writable by the current process."""
|
|
try:
|
|
if os.path.exists(filepath):
|
|
if os.path.isdir(filepath):
|
|
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
|
with open(test_f, "w") as f:
|
|
f.write("1")
|
|
os.remove(test_f)
|
|
return True
|
|
else:
|
|
with open(filepath, "a") as f:
|
|
pass
|
|
return True
|
|
else:
|
|
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
|
os.makedirs(parent, exist_ok=True)
|
|
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
|
with open(test_f, "w") as f:
|
|
f.write("1")
|
|
os.remove(test_f)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_default_data_dir(app_name: str = "LOGAR-Server") -> str:
|
|
"""Returns a suitable writable application data directory."""
|
|
if sys.platform == "win32":
|
|
# 1. ProgramData (C:\ProgramData\LOGAR-Server)
|
|
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
|
target_prog = os.path.join(prog_data, app_name)
|
|
if is_path_writable(target_prog):
|
|
return target_prog
|
|
|
|
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR-Server)
|
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
|
target_local = os.path.join(local_app, app_name)
|
|
if is_path_writable(target_local):
|
|
return target_local
|
|
|
|
# 3. Temp directory (%TEMP%\LOGAR-Server)
|
|
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
|
if is_path_writable(temp_dir):
|
|
return temp_dir
|
|
|
|
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 file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
|
if not path:
|
|
path = default_filename
|
|
|
|
# If already absolute and writable, keep it
|
|
if os.path.isabs(path) and is_path_writable(path):
|
|
return path
|
|
|
|
# If relative and CWD is writable and not in Program Files, use CWD
|
|
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
|
return os.path.abspath(path)
|
|
|
|
# Otherwise route into the guaranteed writable data directory
|
|
data_dir = get_default_data_dir(app_name)
|
|
fname = os.path.basename(path)
|
|
return os.path.join(data_dir, fname)
|
|
|
|
|
|
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
|
|
|
|
# Resolve relative db_path and cert_dir to writable paths
|
|
config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE)
|
|
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs")
|
|
|
|
global SESSION_SECRET, _CACHED_SESSION_SECRET
|
|
if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config:
|
|
SESSION_SECRET = config["session_secret"]
|
|
_CACHED_SESSION_SECRET = config["session_secret"]
|
|
return config
|
|
|
|
# Fallback to writable data dir if CWD is read-only / Program Files
|
|
if not os.path.isabs(config_path) and ("Program Files" in os.getcwd() or not is_path_writable(os.getcwd())):
|
|
config_path = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path))
|
|
|
|
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
|
|
}
|
|
if not os.environ.get("LOGAR_SESSION_SECRET"):
|
|
SESSION_SECRET = session_sec
|
|
_CACHED_SESSION_SECRET = session_sec
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True)
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
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 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-only key/fingerprint."""
|
|
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
|
|
|
|
|
|
def format_relative_time(dt_str: Optional[str]) -> str:
|
|
"""Calculates human-readable relative time from ISO or SQL timestamp string."""
|
|
if not dt_str or dt_str in ("N/A", "Never"):
|
|
return "Never"
|
|
try:
|
|
clean_str = dt_str.replace(" UTC", "")
|
|
if "T" in clean_str:
|
|
dt = datetime.fromisoformat(clean_str)
|
|
else:
|
|
dt = datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
now = datetime.now(timezone.utc)
|
|
diff = now - dt
|
|
seconds = int(diff.total_seconds())
|
|
if seconds < 0:
|
|
return "Just now"
|
|
if seconds < 60:
|
|
return f"{seconds}s ago"
|
|
minutes = seconds // 60
|
|
if minutes < 60:
|
|
return f"{minutes}m ago"
|
|
hours = minutes // 60
|
|
if hours < 24:
|
|
return f"{hours}h ago"
|
|
days = hours // 24
|
|
return f"{days}d ago"
|
|
except Exception:
|
|
return dt_str[:16] if dt_str else "N/A"
|
|
|
|
|
|
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 0) -> str:
|
|
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota."""
|
|
def _execute_schema(target_path: str):
|
|
target_dir = os.path.dirname(os.path.abspath(target_path))
|
|
if target_dir:
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
conn = sqlite3.connect(target_path, timeout=10.0)
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL;")
|
|
conn.execute("PRAGMA busy_timeout=5000;")
|
|
except Exception:
|
|
pass
|
|
|
|
# 1. Active Issues
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS active_issues (
|
|
fingerprint TEXT PRIMARY KEY,
|
|
site_name TEXT,
|
|
server TEXT,
|
|
signature TEXT,
|
|
severity TEXT,
|
|
message TEXT,
|
|
os_type TEXT,
|
|
first_seen TEXT,
|
|
last_seen TEXT,
|
|
run_count INTEGER,
|
|
status TEXT,
|
|
last_run_id TEXT
|
|
)
|
|
""")
|
|
|
|
# 2. Ingest Runs
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS ingest_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
site_name TEXT,
|
|
server TEXT,
|
|
timestamp TEXT,
|
|
log_count INTEGER
|
|
)
|
|
""")
|
|
|
|
# 3. License Config
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS license_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
max_seats INTEGER NOT NULL DEFAULT 0,
|
|
enrollment_secret TEXT NOT NULL,
|
|
license_id TEXT,
|
|
license_server_url TEXT,
|
|
issued_at TEXT,
|
|
expires_at TEXT,
|
|
license_status TEXT DEFAULT 'unlicensed'
|
|
)
|
|
""")
|
|
|
|
# Migrations for license_config
|
|
for col, col_def in [
|
|
("license_id", "TEXT"),
|
|
("license_server_url", "TEXT"),
|
|
("issued_at", "TEXT"),
|
|
("expires_at", "TEXT"),
|
|
("license_status", "TEXT DEFAULT 'unlicensed'"),
|
|
("features", "TEXT DEFAULT '[]'")
|
|
]:
|
|
try:
|
|
conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# 4. Clients Table
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS clients (
|
|
client_id TEXT PRIMARY KEY,
|
|
hostname TEXT NOT NULL,
|
|
os_type TEXT NOT NULL,
|
|
cert_fingerprint TEXT NOT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
""")
|
|
|
|
# 5. Users Table
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
username TEXT PRIMARY KEY,
|
|
password_hash TEXT NOT NULL,
|
|
salt TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|
created_at TEXT NOT NULL,
|
|
totp_secret TEXT,
|
|
totp_enabled INTEGER DEFAULT 0
|
|
)
|
|
""")
|
|
|
|
# Migrations for users table (2FA)
|
|
for col, col_def in [
|
|
("totp_secret", "TEXT"),
|
|
("totp_enabled", "INTEGER DEFAULT 0")
|
|
]:
|
|
try:
|
|
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}")
|
|
except sqlite3.OperationalError:
|
|
pass
|
|
|
|
# Ensure default admin user exists
|
|
c = conn.cursor()
|
|
c.execute("SELECT COUNT(*) FROM users")
|
|
if c.fetchone()[0] == 0:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
pwd_hash, salt = hash_password(DEFAULT_ADMIN_PASS)
|
|
conn.execute("INSERT INTO users (username, password_hash, salt, role, created_at) VALUES (?, ?, ?, 'admin', ?)",
|
|
(DEFAULT_ADMIN_USER, pwd_hash, salt, now_iso))
|
|
|
|
# 6. Hermes Tokens Table
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS hermes_tokens (
|
|
token TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
last_used TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active'
|
|
)
|
|
""")
|
|
|
|
# Auto-initialize initial Hermes token if table is empty
|
|
c.execute("SELECT COUNT(*) FROM hermes_tokens")
|
|
if c.fetchone()[0] == 0:
|
|
initial_token = f"hermes_live_{secrets.token_hex(20)}"
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')",
|
|
(initial_token, now_iso))
|
|
|
|
# 7. Rate Limits & Server Meta Tables
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
ip TEXT NOT NULL,
|
|
scope TEXT NOT NULL,
|
|
attempt_time REAL NOT NULL
|
|
)
|
|
""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_ip_scope ON rate_limits (ip, scope, attempt_time)")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS server_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)
|
|
""")
|
|
|
|
status = "active" if max_seats > 0 else "unlicensed"
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status, features)
|
|
VALUES (1, ?, ?, ?, '[]')
|
|
""", (max_seats, enrollment_secret or "", status))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
try:
|
|
_execute_schema(db_path)
|
|
return db_path
|
|
except (sqlite3.OperationalError, PermissionError, OSError) as e:
|
|
if "readonly" in str(e).lower() or "permission" in str(e).lower() or "locked" in str(e).lower() or isinstance(e, PermissionError):
|
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
|
fallback_dir = os.path.join(local_app, "LOGAR-Server")
|
|
os.makedirs(fallback_dir, exist_ok=True)
|
|
fallback_path = os.path.join(fallback_dir, "logar_state.db")
|
|
if fallback_path != db_path:
|
|
print(f"[!] Warning: Database path '{db_path}' is not writable ({e}). Falling back to '{fallback_path}'...")
|
|
_execute_schema(fallback_path)
|
|
return fallback_path
|
|
raise
|
|
|
|
|
|
def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
|
"""Generates CA CSR and packages into exportable .lic dictionary and JSON string."""
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
hostname = socket.gethostname()
|
|
seats = config.get("max_seats", 10)
|
|
fp = config.get("server_fingerprint", "")
|
|
return enrollment.create_license_request_lic(
|
|
cert_dir=cert_dir,
|
|
license_server=lic_server,
|
|
server_fingerprint=fp,
|
|
hostname=hostname,
|
|
requested_seats=seats
|
|
)
|
|
|
|
|
|
def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool:
|
|
"""
|
|
Checks whether a specific enterprise capability (e.g. 'enterprise_db') is unlocked
|
|
either via custom X.509 certificate extensions or the persistent license configuration.
|
|
"""
|
|
# 1. Check in-memory / active Subordinate CA certificate
|
|
ca_pem = SERVER_STATE.get("ca_cert_pem")
|
|
if not ca_pem:
|
|
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
|
if os.path.exists(ca_file):
|
|
try:
|
|
with open(ca_file, "r", encoding="utf-8") as f:
|
|
ca_pem = f.read()
|
|
except Exception:
|
|
pass
|
|
|
|
if ca_pem:
|
|
try:
|
|
info = enrollment.extract_license_info_from_cert(ca_pem)
|
|
cert_features = info.get("features", [])
|
|
if feature in cert_features:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# 2. Check license_config table in database
|
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
if target_db and os.path.exists(target_db):
|
|
try:
|
|
conn = sqlite3.connect(target_db, timeout=3.0)
|
|
c = conn.cursor()
|
|
c.execute("SELECT features FROM license_config WHERE id = 1")
|
|
row = c.fetchone()
|
|
conn.close()
|
|
if row and row[0]:
|
|
feat_raw = row[0]
|
|
if isinstance(feat_raw, str):
|
|
if feat_raw.startswith("["):
|
|
feat_list = json.loads(feat_raw)
|
|
else:
|
|
feat_list = [f.strip() for f in feat_raw.split(",") if f.strip()]
|
|
if feature in feat_list:
|
|
return True
|
|
elif isinstance(feat_raw, list) and feature in feat_raw:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
return False
|
|
|
|
|
|
def get_server_public_key() -> Optional[pgpy.PGPKey]:
|
|
"""Retrieves or parses the server's OpenPGP public key for encryption operations."""
|
|
pub_obj = SERVER_STATE.get("public_key_obj")
|
|
if pub_obj:
|
|
return pub_obj
|
|
priv_obj = SERVER_STATE.get("private_key_obj")
|
|
if priv_obj and hasattr(priv_obj, "pubkey"):
|
|
SERVER_STATE["public_key_obj"] = priv_obj.pubkey
|
|
return priv_obj.pubkey
|
|
pub_blob = SERVER_STATE.get("config", {}).get("public_key")
|
|
if pub_blob:
|
|
try:
|
|
key, _ = pgpy.PGPKey.from_blob(pub_blob)
|
|
SERVER_STATE["public_key_obj"] = key
|
|
return key
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def encrypt_field_for_storage(text: str, force: bool = False) -> str:
|
|
"""
|
|
Encrypts a text payload using the server's OpenPGP public key if encrypt_at_rest is enabled.
|
|
Guarantees zero-knowledge database hosting on external database servers.
|
|
"""
|
|
if not text or not isinstance(text, str):
|
|
return text
|
|
config = SERVER_STATE.get("config", {})
|
|
should_encrypt = force or config.get("encrypt_at_rest", False)
|
|
if not should_encrypt:
|
|
return text
|
|
|
|
if text.startswith("-----BEGIN PGP MESSAGE-----"):
|
|
return text
|
|
|
|
pub_key = get_server_public_key()
|
|
if not pub_key:
|
|
return text
|
|
|
|
try:
|
|
msg = pgpy.PGPMessage.new(text)
|
|
encrypted = pub_key.encrypt(msg)
|
|
return str(encrypted)
|
|
except Exception as e:
|
|
print(f"[!] Warning: Failed to encrypt field at rest: {e}")
|
|
return text
|
|
|
|
|
|
def decrypt_field_from_storage(text: str) -> str:
|
|
"""
|
|
Decrypts an OpenPGP armored message string using the server's private key.
|
|
Returns cleartext for authorized display and API consumption.
|
|
"""
|
|
if not text or not isinstance(text, str):
|
|
return text
|
|
if not text.startswith("-----BEGIN PGP MESSAGE-----"):
|
|
return text
|
|
|
|
priv_key = SERVER_STATE.get("private_key_obj")
|
|
if not priv_key:
|
|
priv_blob = SERVER_STATE.get("config", {}).get("private_key")
|
|
if priv_blob:
|
|
try:
|
|
priv_key, _ = pgpy.PGPKey.from_blob(priv_blob)
|
|
SERVER_STATE["private_key_obj"] = priv_key
|
|
except Exception:
|
|
pass
|
|
|
|
if not priv_key:
|
|
return text
|
|
|
|
try:
|
|
pgp_msg = pgpy.PGPMessage.from_blob(text)
|
|
decrypted = priv_key.decrypt(pgp_msg)
|
|
return str(decrypted.message)
|
|
except Exception as e:
|
|
print(f"[!] Warning: Failed to decrypt field from storage: {e}")
|
|
return text
|
|
|
|
|
|
def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Returns metadata about the active storage backend, configuration, and database telemetry."""
|
|
config = SERVER_STATE.get("config", {})
|
|
db_type = config.get("db_type", config.get("storage_engine", "sqlite")).lower()
|
|
target_db = db_path or config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
table_stats = {"active_issues": 0, "ingest_runs": 0, "clients": 0, "users": 0}
|
|
is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db)
|
|
|
|
licensed_features = []
|
|
if target_db and os.path.exists(target_db):
|
|
try:
|
|
conn_l = sqlite3.connect(target_db, timeout=3.0)
|
|
row_l = conn_l.execute("SELECT features FROM license_config WHERE id = 1").fetchone()
|
|
conn_l.close()
|
|
if row_l and row_l[0]:
|
|
feat_raw = row_l[0]
|
|
if isinstance(feat_raw, str):
|
|
if feat_raw.startswith("["):
|
|
licensed_features = json.loads(feat_raw)
|
|
else:
|
|
licensed_features = [f.strip() for f in feat_raw.split(",") if f.strip()]
|
|
elif isinstance(feat_raw, list):
|
|
licensed_features = list(feat_raw)
|
|
except Exception:
|
|
pass
|
|
|
|
if db_type in ("postgres", "postgresql"):
|
|
pg_cfg = config.get("postgres", {})
|
|
host = pg_cfg.get("host", "localhost")
|
|
port = int(pg_cfg.get("port", 5432))
|
|
dbname = pg_cfg.get("database", "logar")
|
|
user = pg_cfg.get("user", "postgres")
|
|
try:
|
|
try:
|
|
import psycopg2
|
|
except ImportError:
|
|
import psycopg as psycopg2
|
|
p_conn = psycopg2.connect(
|
|
host=host, port=port, dbname=dbname, user=user,
|
|
password=pg_cfg.get("password", ""),
|
|
sslmode=pg_cfg.get("sslmode", "prefer"),
|
|
connect_timeout=int(pg_cfg.get("connect_timeout", 3))
|
|
)
|
|
with p_conn.cursor() as p_cur:
|
|
for tbl in ["active_issues", "ingest_runs", "clients", "users"]:
|
|
try:
|
|
p_cur.execute(f"SELECT COUNT(*) FROM {tbl}")
|
|
r = p_cur.fetchone()
|
|
table_stats[tbl] = r[0] if r else 0
|
|
except Exception:
|
|
pass
|
|
p_conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"engine": "postgres",
|
|
"engine_type": "postgres",
|
|
"backend_display": "PostgreSQL Enterprise Pool",
|
|
"connection_target": f"{user}@{host}:{port}/{dbname}",
|
|
"status": "Enterprise Connected",
|
|
"table_stats": table_stats,
|
|
"is_enterprise": True,
|
|
"feature_unlocked": is_unlocked,
|
|
"licensed_features": licensed_features,
|
|
"cache_mode": config.get("cache_mode", "direct"),
|
|
"encrypt_at_rest": config.get("encrypt_at_rest", False),
|
|
"flush_interval": INGEST_QUEUE.flush_interval,
|
|
"batch_size": INGEST_QUEUE.batch_size,
|
|
"max_queue": INGEST_QUEUE.max_queue,
|
|
"postgres_config": pg_cfg
|
|
}
|
|
else:
|
|
size_bytes = 0
|
|
if target_db and os.path.exists(target_db):
|
|
try:
|
|
size_bytes = os.path.getsize(target_db)
|
|
conn = sqlite3.connect(target_db, timeout=3.0)
|
|
for tbl in ["active_issues", "ingest_runs", "clients", "users"]:
|
|
try:
|
|
row = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()
|
|
table_stats[tbl] = row[0] if row else 0
|
|
except Exception:
|
|
pass
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
size_kb = round(size_bytes / 1024, 2)
|
|
return {
|
|
"engine": "sqlite",
|
|
"engine_type": "sqlite",
|
|
"backend_display": "SQLite 3 (WAL Mode)",
|
|
"connection_target": target_db,
|
|
"db_size_kb": size_kb,
|
|
"status": "Local WAL Optimized",
|
|
"table_stats": table_stats,
|
|
"is_enterprise": False,
|
|
"feature_unlocked": is_unlocked,
|
|
"licensed_features": licensed_features,
|
|
"cache_mode": config.get("cache_mode", "direct"),
|
|
"encrypt_at_rest": config.get("encrypt_at_rest", False),
|
|
"flush_interval": INGEST_QUEUE.flush_interval,
|
|
"batch_size": INGEST_QUEUE.batch_size,
|
|
"max_queue": INGEST_QUEUE.max_queue,
|
|
"postgres_config": config.get("postgres", {
|
|
"host": "localhost",
|
|
"port": 5432,
|
|
"database": "logar",
|
|
"user": "postgres",
|
|
"password": "",
|
|
"sslmode": "prefer",
|
|
"connect_timeout": 5,
|
|
"pool_size": 10
|
|
})
|
|
}
|
|
|
|
|
|
def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]:
|
|
"""Initializes table schema on a PostgreSQL database instance."""
|
|
try:
|
|
try:
|
|
import psycopg2
|
|
except ImportError:
|
|
try:
|
|
import psycopg as psycopg2
|
|
except ImportError:
|
|
return False, "PostgreSQL driver not installed on server (install psycopg2-binary or psycopg)"
|
|
|
|
host = pg_config.get("host", "localhost")
|
|
port = int(pg_config.get("port", 5432))
|
|
database = pg_config.get("database", "logar")
|
|
user = pg_config.get("user", "postgres")
|
|
password = pg_config.get("password", "")
|
|
sslmode = pg_config.get("sslmode", "prefer")
|
|
timeout = int(pg_config.get("connect_timeout", 5))
|
|
|
|
conn = psycopg2.connect(
|
|
host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout
|
|
)
|
|
|
|
with conn.cursor() as cur:
|
|
cur.execute("""
|
|
CREATE TABLE IF NOT EXISTS active_issues (
|
|
fingerprint VARCHAR(255) PRIMARY KEY,
|
|
site_name VARCHAR(255),
|
|
server VARCHAR(255),
|
|
signature TEXT,
|
|
severity VARCHAR(50),
|
|
message TEXT,
|
|
os_type VARCHAR(50),
|
|
first_seen VARCHAR(50),
|
|
last_seen VARCHAR(50),
|
|
run_count INTEGER,
|
|
status VARCHAR(50),
|
|
last_run_id VARCHAR(100)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS ingest_runs (
|
|
run_id VARCHAR(100) PRIMARY KEY,
|
|
site_name VARCHAR(255),
|
|
server VARCHAR(255),
|
|
timestamp VARCHAR(50),
|
|
log_count INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS license_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
max_seats INTEGER NOT NULL DEFAULT 0,
|
|
enrollment_secret TEXT NOT NULL,
|
|
license_id VARCHAR(100),
|
|
license_server_url VARCHAR(255),
|
|
issued_at VARCHAR(50),
|
|
expires_at VARCHAR(50),
|
|
license_status VARCHAR(50) DEFAULT 'unlicensed',
|
|
features TEXT DEFAULT '[]'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS clients (
|
|
client_id VARCHAR(100) PRIMARY KEY,
|
|
hostname VARCHAR(255) NOT NULL,
|
|
os_type VARCHAR(50) NOT NULL,
|
|
cert_fingerprint VARCHAR(255) NOT NULL,
|
|
status VARCHAR(50) DEFAULT 'active',
|
|
first_seen VARCHAR(50),
|
|
last_seen VARCHAR(50)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
username VARCHAR(100) PRIMARY KEY,
|
|
password_hash TEXT NOT NULL,
|
|
salt TEXT NOT NULL,
|
|
role VARCHAR(50) NOT NULL DEFAULT 'admin',
|
|
created_at VARCHAR(50) NOT NULL,
|
|
totp_secret TEXT,
|
|
totp_enabled INTEGER DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS hermes_tokens (
|
|
token VARCHAR(255) PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
created_at VARCHAR(50) NOT NULL,
|
|
last_used VARCHAR(50),
|
|
status VARCHAR(50) NOT NULL DEFAULT 'active'
|
|
);
|
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
ip VARCHAR(100) NOT NULL,
|
|
scope VARCHAR(50) NOT NULL,
|
|
attempt_time DOUBLE PRECISION NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS server_meta (
|
|
key VARCHAR(100) PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
return True, "PostgreSQL schema successfully initialized"
|
|
except Exception as e:
|
|
return False, f"PostgreSQL connection/init failed: {e}"
|
|
|
|
|
|
def migrate_sqlite_to_postgres(sqlite_path: str, pg_config: Dict[str, Any]) -> Tuple[bool, str, Dict[str, int]]:
|
|
"""Transfers all tables and records from SQLite to PostgreSQL with data validation and encryption."""
|
|
if not os.path.exists(sqlite_path):
|
|
return False, f"SQLite database not found at {sqlite_path}", {}
|
|
|
|
ok, init_msg = init_postgres_db(pg_config)
|
|
if not ok:
|
|
return False, init_msg, {}
|
|
|
|
try:
|
|
try:
|
|
import psycopg2
|
|
except ImportError:
|
|
import psycopg as psycopg2
|
|
|
|
host = pg_config.get("host", "localhost")
|
|
port = int(pg_config.get("port", 5432))
|
|
database = pg_config.get("database", "logar")
|
|
user = pg_config.get("user", "postgres")
|
|
password = pg_config.get("password", "")
|
|
sslmode = pg_config.get("sslmode", "prefer")
|
|
timeout = int(pg_config.get("connect_timeout", 5))
|
|
|
|
pg_conn = psycopg2.connect(
|
|
host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=timeout
|
|
)
|
|
sqlite_conn = sqlite3.connect(sqlite_path)
|
|
sqlite_conn.row_factory = sqlite3.Row
|
|
|
|
migrated_counts = {}
|
|
tables = ["active_issues", "ingest_runs", "license_config", "clients", "users", "hermes_tokens", "rate_limits", "server_meta"]
|
|
|
|
with pg_conn.cursor() as pg_cur:
|
|
for tbl in tables:
|
|
try:
|
|
s_cur = sqlite_conn.cursor()
|
|
s_cur.execute(f"SELECT * FROM {tbl}")
|
|
rows = s_cur.fetchall()
|
|
if rows:
|
|
col_names = [col[0] for col in s_cur.description]
|
|
cols_joined = ", ".join(col_names)
|
|
placeholders = ", ".join(["%s"] * len(col_names))
|
|
insert_query = f"INSERT INTO {tbl} ({cols_joined}) VALUES ({placeholders}) ON CONFLICT DO NOTHING;"
|
|
data = []
|
|
for r in rows:
|
|
row_data = [r[c] for c in col_names]
|
|
if tbl == "active_issues" and "message" in col_names:
|
|
msg_idx = col_names.index("message")
|
|
row_data[msg_idx] = encrypt_field_for_storage(row_data[msg_idx])
|
|
data.append(row_data)
|
|
|
|
pg_cur.executemany(insert_query, data)
|
|
migrated_counts[tbl] = len(rows)
|
|
else:
|
|
migrated_counts[tbl] = 0
|
|
except Exception as tbl_err:
|
|
print(f"[!] Warning migrating table {tbl}: {tbl_err}")
|
|
migrated_counts[tbl] = 0
|
|
|
|
pg_conn.commit()
|
|
|
|
sqlite_conn.close()
|
|
pg_conn.close()
|
|
return True, "Migration completed successfully", migrated_counts
|
|
except Exception as e:
|
|
return False, f"Migration failed: {e}", {}
|
|
|
|
|
|
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)
|
|
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")
|
|
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
|
|
|
|
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")
|
|
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, message, os_type, now_iso, now_iso, 1, initial_status, run_id))
|
|
|
|
processed_count += 1
|
|
|
|
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:
|
|
"""Collects local Windows Application and System event logs."""
|
|
collector = WindowsLogCollector(state=state, channels=["Application", "System"])
|
|
return collector.collect_logs(hours=hours)
|
|
|
|
|
|
def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
|
"""Collects local Linux journalctl / syslog events."""
|
|
collector = LinuxLogCollector(state=state)
|
|
return collector.collect_logs(hours=hours)
|
|
|
|
|
|
def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
|
"""Platform-agnostic local host log collector."""
|
|
collector = get_log_collector(state=state)
|
|
return collector.collect_logs(hours=hours)
|
|
|
|
|
|
def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int:
|
|
"""
|
|
Executes one cycle of local host log collection and direct ingestion into SQLite.
|
|
Does not require a license seat, certificate enrollment, or mTLS socket connection.
|
|
"""
|
|
machine_id = get_machine_identifier()
|
|
os_type = "windows" if sys.platform == "win32" else "linux"
|
|
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
# 1. Ensure local node is registered in clients table with LOCAL-HOST-BUILTIN fingerprint
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
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}")
|
|
|
|
# 2. Collect local logs
|
|
logs = get_local_system_logs(hours=window_hours, state=state)
|
|
if not logs:
|
|
return 0
|
|
|
|
# 3. Direct ingestion without network overhead or seat checks
|
|
payload = {
|
|
"server": machine_id,
|
|
"site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub",
|
|
"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 = {}
|
|
|
|
# Run initial cycle immediately
|
|
try:
|
|
run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state)
|
|
with open(state_file, "w", encoding="utf-8") as f:
|
|
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}")
|
|
|
|
|
|
def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext:
|
|
"""Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS)."""
|
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
|
srv_cert = os.path.join(cert_dir, "server.crt")
|
|
srv_key = os.path.join(cert_dir, "server.key")
|
|
|
|
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
|
ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
|
|
ctx.load_verify_locations(cafile=ca_file)
|
|
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
|
if os.path.exists(upstream_file):
|
|
try:
|
|
ctx.load_verify_locations(cafile=upstream_file)
|
|
except Exception:
|
|
pass
|
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
return ctx
|
|
|
|
|
|
def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"):
|
|
"""
|
|
Dynamically reloads server certificate chain and Root CA in an active SSLContext.
|
|
Allows in-flight TLS certificate rotation without dropping the listening socket.
|
|
"""
|
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
|
srv_cert = os.path.join(cert_dir, "server.crt")
|
|
srv_key = os.path.join(cert_dir, "server.key")
|
|
|
|
ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
|
|
ssl_ctx.load_verify_locations(cafile=ca_file)
|
|
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
|
|
if os.path.exists(upstream_file):
|
|
try:
|
|
ssl_ctx.load_verify_locations(cafile=upstream_file)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def check_and_rotate_server_certs(
|
|
cert_dir: str = "certs",
|
|
hostnames: Optional[List[str]] = None,
|
|
threshold_days: int = 30
|
|
) -> bool:
|
|
"""
|
|
Checks if Root/Subordinate CA or server TLS certificate are expiring within threshold_days.
|
|
If a licensed Subordinate CA is expiring, automatically connects over mTLS to logar.eibl.tech
|
|
to request an in-flight renewal, then reloads the active SSLContext without socket interruption.
|
|
Returns True if renewed/reloaded, False otherwise.
|
|
"""
|
|
config = SERVER_STATE.get("config", {})
|
|
license_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
|
|
|
# 1. Check if CA certificate is expiring and attempt upstream mTLS renewal
|
|
if os.path.exists(ca_cert_path):
|
|
try:
|
|
with open(ca_cert_path, "r", encoding="utf-8") as f:
|
|
ca_pem = f.read()
|
|
if enrollment.is_cert_expiring_soon(ca_pem, threshold_days=threshold_days):
|
|
info = enrollment.extract_license_info_from_cert(ca_pem)
|
|
if not info.get("is_self_signed", True) or info.get("license_id"):
|
|
print("[*] Subordinate CA expiring soon. Initiating automated mTLS renewal with logar.eibl.tech...")
|
|
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
|
|
cert_dir=cert_dir,
|
|
license_server_url=license_server
|
|
)
|
|
if success and new_pem:
|
|
print(f"[+] Automated mTLS renewal successful: {msg}")
|
|
new_info = enrollment.extract_license_info_from_cert(new_pem)
|
|
if new_info.get("seats"):
|
|
config["max_seats"] = new_info["seats"]
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (new_info["seats"],))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
SERVER_STATE["ca_cert"] = ca_cert
|
|
SERVER_STATE["ca_key"] = ca_key
|
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
|
enrollment.generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True)
|
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
|
if ssl_ctx is not None:
|
|
try:
|
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
|
print("[+] In-flight mTLS SSLContext successfully reloaded with renewed CA.")
|
|
except Exception as e:
|
|
print(f"[!] Failed to reload in-flight SSLContext: {e}")
|
|
return True
|
|
else:
|
|
print(f"[!] Automated mTLS renewal attempt failed: {msg}")
|
|
except Exception as e:
|
|
print(f"[!] Error evaluating CA certificate for mTLS renewal: {e}")
|
|
|
|
# 2. Local self-healing / regeneration check
|
|
ca_renewed, srv_renewed = enrollment.check_and_renew_hub_pki(
|
|
cert_dir=cert_dir,
|
|
hostnames=hostnames,
|
|
threshold_days=threshold_days
|
|
)
|
|
if ca_renewed or srv_renewed:
|
|
print(f"[!] Server Hub PKI certificates renewed (CA renewed: {ca_renewed}, Server cert renewed: {srv_renewed}).")
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
SERVER_STATE["ca_cert"] = ca_cert
|
|
SERVER_STATE["ca_key"] = ca_key
|
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
|
|
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
|
if ssl_ctx is not None:
|
|
try:
|
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
|
print("[+] In-flight mTLS SSLContext successfully reloaded with updated certificates.")
|
|
except Exception as e:
|
|
print(f"[!] Failed to reload in-flight SSLContext: {e}")
|
|
return True
|
|
return False
|
|
|
|
|
|
def report_seat_quota_to_authority_sync():
|
|
"""Background helper to push active seat count to upstream authority without blocking."""
|
|
try:
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
if not os.path.exists(os.path.join(cert_dir, "ca.crt")) or not os.path.exists(os.path.join(cert_dir, "ca.key")):
|
|
return
|
|
|
|
active_seats = 0
|
|
if os.path.exists(db_path):
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone()
|
|
if row:
|
|
active_seats = row[0]
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
enrollment.renew_ca_cert_mtls(
|
|
cert_dir=cert_dir,
|
|
license_server_url=lic_server,
|
|
seats_taken=active_seats,
|
|
active_seats=active_seats,
|
|
hostname=config.get("server_name", ""),
|
|
server_fingerprint=config.get("server_fingerprint", "")
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def trigger_async_seat_report():
|
|
"""Fires a non-blocking background thread to sync seat usage with upstream authority."""
|
|
threading.Thread(target=report_seat_quota_to_authority_sync, daemon=True).start()
|
|
|
|
|
|
async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30):
|
|
"""Periodically checks the validity of Hub Root CA and Server TLS certificates and reports seat quotas."""
|
|
config = SERVER_STATE.get("config", {})
|
|
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.
|
|
Extracts client CN (client_id) from the TLS handshake, validates active license status in SQLite,
|
|
updates last_seen, reads length-prefixed JSON payload, and ingests candidate logs.
|
|
"""
|
|
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 mTLS is enforced, verify client in accounting database
|
|
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"]
|
|
)
|
|
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
|
|
|
|
|
|
# --- API Endpoints: Authentication, Clients, Hermes Tokens & Licensing ---
|
|
|
|
@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="""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LOGAR Hub - Login</title>
|
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpolygon points='256,24 456,139 456,373 256,488 56,373 56,139' fill='%230E1A2E' stroke='%2300E5FF' stroke-width='24'/%3E%3Crect x='140' y='140' width='46' height='232' rx='10' fill='%2300E5FF'/%3E%3Crect x='140' y='326' width='220' height='46' rx='10' fill='%2300E5FF'/%3E%3Crect x='220' y='210' width='36' height='80' rx='8' fill='%2300FFA3'/%3E%3Crect x='286' y='150' width='36' height='140' rx='8' fill='%2300E5FF'/%3E%3C/svg%3E">
|
|
<style>
|
|
:root { --bg: #0b0f19; --card: #151c2e; --primary: #3b82f6; --primary-hover: #2563eb; --border: #1e293b; --text: #f8fafc; --text-muted: #94a3b8; --danger: #ef4444; }
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
|
.login-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 36px; width: 100%; max-width: 420px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); }
|
|
.logo-header { text-align: center; margin-bottom: 24px; }
|
|
.logo-icon { width: 56px; height: 56px; background: transparent; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
|
|
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
|
|
.subtitle { font-size: 13px; color: var(--text-muted); }
|
|
.form-group { margin-bottom: 18px; text-align: left; }
|
|
.form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; }
|
|
.form-control { width: 100%; padding: 10px 14px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px; color: #fff; font-size: 14px; }
|
|
.form-control:focus { outline: none; border-color: var(--primary); }
|
|
.btn { width: 100%; padding: 11px; background: var(--primary); color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.2s; margin-top: 6px; }
|
|
.btn:hover { background: var(--primary-hover); }
|
|
.alert-error { background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #f87171; padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-card">
|
|
<div class="logo-header">
|
|
<div class="logo-icon">
|
|
<svg width="28" height="28" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139" fill="#0E1A2E" stroke="#00E5FF" stroke-width="24"/>
|
|
<rect x="140" y="140" width="46" height="232" rx="10" fill="#00E5FF"/>
|
|
<rect x="140" y="326" width="220" height="46" rx="10" fill="#00E5FF"/>
|
|
<rect x="220" y="210" width="36" height="80" rx="8" fill="#00FFA3"/>
|
|
<rect x="286" y="150" width="36" height="140" rx="8" fill="#00E5FF"/>
|
|
</svg>
|
|
</div>
|
|
<h1>LOGAR Server Hub</h1>
|
|
<div class="subtitle">Fleet Ingestion & Centralized Provisioning</div>
|
|
</div>
|
|
<div id="loginError" class="alert-error"></div>
|
|
<form id="loginForm" onsubmit="handleLogin(event)">
|
|
<div class="form-group" id="userGroup">
|
|
<label class="form-label">Username</label>
|
|
<input type="text" id="username" class="form-control" placeholder="admin" required autofocus>
|
|
</div>
|
|
<div class="form-group" id="passGroup">
|
|
<label class="form-label">Password</label>
|
|
<input type="password" id="password" class="form-control" placeholder="••••••••" required>
|
|
</div>
|
|
<div class="form-group" id="totpGroup" style="display: none;">
|
|
<label class="form-label">🛡️ 2FA Verification Code</label>
|
|
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">Enter the 6-digit code from your authenticator app:</p>
|
|
<input type="text" id="totpCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="font-family: 'JetBrains Mono', monospace; font-size: 18px; letter-spacing: 4px; text-align: center;">
|
|
</div>
|
|
<button type="submit" class="btn" id="submitBtn">Sign In</button>
|
|
</form>
|
|
</div>
|
|
<script>
|
|
async function handleLogin(e) {
|
|
e.preventDefault();
|
|
const u = document.getElementById('username').value.trim();
|
|
const p = document.getElementById('password').value;
|
|
const totp = document.getElementById('totpCode').value.trim();
|
|
const errEl = document.getElementById('loginError');
|
|
const btn = document.getElementById('submitBtn');
|
|
const totpGrp = document.getElementById('totpGroup');
|
|
errEl.style.display = 'none';
|
|
btn.disabled = true;
|
|
btn.textContent = 'Authenticating...';
|
|
|
|
const payload = { username: u, password: p };
|
|
if (totp) payload.totp_code = totp;
|
|
|
|
try {
|
|
const res = await fetch('/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.status === 'success') {
|
|
window.location.href = '/';
|
|
} else if (data.requires_2fa || data.status === '2fa_required') {
|
|
totpGrp.style.display = 'block';
|
|
document.getElementById('totpCode').focus();
|
|
btn.disabled = false;
|
|
btn.textContent = 'Verify & Sign In';
|
|
errEl.style.display = 'none';
|
|
} else {
|
|
errEl.textContent = data.detail || 'Invalid username or password';
|
|
errEl.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.textContent = totpGrp.style.display === 'block' ? 'Verify & Sign In' : 'Sign In';
|
|
}
|
|
} catch(ex) {
|
|
errEl.textContent = 'Connection error: ' + ex.message;
|
|
errEl.style.display = 'block';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sign In';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</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, _LOGIN_FAILURES, 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, _LOGIN_FAILURES, 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, _LOGIN_FAILURES, scope="login", db_path=db_path)
|
|
await asyncio.sleep(0.3)
|
|
raise HTTPException(status_code=401, detail="Invalid 6-digit 2FA verification code")
|
|
|
|
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES, scope="login", db_path=db_path)
|
|
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"}
|
|
|
|
|
|
# --- 2FA Authentication Management Endpoints ---
|
|
|
|
@app.get("/api/user/2fa/status")
|
|
def api_user_2fa_status(auth_user: str = Depends(require_admin_auth)):
|
|
"""Returns 2FA status for currently logged in administrator."""
|
|
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,
|
|
checking license seat limits, issuing a signed client certificate + key,
|
|
and recording the client in the SQLite accounting database.
|
|
"""
|
|
client_ip = get_client_ip(request)
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60, scope="enroll", db_path=db_path):
|
|
raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.")
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
|
|
# 1. Validate enrollment secret against license_config
|
|
c.execute("SELECT enrollment_secret, max_seats, license_status FROM license_config WHERE id = 1")
|
|
row = c.fetchone()
|
|
if not row:
|
|
conn.close()
|
|
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
|
|
|
expected_secret, max_seats = row[0], row[1]
|
|
license_status = row[2] if len(row) > 2 and row[2] else ("active" if max_seats > 0 else "unlicensed")
|
|
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, _ENROLL_FAILURES, scope="enroll", db_path=db_path)
|
|
time.sleep(0.5)
|
|
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
|
|
|
reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES, scope="enroll", db_path=db_path)
|
|
|
|
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")
|
|
|
|
# Ensure full trust chain (subordinate CA + upstream root CA if present) is delivered to edge clients
|
|
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
|
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
|
if os.path.exists(upstream_ca_path):
|
|
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
|
|
|
|
# 2. Check if client_id already registered
|
|
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
|
|
client_row = c.fetchone()
|
|
if client_row:
|
|
if client_row[0] == "revoked":
|
|
conn.close()
|
|
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
|
|
|
|
# Re-issue for existing active client
|
|
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
|
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
|
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
c.execute("""
|
|
UPDATE clients
|
|
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = ?
|
|
WHERE client_id = ?
|
|
""", (req.hostname, req.os, fp, now_sql, req.client_id))
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})")
|
|
return {
|
|
"ca_cert": ca_cert_pem,
|
|
"client_cert": client_cert_pem,
|
|
"client_key": client_key_pem
|
|
}
|
|
|
|
# 3. New client: check seat limits
|
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
|
active_count = c.fetchone()[0]
|
|
if active_count >= max_seats:
|
|
conn.close()
|
|
raise HTTPException(status_code=403, detail="License seat limit reached")
|
|
|
|
# 4. Issue signed cert + key
|
|
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
|
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
|
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
c.execute("""
|
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
|
|
VALUES (?, ?, ?, ?, 'active', ?, ?)
|
|
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql))
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
|
|
trigger_async_seat_report()
|
|
|
|
return {
|
|
"ca_cert": ca_cert_pem,
|
|
"client_cert": client_cert_pem,
|
|
"client_key": client_key_pem
|
|
}
|
|
|
|
|
|
@app.get("/api/client/export-profile", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/client/export-profile", dependencies=[Depends(require_admin_auth)])
|
|
def export_client_profile(
|
|
server_host: Optional[str] = None,
|
|
server_port: Optional[int] = None,
|
|
client_id: Optional[str] = None
|
|
):
|
|
"""
|
|
Exports a standalone client profile JSON configuration bundle
|
|
for zero-touch edge client bootstrapping.
|
|
"""
|
|
config = SERVER_STATE.get("config", {})
|
|
host = server_host or config.get("tcp_host", "127.0.0.1")
|
|
if host in ("0.0.0.0", ""):
|
|
host = socket.gethostname()
|
|
port = int(server_port or config.get("tcp_port", 9443))
|
|
|
|
profile = generate_client_profile_dict(
|
|
server_host=host,
|
|
server_port=port,
|
|
client_id=client_id,
|
|
config_dict=config
|
|
)
|
|
profile_json = json.dumps(profile, indent=2)
|
|
|
|
return Response(
|
|
content=profile_json,
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": 'attachment; filename="client_config.json"'}
|
|
)
|
|
|
|
|
|
@app.get("/api/clients", dependencies=[Depends(require_admin_auth)])
|
|
def list_clients():
|
|
"""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 FROM clients ORDER BY last_seen DESC")
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
|
|
clients = [
|
|
{
|
|
"client_id": r[0],
|
|
"hostname": r[1],
|
|
"os_type": r[2],
|
|
"cert_fingerprint": r[3],
|
|
"status": r[4],
|
|
"first_seen": r[5],
|
|
"last_seen": r[6],
|
|
"last_activity_relative": format_relative_time(r[6])
|
|
}
|
|
for r in rows
|
|
]
|
|
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/clients/approve", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/clients/reactivate", dependencies=[Depends(require_admin_auth)])
|
|
def approve_client(action: ClientActionRequest):
|
|
"""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}
|
|
|
|
|
|
# Backward-compatible function alias for test suite
|
|
reactivate_client = approve_client
|
|
|
|
|
|
|
|
@app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)])
|
|
def revoke_client(action: ClientActionRequest):
|
|
"""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/clients/delete", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)])
|
|
def delete_client(action: ClientActionRequest):
|
|
"""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}
|
|
|
|
|
|
# --- Hermes API Tokens Endpoints ---
|
|
|
|
@app.get("/api/hermes/tokens", dependencies=[Depends(require_admin_auth)])
|
|
def api_list_hermes_tokens():
|
|
"""Lists all Hermes access tokens."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
return {"tokens": list_hermes_tokens(db_path=db_path)}
|
|
|
|
|
|
@app.post("/api/hermes/tokens/create", dependencies=[Depends(require_admin_auth)])
|
|
def api_create_hermes_token(req: HermesTokenCreateRequest):
|
|
"""Generates a new Hermes API token."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
token_data = create_hermes_token(name=req.name, db_path=db_path)
|
|
return {"status": "success", "token": token_data}
|
|
|
|
|
|
@app.post("/api/hermes/tokens/revoke", dependencies=[Depends(require_admin_auth)])
|
|
def api_revoke_hermes_token(req: HermesTokenRevokeRequest):
|
|
"""Revokes a Hermes API token."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
ok = revoke_hermes_token(req.token, db_path=db_path)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Token not found")
|
|
return {"status": "success", "token": req.token}
|
|
|
|
|
|
@app.post("/api/hermes/tokens/delete", dependencies=[Depends(require_admin_auth)])
|
|
def api_delete_hermes_token(req: HermesTokenRevokeRequest):
|
|
"""Deletes a Hermes API token."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
ok = delete_hermes_token(req.token, db_path=db_path)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="Token not found")
|
|
return {"status": "success", "token": req.token}
|
|
|
|
|
|
# --- Hermes Reporting Endpoints ---
|
|
|
|
@app.get("/api/hermes/report")
|
|
def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admin_auth)):
|
|
"""
|
|
Ingestion endpoint for Hermes agentic workflows.
|
|
Returns verified anomalies within the active 12-hour evaluation window
|
|
for Hermes AI direct reasoning and autonomous decision-making.
|
|
"""
|
|
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()
|
|
conn.close()
|
|
|
|
window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12)
|
|
report = []
|
|
for r in rows:
|
|
report.append({
|
|
"fingerprint": r[0],
|
|
"site": 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],
|
|
"consecutive_runs": r[9],
|
|
"evaluation_window": f"{window_hours}h",
|
|
"verified": True,
|
|
"status": r[10]
|
|
})
|
|
|
|
return report
|
|
|
|
|
|
@app.get("/api/hermes/all")
|
|
def get_all_issues(auth: str = Depends(require_hermes_or_admin_auth)):
|
|
"""Diagnostic endpoint to inspect both transient candidate blips and verified anomalies."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
|
FROM active_issues
|
|
""")
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
return [
|
|
{
|
|
"fingerprint": r[0],
|
|
"site": 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 rows
|
|
]
|
|
|
|
|
|
def get_license_summary() -> Dict[str, Any]:
|
|
"""Inspects certs/ca.crt, database quotas, and returns comprehensive licensing status."""
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
max_seats = config.get("max_seats", 0)
|
|
license_status_db = "unlicensed"
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT max_seats, license_status FROM license_config WHERE id = 1")
|
|
row = c.fetchone()
|
|
if row:
|
|
if row[0] is not None:
|
|
max_seats = row[0]
|
|
if len(row) > 1 and row[1]:
|
|
license_status_db = row[1]
|
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
|
active_seats = c.fetchone()[0]
|
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
|
revoked_seats = c.fetchone()[0]
|
|
conn.close()
|
|
except Exception:
|
|
active_seats = 0
|
|
revoked_seats = 0
|
|
|
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
|
csr_exists = os.path.exists(csr_path)
|
|
|
|
if os.path.exists(ca_path):
|
|
try:
|
|
with open(ca_path, "r", encoding="utf-8") as f:
|
|
ca_pem = f.read()
|
|
info = enrollment.extract_license_info_from_cert(ca_pem)
|
|
if info.get("seats"):
|
|
max_seats = info["seats"]
|
|
except Exception:
|
|
info = {"valid": False}
|
|
else:
|
|
info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0}
|
|
|
|
license_id = info.get("license_id") or "UNLICENSED"
|
|
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
|
|
|
|
if info.get("is_expired"):
|
|
status_label = "EXPIRED"
|
|
elif info.get("seats") and not info.get("is_self_signed"):
|
|
status_label = "ACTIVE"
|
|
elif max_seats > 0 and license_status_db == "active":
|
|
status_label = "ACTIVE"
|
|
elif info.get("is_self_signed"):
|
|
status_label = "SELF-SIGNED" if max_seats > 0 else "UNLICENSED"
|
|
else:
|
|
status_label = "UNLICENSED" if max_seats <= 0 else "ACTIVE"
|
|
|
|
pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0
|
|
return {
|
|
"status": status_label,
|
|
"active_seats": active_seats,
|
|
"revoked_seats": revoked_seats,
|
|
"max_seats": max_seats,
|
|
"available_seats": max(0, max_seats - active_seats),
|
|
"percentage": min(100, pct),
|
|
"license_id": license_id,
|
|
"issuer_url": issuer_url,
|
|
"issuer_cn": info.get("issuer_cn", "N/A"),
|
|
"subject_cn": info.get("subject_cn", "N/A"),
|
|
"is_self_signed": info.get("is_self_signed", False),
|
|
"not_valid_before": info.get("not_valid_before", "N/A"),
|
|
"not_valid_after": info.get("not_valid_after", "N/A"),
|
|
"days_remaining": info.get("days_remaining", 0),
|
|
"fingerprint": info.get("fingerprint", "N/A"),
|
|
"csr_exists": csr_exists,
|
|
"server_fingerprint": config.get("server_fingerprint", "N/A")
|
|
}
|
|
|
|
|
|
@app.get("/api/license/status")
|
|
def api_license_status():
|
|
"""Returns license details, seat quotas, and certificate validity."""
|
|
return get_license_summary()
|
|
|
|
|
|
@app.get("/api/license/export-lic", dependencies=[Depends(require_admin_auth)])
|
|
def export_lic_file():
|
|
"""Generates and downloads the logar_server.lic file containing CA CSR and authority coordinates."""
|
|
config = SERVER_STATE.get("config", {})
|
|
lic_dict, lic_json = export_license_request_lic(config)
|
|
return Response(
|
|
content=lic_json,
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": 'attachment; filename="logar_server.lic"'}
|
|
)
|
|
|
|
|
|
@app.get("/api/license/csr", dependencies=[Depends(require_admin_auth)])
|
|
def get_ca_csr():
|
|
"""Returns raw PEM Certificate Signing Request (CSR) for subordinate CA."""
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
|
if not os.path.exists(csr_path):
|
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=cert_dir)
|
|
else:
|
|
with open(csr_path, "r", encoding="utf-8") as f:
|
|
csr_pem = f.read()
|
|
return {"csr": csr_pem}
|
|
|
|
|
|
@app.post("/api/license/import", dependencies=[Depends(require_admin_auth)])
|
|
async def import_signed_license(req: Request):
|
|
"""Imports and activates a signed CA certificate or .lic file."""
|
|
try:
|
|
body = await req.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
|
|
if isinstance(body, dict):
|
|
if "master_root_ca" in body or "upstream_ca" in body:
|
|
raw_text = json.dumps(body)
|
|
elif "lic" in body:
|
|
raw_text = json.dumps(body["lic"]) if isinstance(body["lic"], dict) else str(body["lic"])
|
|
elif "ca_cert" in body and not body.get("master_root_ca"):
|
|
raw_text = str(body["ca_cert"])
|
|
else:
|
|
raw_text = json.dumps(body)
|
|
else:
|
|
raw_text = str(body)
|
|
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
|
|
try:
|
|
res = enrollment.import_license_package(
|
|
raw_text,
|
|
cert_dir=cert_dir,
|
|
server_fingerprint=config.get("server_fingerprint", ""),
|
|
hostname=config.get("server_name", socket.gethostname())
|
|
)
|
|
except ValueError as ve:
|
|
raise HTTPException(status_code=400, detail=str(ve))
|
|
|
|
info = res["license_info"]
|
|
ca_cert_pem = res["ca_cert_pem"]
|
|
new_seats = info.get("seats") or config.get("max_seats", 0)
|
|
config["max_seats"] = new_seats
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("""
|
|
UPDATE license_config
|
|
SET max_seats = ?, license_id = ?, issued_at = ?, expires_at = ?, license_status = 'active'
|
|
WHERE id = 1
|
|
""", (new_seats, info.get("license_id"), info.get("not_valid_before"), info.get("not_valid_after")))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Regenerate server TLS certificate signed by new CA
|
|
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
SERVER_STATE["ca_cert"] = ca_cert
|
|
SERVER_STATE["ca_key"] = ca_key
|
|
SERVER_STATE["ca_cert_pem"] = ca_cert_pem
|
|
enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir,
|
|
force_renew=True
|
|
)
|
|
|
|
# In-flight SSLContext hot reload
|
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
|
if ssl_ctx:
|
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
|
print("[+] In-flight mTLS SSLContext successfully reloaded with newly imported CA.")
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"License successfully activated for {new_seats} seats.",
|
|
"license_id": info.get("license_id"),
|
|
"seats": new_seats,
|
|
"expires_at": info.get("not_valid_after"),
|
|
"days_remaining": info.get("days_remaining")
|
|
}
|
|
|
|
|
|
@app.post("/api/license/renew", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/license/sync", dependencies=[Depends(require_admin_auth)])
|
|
async def trigger_license_renewal(request: Request):
|
|
"""Explicitly triggers automated mTLS sync and renewal with logar.eibl.tech."""
|
|
requested_seats = None
|
|
seat_increment = 0
|
|
try:
|
|
body = await request.json()
|
|
if isinstance(body, dict):
|
|
requested_seats = body.get("requested_seats")
|
|
seat_increment = int(body.get("seat_increment", 0))
|
|
except Exception:
|
|
pass
|
|
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
active_seats = 0
|
|
try:
|
|
conn_cnt = sqlite3.connect(db_path)
|
|
c_cnt = conn_cnt.cursor()
|
|
c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
|
|
row = c_cnt.fetchone()
|
|
if row:
|
|
active_seats = row[0]
|
|
conn_cnt.close()
|
|
except Exception:
|
|
pass
|
|
|
|
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
|
|
cert_dir=cert_dir,
|
|
license_server_url=lic_server,
|
|
requested_seats=requested_seats,
|
|
seat_increment=seat_increment,
|
|
seats_taken=active_seats,
|
|
active_seats=active_seats,
|
|
hostname=config.get("server_name", ""),
|
|
server_fingerprint=config.get("server_fingerprint", "")
|
|
)
|
|
if not success or not new_pem:
|
|
raise HTTPException(status_code=400, detail=msg)
|
|
|
|
info = enrollment.extract_license_info_from_cert(new_pem)
|
|
if info.get("seats"):
|
|
config["max_seats"] = info["seats"]
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (info["seats"],))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
SERVER_STATE["ca_cert"] = ca_cert
|
|
SERVER_STATE["ca_key"] = ca_key
|
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
|
enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir,
|
|
force_renew=True
|
|
)
|
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
|
if ssl_ctx:
|
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
|
print(f"[+] In-flight mTLS SSLContext successfully reloaded after renewal/sync (Seats: {info.get('seats')}).")
|
|
|
|
# --- Database Engine & Ingestion Buffer Endpoints ---
|
|
|
|
@app.get("/api/db/stats", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_stats():
|
|
"""Returns real-time buffer throughput, queue stats, and storage engine metrics."""
|
|
return {
|
|
"status": "success",
|
|
"queue": INGEST_QUEUE.get_stats(),
|
|
"storage": get_storage_engine_info(),
|
|
"enterprise_db_licensed": is_feature_licensed("enterprise_db")
|
|
}
|
|
|
|
|
|
@app.post("/api/db/flush-buffer", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_flush_buffer():
|
|
"""Manually forces immediate drain and write of all buffered anomalies to the database."""
|
|
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)
|
|
return {
|
|
"status": "success",
|
|
"message": f"Successfully flushed {res.get('processed_logs', 0)} events across {res.get('flushed_runs', 0)} batch runs ({res.get('flush_latency_ms', 0)}ms).",
|
|
"flush_details": res,
|
|
"queue": INGEST_QUEUE.get_stats()
|
|
}
|
|
|
|
|
|
@app.post("/api/db/test-connection", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_test_connection(req: DbConnectionTestRequest):
|
|
"""Tests connection to external PostgreSQL database. Requires enterprise_db license feature."""
|
|
if not is_feature_licensed("enterprise_db"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
|
)
|
|
ok, msg = init_postgres_db(req.model_dump())
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=msg)
|
|
return {"status": "success", "message": "Successfully connected to PostgreSQL and verified schema."}
|
|
|
|
|
|
@app.post("/api/db/switch-backend", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_switch_backend(req: DbSwitchBackendRequest):
|
|
"""Switches active database backend between SQLite and PostgreSQL. Requires enterprise_db license feature."""
|
|
if not is_feature_licensed("enterprise_db"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
|
)
|
|
config = SERVER_STATE.get("config", {})
|
|
if req.db_type == "postgres":
|
|
pg_cfg = {
|
|
"host": req.host or "localhost",
|
|
"port": req.port or 5432,
|
|
"database": req.database or "logar",
|
|
"user": req.user or "postgres",
|
|
"password": req.password or "",
|
|
"sslmode": req.sslmode or "prefer"
|
|
}
|
|
ok, msg = init_postgres_db(pg_cfg)
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=f"Cannot switch backend: {msg}")
|
|
config["db_type"] = "postgres"
|
|
config["postgres"] = pg_cfg
|
|
else:
|
|
config["db_type"] = "sqlite"
|
|
if req.db_path:
|
|
config["db_path"] = req.db_path
|
|
|
|
cfg_path = config.get("config_file_path", CONFIG_FILE_NAME)
|
|
if os.path.exists(cfg_path):
|
|
try:
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": f"Storage backend successfully switched to {config.get('db_type', 'sqlite')}.",
|
|
"storage": get_storage_engine_info()
|
|
}
|
|
|
|
|
|
@app.post("/api/db/config", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_update_config(req: DbFullConfigRequest):
|
|
"""Updates complete database storage engine, cache mode, PostgreSQL parameters, and encryption at rest."""
|
|
if req.storage_engine == "postgres" and not is_feature_licensed("enterprise_db"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="External PostgreSQL storage is locked. This capability requires the 'enterprise_db' license feature."
|
|
)
|
|
|
|
config = SERVER_STATE.get("config", {})
|
|
config["storage_engine"] = req.storage_engine
|
|
config["db_type"] = req.storage_engine
|
|
config["cache_mode"] = req.cache_mode
|
|
config["encrypt_at_rest"] = req.encrypt_at_rest
|
|
|
|
INGEST_QUEUE.flush_interval = max(0.5, float(req.flush_interval))
|
|
INGEST_QUEUE.batch_size = max(1, int(req.batch_size))
|
|
INGEST_QUEUE.max_queue = max(100, int(req.max_queue))
|
|
config["flush_interval"] = INGEST_QUEUE.flush_interval
|
|
config["batch_size"] = INGEST_QUEUE.batch_size
|
|
config["max_queue"] = INGEST_QUEUE.max_queue
|
|
|
|
if req.pg_config:
|
|
pg_dict = req.pg_config.model_dump()
|
|
config["postgres"] = pg_dict
|
|
if req.storage_engine == "postgres":
|
|
ok, msg = init_postgres_db(pg_dict)
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}")
|
|
|
|
cfg_path = config.get("config_file_path", CONFIG_FILE_NAME)
|
|
if os.path.exists(cfg_path):
|
|
try:
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Database storage engine, cache mode, and encryption settings successfully updated.",
|
|
"storage": get_storage_engine_info()
|
|
}
|
|
|
|
|
|
@app.post("/api/db/migrate-to-postgres", dependencies=[Depends(require_admin_auth)])
|
|
def api_db_migrate_to_postgres(req: DbConnectionTestRequest):
|
|
"""Migrates all telemetry, runs, clients, users, and license configs from SQLite to PostgreSQL."""
|
|
if not is_feature_licensed("enterprise_db"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
|
)
|
|
config = SERVER_STATE.get("config", {})
|
|
sqlite_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
INGEST_QUEUE.flush(db_path=sqlite_path)
|
|
|
|
ok, msg, counts = migrate_sqlite_to_postgres(sqlite_path, req.model_dump())
|
|
if not ok:
|
|
raise HTTPException(status_code=400, detail=msg)
|
|
return {
|
|
"status": "success",
|
|
"message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.",
|
|
"counts": counts
|
|
}
|
|
|
|
|
|
# --- Comprehensive Info WebUI Dashboard ---
|
|
|
|
@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
|
|
|
|
summary = get_license_summary()
|
|
config = SERVER_STATE.get("config", {})
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
is_db_unlocked = is_feature_licensed("enterprise_db", db_path)
|
|
queue_stats = INGEST_QUEUE.get_stats()
|
|
storage_info = get_storage_engine_info()
|
|
pg_config = config.get("postgres", {})
|
|
db_tab_btn_badge = '<span class="badge badge-active" style="margin-left:4px; font-size:10px;">🟢 UNLOCKED</span>' if is_db_unlocked else '<span class="badge" style="background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.3); margin-left:4px; font-size:10px;">🔒 ENTERPRISE</span>'
|
|
|
|
|
|
# Fetch clients
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
|
|
clients = c.fetchall()
|
|
|
|
# Fetch verified anomalies
|
|
c.execute("SELECT fingerprint, server, severity, message, run_count, last_seen FROM active_issues WHERE status = 'VERIFIED' ORDER BY last_seen DESC LIMIT 50")
|
|
anomalies_raw = c.fetchall()
|
|
anomalies = []
|
|
for an in anomalies_raw:
|
|
afp, srv, sev, msg, rc, ls = an
|
|
decrypted_msg = decrypt_field_from_storage(msg)
|
|
anomalies.append((afp, srv, sev, decrypted_msg, rc, ls))
|
|
|
|
# Fetch hermes tokens
|
|
c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC")
|
|
hermes_tokens = c.fetchall()
|
|
conn.close()
|
|
|
|
# Build client rows with exact + relative activity time and seat management actions
|
|
client_rows = []
|
|
for cl in clients:
|
|
cid, host, ost, fp, st, first, last = cl
|
|
esc_cid = html.escape(str(cid))
|
|
esc_host = html.escape(str(host))
|
|
esc_ost = html.escape(str(ost))
|
|
esc_fp = html.escape(str(fp))
|
|
esc_st = html.escape(str(st))
|
|
badge_cls = "badge-active" if st == "active" else "badge-revoked"
|
|
rel_time = format_relative_time(last)
|
|
last_display = f"<div><strong>{html.escape(str(last[:19]))}</strong></div><div style='font-size:11px; color:#94a3b8;'>{html.escape(rel_time)}</div>"
|
|
|
|
if fp == "LOCAL-HOST-BUILTIN":
|
|
esc_st = "LOCAL HUB HOST"
|
|
badge_cls = "badge-active"
|
|
fp_display = '<span style="font-size:11px; color:#38bdf8; font-weight:600;">⚡ Built-in Log Collector</span>'
|
|
btn_action = '<span style="font-size:11px; color:#94a3b8;">Integrated (0 Seats)</span>'
|
|
btn_del = ''
|
|
else:
|
|
fp_display = f"<code title='{esc_fp}' style='cursor:pointer;' onclick=\"copyText('{esc_fp}', 'Cert Fingerprint')\">{esc_fp[:16]}...</code>"
|
|
btn_action = (
|
|
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>'
|
|
if st == "active" else
|
|
f'<button class="btn btn-accent" style="padding:4px 8px; font-size:11px;" onclick="approveClient(\'{esc_cid}\')">Approve</button>'
|
|
)
|
|
btn_del = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteClient(\'{esc_cid}\')" title="Remove client and free seat">Remove</button>'
|
|
|
|
client_rows.append(f"""
|
|
<tr>
|
|
<td><span class="badge {badge_cls}">{esc_st.upper()}</span></td>
|
|
<td><strong>{esc_host}</strong></td>
|
|
<td><code>{esc_cid}</code></td>
|
|
<td><span class="badge badge-os">{esc_ost.upper()}</span></td>
|
|
<td>{fp_display}</td>
|
|
<td>{last_display}</td>
|
|
<td>
|
|
<div style="display:flex; gap:6px; align-items:center;">
|
|
{btn_action}
|
|
{btn_del}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
""")
|
|
clients_html = "".join(client_rows) if client_rows else '<tr><td colspan="7" class="empty-state">No edge clients registered yet.</td></tr>'
|
|
|
|
# Build anomaly rows
|
|
anomaly_rows = []
|
|
for an in anomalies:
|
|
afp, srv, sev, msg, rc, ls = an
|
|
esc_srv = html.escape(str(srv))
|
|
esc_sev = html.escape(str(sev))
|
|
esc_msg = html.escape(str(msg))
|
|
esc_ls = html.escape(str(ls[:19]))
|
|
sev_cls = "badge-error" if sev in ("ERROR", "CRITICAL", "FATAL") else "badge-warning"
|
|
short_msg = msg[:80] + "..." if len(msg) > 80 else msg
|
|
esc_short_msg = html.escape(str(short_msg))
|
|
anomaly_rows.append(f"""
|
|
<tr>
|
|
<td><span class="badge {sev_cls}">{esc_sev}</span></td>
|
|
<td><strong>{esc_srv}</strong></td>
|
|
<td><span class="badge badge-active">{int(rc)} runs</span></td>
|
|
<td title="{esc_msg}">{esc_short_msg}</td>
|
|
<td>{esc_ls}</td>
|
|
</tr>
|
|
""")
|
|
anomalies_html = "".join(anomaly_rows) if anomaly_rows else '<tr><td colspan="5" class="empty-state">No verified anomalies currently reported.</td></tr>'
|
|
|
|
# Build hermes tokens rows
|
|
token_rows = []
|
|
for tk in hermes_tokens:
|
|
tok, tname, tcreated, tlast, tstatus = tk
|
|
esc_tok = html.escape(str(tok))
|
|
esc_tname = html.escape(str(tname))
|
|
esc_tstatus = html.escape(str(tstatus))
|
|
esc_tcreated = html.escape(str(tcreated[:19]))
|
|
esc_tlast = html.escape(str(tlast[:19])) if tlast else 'Never'
|
|
tbadge = "badge-active" if tstatus == "active" else "badge-revoked"
|
|
masked = esc_tok[:10] + "••••••••" + esc_tok[-4:]
|
|
tok_action = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="revokeToken(\'{esc_tok}\')">Revoke</button>' if tstatus == "active" else f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="deleteToken(\'{esc_tok}\')">Delete</button>'
|
|
token_rows.append(f"""
|
|
<tr>
|
|
<td><strong>{esc_tname}</strong></td>
|
|
<td><code style="cursor:pointer;" title="Click to copy full token" onclick="copyText('{esc_tok}', 'Hermes API Token')">{masked}</code></td>
|
|
<td><span class="badge {tbadge}">{esc_tstatus.upper()}</span></td>
|
|
<td style="color:#94a3b8; font-size:12px;">{esc_tcreated}</td>
|
|
<td style="color:#94a3b8; font-size:12px;">{esc_tlast}</td>
|
|
<td>
|
|
<div style="display:flex; gap:6px; align-items:center;">
|
|
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="copyText('{esc_tok}', 'Hermes API Token')">Copy</button>
|
|
{tok_action}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
""")
|
|
tokens_html = "".join(token_rows) if token_rows else '<tr><td colspan="6" class="empty-state">No Hermes API tokens created yet.</td></tr>'
|
|
|
|
# Progress bar color logic
|
|
pct = summary["percentage"]
|
|
bar_color = "#10b981" if pct < 70 else ("#f59e0b" if pct < 90 else "#ef4444")
|
|
badge_status_cls = "badge-active" if summary["status"] == "ACTIVE" else ("badge-warning" if summary["status"] == "SELF-SIGNED" else "badge-revoked")
|
|
|
|
unlicensed_banner_html = ""
|
|
if summary["status"] == "UNLICENSED" or summary["max_seats"] == 0:
|
|
unlicensed_banner_html = f"""
|
|
<div style="background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.35); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
|
|
<div style="display: flex; align-items: center; gap: 14px;">
|
|
<div style="font-size: 28px;">⚠️</div>
|
|
<div>
|
|
<div style="font-weight: 700; color: #f87171; font-size: 15px; margin-bottom: 2px;">LOGAR Server is Unlicensed (0 Active Seats)</div>
|
|
<div style="font-size: 13px; color: #cbd5e1;">Forwarder enrollment and log ingestion are restricted. Import an activated <code>.lic</code> package to enable edge client connections and seat provisioning.</div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
<button onclick="openImportLicModal()" class="btn btn-accent" style="font-size: 12px; padding: 7px 14px;">📥 Import License (.lic)</button>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
detected_host = (request.headers.get("host", "").split(":")[0] if request and request.headers else "") or config.get("tcp_host", "127.0.0.1")
|
|
if detected_host in ("0.0.0.0", "localhost", ""):
|
|
detected_host = socket.gethostname()
|
|
|
|
is_encrypt_at_rest = config.get("encrypt_at_rest", False)
|
|
encrypt_status_label = "🔒 OpenPGP Encrypted" if is_encrypt_at_rest else "🔓 Plaintext Storage"
|
|
encrypt_status_color = "#34d399" if is_encrypt_at_rest else "#94a3b8"
|
|
|
|
if is_db_unlocked:
|
|
db_tab_pane_html = f"""
|
|
<!-- Tab 5: Database Engine -->
|
|
<div class="tab-pane" id="tabPane-db">
|
|
<!-- Ingestion Buffer Live Stats -->
|
|
<div class="card" style="border-color: rgba(0, 229, 255, 0.3);">
|
|
<div class="card-header">
|
|
<div>
|
|
<span class="card-title" style="color: #00E5FF;">💾 Storage Engine & Live Telemetry Metrics</span>
|
|
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Centralized high-availability SQL pooling, buffer telemetry, and encryption at rest</div>
|
|
</div>
|
|
<span class="badge badge-active">{storage_info['engine'].upper()} BACKEND</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-top: 14px;">
|
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div class="kpi-sub">Active Storage Backend</div>
|
|
<div style="font-weight: 700; color: #38bdf8; margin-top: 4px; font-size: 15px;">{storage_info['backend_display']}</div>
|
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">{storage_info['status']}</div>
|
|
</div>
|
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div class="kpi-sub">Telemetry Records</div>
|
|
<div class="kpi-value" style="color: #34d399; margin-top: 4px; font-size: 18px;">{storage_info['table_stats']['active_issues']} <span style="font-size: 12px; color: var(--text-muted);">Issues</span> / {storage_info['table_stats']['ingest_runs']} <span style="font-size: 12px; color: var(--text-muted);">Runs</span></div>
|
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Clients: {storage_info['table_stats']['clients']} | Users: {storage_info['table_stats']['users']}</div>
|
|
</div>
|
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div class="kpi-sub">Buffered Events in RAM</div>
|
|
<div class="kpi-value" id="liveQueueSize" style="color: #c084fc; margin-top: 4px; font-size: 18px;">{queue_stats['queue_size']} <span style="font-size: 12px; color: var(--text-muted);">events</span></div>
|
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Ingested: {queue_stats['total_ingested']} | Flushed: {queue_stats['total_flushed']}</div>
|
|
</div>
|
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div class="kpi-sub">Data-at-Rest Security</div>
|
|
<div style="font-weight: 700; color: {encrypt_status_color}; margin-top: 4px; font-size: 15px;">{encrypt_status_label}</div>
|
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Zero-Knowledge Storage on External DB</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Storage Engine & SQL Configurator Form -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Database Storage Engine Configuration</span>
|
|
<span class="badge badge-os">STORAGE SELECTION</span>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
<label class="form-label" style="font-size: 14px; font-weight: 700; margin-bottom: 8px;">Select Storage Engine Backend:</label>
|
|
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
|
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 18px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px;">
|
|
<input type="radio" name="dbEngineRadio" id="engineRadioSqlite" value="sqlite" {'checked' if storage_info['engine'] == 'sqlite' else ''} onchange="toggleStorageEngineForm()">
|
|
<span>💾 <strong>Local SQLite 3 (WAL Mode)</strong> <span style="color: var(--text-muted); font-size: 12px;">(Embedded, Zero Configuration)</span></span>
|
|
</label>
|
|
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 18px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px;">
|
|
<input type="radio" name="dbEngineRadio" id="engineRadioPostgres" value="postgres" {'checked' if storage_info['engine'] == 'postgres' else ''} onchange="toggleStorageEngineForm()">
|
|
<span>🐘 <strong>External PostgreSQL Database Server</strong> <span style="color: #34d399; font-size: 12px; font-weight: 600;">(Enterprise Clustering)</span></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- External PostgreSQL Parameters -->
|
|
<div id="pgConfigSection" style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px; display: {'block' if storage_info['engine'] == 'postgres' else 'none'};">
|
|
<div style="font-size: 14px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between;">
|
|
<span style="display: flex; align-items: center; gap: 8px;">🐘 External PostgreSQL Connection Parameters</span>
|
|
<span class="badge badge-active">ENTERPRISE POOL</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 16px;">
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Host / IP Address</label>
|
|
<input type="text" id="pgHost" class="form-control" value="{pg_config.get('host', 'localhost')}" placeholder="localhost">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Port</label>
|
|
<input type="number" id="pgPort" class="form-control" value="{pg_config.get('port', 5432)}" placeholder="5432">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Database Name</label>
|
|
<input type="text" id="pgDatabase" class="form-control" value="{pg_config.get('database', 'logar')}" placeholder="logar">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Username</label>
|
|
<input type="text" id="pgUser" class="form-control" value="{pg_config.get('user', 'postgres')}" placeholder="postgres">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Password</label>
|
|
<input type="password" id="pgPassword" class="form-control" value="{pg_config.get('password', '')}" placeholder="••••••••">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">SSL Mode</label>
|
|
<select id="pgSslMode" class="form-control">
|
|
<option value="prefer" {'selected' if pg_config.get('sslmode') == 'prefer' else ''}>prefer</option>
|
|
<option value="require" {'selected' if pg_config.get('sslmode') == 'require' else ''}>require</option>
|
|
<option value="verify-ca" {'selected' if pg_config.get('sslmode') == 'verify-ca' else ''}>verify-ca</option>
|
|
<option value="verify-full" {'selected' if pg_config.get('sslmode') == 'verify-full' else ''}>verify-full</option>
|
|
<option value="disable" {'selected' if pg_config.get('sslmode') == 'disable' else ''}>disable</option>
|
|
<option value="allow" {'selected' if pg_config.get('sslmode') == 'allow' else ''}>allow</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Connect Timeout (sec)</label>
|
|
<input type="number" id="pgConnectTimeout" class="form-control" value="{pg_config.get('connect_timeout', 5)}" placeholder="5">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Connection Pool Size</label>
|
|
<input type="number" id="pgPoolSize" class="form-control" value="{pg_config.get('pool_size', 10)}" placeholder="10">
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
<button type="button" onclick="testPgConnection()" class="btn btn-secondary">🔍 Test Connection</button>
|
|
<button type="button" onclick="migrateToPostgres()" class="btn btn-accent">🚀 Migrate SQLite Data to PostgreSQL</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zero-Knowledge OpenPGP Data Encryption at Rest -->
|
|
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
|
<div style="font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px;">
|
|
<span>🔐</span> Zero-Knowledge Data Encryption at Rest
|
|
</div>
|
|
<label style="display: inline-flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
|
<input type="checkbox" id="encryptAtRestCheck" {'checked' if config.get('encrypt_at_rest', False) else ''} style="width: 18px; height: 18px; accent-color: var(--accent);">
|
|
<span style="font-weight: 600; font-size: 14px;">Encrypt Sensitive Telemetry & Log Messages with Server OpenPGP Key</span>
|
|
</label>
|
|
<p style="font-size: 12px; color: var(--text-muted); line-height: 1.5; margin-left: 28px;">
|
|
When enabled, anomaly message payloads stored in the external database are encrypted with the server's OpenPGP public key before writing. Only this hub instance can decrypt records for authorized WebUI and Hermes AI usage, ensuring zero-knowledge privacy on remote database servers.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Telemetry Ingestion Cache Mode & Buffer Tuning -->
|
|
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
|
<div style="font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
|
|
<span style="display: flex; align-items: center; gap: 8px;">⚡ Ingestion Cache & Buffer Mode</span>
|
|
<button type="button" onclick="flushIngestBuffer()" class="btn btn-accent" style="padding: 4px 10px; font-size: 11px;">🌊 Flush Buffer Now</button>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 14px;">
|
|
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
|
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
|
<input type="radio" name="cacheModeRadio" id="cacheModeDirect" value="direct" {'checked' if config.get('cache_mode', 'direct') == 'direct' else ''} onchange="toggleCacheModeParams()">
|
|
<span>⚡ <strong>Immediate Direct Write</strong> <span style="color: var(--text-muted); font-size: 12px;">(Low Latency / Synchronous Verification)</span></span>
|
|
</label>
|
|
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
|
<input type="radio" name="cacheModeRadio" id="cacheModeBuffered" value="buffered" {'checked' if config.get('cache_mode', 'direct') == 'buffered' else ''} onchange="toggleCacheModeParams()">
|
|
<span>🚀 <strong>In-Memory Queue Buffer</strong> <span style="color: var(--text-muted); font-size: 12px;">(High-Throughput Batch Flushing)</span></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="bufferParamsSection" style="display: {'grid' if config.get('cache_mode', 'direct') == 'buffered' else 'none'}; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; padding-top: 10px; border-top: 1px solid var(--border);">
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Flush Interval (seconds)</label>
|
|
<input type="number" id="bufferFlushInterval" step="0.5" class="form-control" value="{config.get('flush_interval', queue_stats.get('flush_interval_seconds', 2.0))}" placeholder="2.0">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Batch Size Threshold</label>
|
|
<input type="number" id="bufferBatchSize" class="form-control" value="{config.get('batch_size', queue_stats.get('batch_size_threshold', 100))}" placeholder="100">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 0;">
|
|
<label class="form-label">Max Buffer Capacity</label>
|
|
<input type="number" id="bufferMaxQueue" class="form-control" value="{config.get('max_queue', 50000)}" placeholder="50000">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Global Save Action Bar -->
|
|
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px;">
|
|
<button type="button" onclick="saveDbConfig()" class="btn btn-primary" style="padding: 10px 24px; font-size: 14px;">
|
|
💾 Save Database & Cache Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
else:
|
|
db_tab_pane_html = f"""
|
|
<!-- Tab 5: Database Engine (Locked State) -->
|
|
<div class="tab-pane" id="tabPane-db">
|
|
<div class="card" style="text-align: center; padding: 48px 24px; border-color: rgba(239, 68, 68, 0.3);">
|
|
<div style="font-size: 52px; margin-bottom: 16px;">🔒</div>
|
|
<h2 style="font-size: 22px; font-weight: 800; margin-bottom: 8px;">Enterprise Database & High-Throughput Engine is Locked</h2>
|
|
<div style="margin-bottom: 16px;">
|
|
<span class="badge" style="background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); padding: 6px 14px; font-size: 12px;">
|
|
REQUIRES 'enterprise_db' LICENSE FEATURE
|
|
</span>
|
|
</div>
|
|
<p style="font-size: 14px; color: var(--text-muted); max-width: 640px; margin: 0 auto 24px;">
|
|
Direct PostgreSQL storage backend clustering, automatic schema synchronization, and customizable high-throughput batch-flush controls are gated behind the <strong>enterprise_db</strong> capability.
|
|
</p>
|
|
<div style="display: flex; justify-content: center; gap: 12px; flex-wrap: wrap;">
|
|
<button onclick="switchTab('pki')" class="btn btn-primary">📜 Go to Sub-CA & Licensing</button>
|
|
<button onclick="openImportLicModal()" class="btn btn-accent">📥 Import Enterprise .lic Package</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
html_content = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LOGAR Hub - Fleet Aggregation & Management</title>
|
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpolygon points='256,24 456,139 456,373 256,488 56,373 56,139' fill='%230E1A2E' stroke='%2300E5FF' stroke-width='24'/%3E%3Crect x='140' y='140' width='46' height='232' rx='10' fill='%2300E5FF'/%3E%3Crect x='140' y='326' width='220' height='46' rx='10' fill='%2300E5FF'/%3E%3Crect x='220' y='210' width='36' height='80' rx='8' fill='%2300FFA3'/%3E%3Crect x='286' y='150' width='36' height='140' rx='8' fill='%2300E5FF'/%3E%3C/svg%3E">
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {{
|
|
--bg: #0b0f19;
|
|
--card: #151c2e;
|
|
--primary: #3b82f6;
|
|
--primary-hover: #2563eb;
|
|
--accent: #10b981;
|
|
--danger: #ef4444;
|
|
--text: #f8fafc;
|
|
--text-muted: #94a3b8;
|
|
--border: #1e293b;
|
|
}}
|
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
body {{
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
padding: 24px;
|
|
min-height: 100vh;
|
|
}}
|
|
.container {{ max-width: 1280px; margin: 0 auto; }}
|
|
|
|
/* Header */
|
|
header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 20px;
|
|
margin-bottom: 24px;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
}}
|
|
.logo-group {{ display: flex; align-items: center; gap: 14px; }}
|
|
.logo-icon {{
|
|
width: 44px; height: 44px;
|
|
background: transparent;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}}
|
|
h1 {{ font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }}
|
|
.subtitle {{ font-size: 13px; color: var(--text-muted); margin-top: 2px; }}
|
|
.header-actions {{ display: flex; align-items: center; gap: 12px; }}
|
|
|
|
/* User Dropdown */
|
|
.user-menu-container {{ position: relative; display: inline-block; }}
|
|
.user-tag {{
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
padding: 8px 14px; background: #0f172a;
|
|
border: 1px solid var(--border); border-radius: 8px;
|
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
user-select: none;
|
|
}}
|
|
.user-tag:hover {{ border-color: var(--primary); background: #151c2e; }}
|
|
.user-dropdown {{
|
|
display: none; position: absolute; right: 0; top: calc(100% + 6px);
|
|
background: var(--card); border: 1px solid var(--border);
|
|
border-radius: 10px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
|
min-width: 200px; z-index: 200; overflow: hidden;
|
|
}}
|
|
.user-menu-container:hover .user-dropdown, .user-menu-container:focus-within .user-dropdown, .user-dropdown.show {{ display: block; }}
|
|
.user-dropdown-header {{ padding: 10px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); background: #0f172a; border-bottom: 1px solid var(--border); }}
|
|
.user-dropdown-item {{
|
|
display: flex; align-items: center; gap: 8px; padding: 10px 14px;
|
|
font-size: 13px; color: var(--text); text-decoration: none; cursor: pointer;
|
|
border: none; width: 100%; background: transparent; text-align: left;
|
|
}}
|
|
.user-dropdown-item:hover {{ background: rgba(255, 255, 255, 0.05); }}
|
|
.user-dropdown-item.danger {{ color: var(--danger); }}
|
|
.user-dropdown-item.danger:hover {{ background: rgba(239, 68, 68, 0.15); }}
|
|
|
|
/* Status Dot */
|
|
.pulse-dot {{
|
|
width: 10px; height: 10px; background-color: #10b981; border-radius: 50%;
|
|
display: inline-block; animation: pulse-green 2s infinite;
|
|
}}
|
|
@keyframes pulse-green {{
|
|
0% {{ transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }}
|
|
70% {{ transform: scale(1); box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }}
|
|
100% {{ transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }}
|
|
}}
|
|
|
|
/* Tabs Navigation */
|
|
.tabs-nav {{ display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }}
|
|
.tab-btn {{
|
|
display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px;
|
|
border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
|
border: 1px solid transparent; background: transparent; color: var(--text-muted);
|
|
transition: all 0.2s;
|
|
}}
|
|
.tab-btn:hover {{ color: var(--text); background: rgba(255, 255, 255, 0.03); }}
|
|
.tab-btn.active {{ color: #fff; background: var(--card); border-color: var(--border); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); }}
|
|
.tab-pane {{ display: none; }}
|
|
.tab-pane.active {{ display: block; }}
|
|
|
|
/* Badges */
|
|
.badge {{
|
|
display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 4px;
|
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
|
}}
|
|
.badge-active {{ background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }}
|
|
.badge-revoked, .badge-error {{ background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }}
|
|
.badge-warning {{ background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }}
|
|
.badge-os {{ background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }}
|
|
|
|
/* KPI Grid */
|
|
.grid-kpi {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}}
|
|
.card {{
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
|
|
margin-bottom: 24px;
|
|
}}
|
|
.card-header {{
|
|
display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;
|
|
}}
|
|
.card-title {{ font-size: 13px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; }}
|
|
.kpi-value {{ font-size: 24px; font-weight: 800; font-family: 'JetBrains Mono', monospace; margin-bottom: 4px; }}
|
|
.kpi-sub {{ font-size: 12px; color: var(--text-muted); }}
|
|
|
|
.progress-container {{
|
|
width: 100%; height: 8px; background: #0f172a; border-radius: 4px; overflow: hidden; margin-top: 10px; border: 1px solid var(--border);
|
|
}}
|
|
.progress-bar {{ height: 100%; transition: width 0.3s ease; }}
|
|
|
|
/* Toolbar & Buttons */
|
|
.toolbar {{
|
|
display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px;
|
|
padding: 14px 18px; background: rgba(21, 28, 46, 0.7);
|
|
border: 1px solid var(--border); border-radius: 10px; align-items: center; justify-content: space-between;
|
|
}}
|
|
.btn {{
|
|
display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px;
|
|
border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer;
|
|
border: none; transition: all 0.2s; text-decoration: none;
|
|
}}
|
|
.btn-primary {{ background: var(--primary); color: #fff; }}
|
|
.btn-primary:hover {{ background: var(--primary-hover); }}
|
|
.btn-accent {{ background: var(--accent); color: #fff; }}
|
|
.btn-accent:hover {{ background: #059669; }}
|
|
.btn-secondary {{ background: #0f172a; color: var(--text); border: 1px solid var(--border); }}
|
|
.btn-secondary:hover {{ border-color: var(--primary); background: #1e293b; }}
|
|
.btn-outline {{ background: transparent; border: 1px solid var(--border); color: var(--text-muted); }}
|
|
.btn-outline:hover {{ color: #fff; border-color: #64748b; }}
|
|
.btn-danger {{ background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); }}
|
|
.btn-danger:hover {{ background: var(--danger); color: #fff; }}
|
|
|
|
/* Table */
|
|
table {{ width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; }}
|
|
th {{ background: #0f172a; padding: 12px 16px; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); }}
|
|
td {{ padding: 14px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }}
|
|
tr:hover td {{ background: rgba(255, 255, 255, 0.02); }}
|
|
code {{ background: #0f172a; padding: 3px 6px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #60a5fa; border: 1px solid #1e293b; }}
|
|
.empty-state {{ text-align: center; color: var(--text-muted); padding: 32px !important; }}
|
|
|
|
/* Modal */
|
|
.modal {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); align-items: center; justify-content: center; z-index: 300; }}
|
|
.modal-content {{ background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; width: 100%; max-width: 580px; max-height: 90vh; overflow-y: auto; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.6); }}
|
|
.modal-title {{ font-size: 18px; font-weight: 700; margin-bottom: 16px; }}
|
|
.form-group {{ margin-bottom: 16px; }}
|
|
.form-label {{ display: block; font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; }}
|
|
.form-control {{ width: 100%; padding: 10px 14px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px; color: #fff; font-size: 13px; font-family: inherit; }}
|
|
.form-control:focus {{ outline: none; border-color: var(--primary); }}
|
|
.modal-actions {{ display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }}
|
|
textarea.form-control {{ font-family: 'JetBrains Mono', monospace; font-size: 12px; resize: vertical; }}
|
|
|
|
.alert {{ padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px; display: none; }}
|
|
.alert-success {{ background: rgba(16, 185, 129, 0.15); border: 1px solid var(--accent); color: #34d399; }}
|
|
.alert-error {{ background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #f87171; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<div class="logo-group">
|
|
<div class="logo-icon">
|
|
<svg width="26" height="26" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139" fill="#0E1A2E" stroke="#00E5FF" stroke-width="24"/>
|
|
<rect x="140" y="140" width="46" height="232" rx="10" fill="#00E5FF"/>
|
|
<rect x="140" y="326" width="220" height="46" rx="10" fill="#00E5FF"/>
|
|
<rect x="220" y="210" width="36" height="80" rx="8" fill="#00FFA3"/>
|
|
<rect x="286" y="150" width="36" height="140" rx="8" fill="#00E5FF"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1>LOGAR Fleet Hub</h1>
|
|
<div class="subtitle">Fleet Log Aggregation & Centralized Provisioning</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button onclick="openExportProfileModal()" class="btn btn-primary">📥 Export Client Profile</button>
|
|
<div class="user-menu-container" tabindex="0">
|
|
<div class="user-tag" onclick="toggleUserDropdown(event)">
|
|
<span>👤 <strong>{auth_user}</strong></span>
|
|
<span style="font-size: 10px; color: var(--text-muted);">▼</span>
|
|
</div>
|
|
<div class="user-dropdown" id="userDropdown">
|
|
<div class="user-dropdown-header">Logged in as {auth_user}</div>
|
|
<button class="user-dropdown-item" onclick="openTwoFactorModal(); hideUserDropdown();">🛡️ Two-Factor Auth (2FA)</button>
|
|
<button class="user-dropdown-item" onclick="openPasswordModal(); hideUserDropdown();">🔑 Change Password</button>
|
|
<a href="/logout" class="user-dropdown-item danger">🚪 Logout</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="statusAlert" class="alert"></div>
|
|
|
|
{unlicensed_banner_html}
|
|
|
|
<!-- Navigation Tabs -->
|
|
<div class="tabs-nav">
|
|
<button class="tab-btn active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders ({len(clients)})</button>
|
|
<button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens ({len(hermes_tokens)})</button>
|
|
<button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry ({len(anomalies)})</button>
|
|
<button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button>
|
|
<button class="tab-btn" id="tabBtn-db" onclick="switchTab('db')">💾 DB Engine {db_tab_btn_badge}</button>
|
|
</div>
|
|
|
|
<!-- KPI Summary Cards -->
|
|
<div class="grid-kpi">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Seat Quota Usage</span>
|
|
<span class="badge {badge_status_cls}">{summary['status']}</span>
|
|
</div>
|
|
<div class="kpi-value">{summary['active_seats']} <span style="font-size: 14px; color: var(--text-muted);">/ {summary['max_seats']} Seats</span></div>
|
|
<div class="kpi-sub">{summary['available_seats']} seats available for automatic leasing</div>
|
|
<div class="progress-container">
|
|
<div class="progress-bar" style="width: {summary['percentage']}%; background-color: {bar_color};"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Hub Status</span>
|
|
<span class="badge badge-active"><span class="pulse-dot" style="margin-right:4px;"></span> HEALTHY</span>
|
|
</div>
|
|
<div class="kpi-value" style="font-size: 18px; color: #60a5fa;">mTLS Port {config.get('tcp_port', 9443)}</div>
|
|
<div class="kpi-sub">Hermes Provisioning API: Port {config.get('hermes_port', 8443)}</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Subordinate CA</span>
|
|
<span class="badge badge-os">X.509 SUB-CA</span>
|
|
</div>
|
|
<div class="kpi-value" style="font-size: 16px; word-break: break-all; color:#c084fc;">{summary['license_id']}</div>
|
|
<div class="kpi-sub">Expires: <strong>{summary['not_valid_after'][:10] if summary['not_valid_after'] != 'N/A' else 'N/A'}</strong> ({summary['days_remaining']} days left)</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Fleet Forwarders</span>
|
|
<span class="badge badge-active">{len(clients)} Total</span>
|
|
</div>
|
|
<div class="kpi-value">{summary['active_seats']} <span style="font-size: 14px; color: var(--text-muted);">Active Nodes</span></div>
|
|
<div class="kpi-sub">{summary['revoked_seats']} revoked nodes blocked at TLS socket</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 1: Fleet Forwarders -->
|
|
<div class="tab-pane active" id="tabPane-fleet">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title" style="font-size: 15px;">Enrolled Forwarders & Seat Management</span>
|
|
<button onclick="openExportProfileModal()" class="btn btn-accent" style="padding:6px 12px; font-size:12px;">+ Export New Client Profile</button>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Host Node</th>
|
|
<th>Client Identifier</th>
|
|
<th>OS</th>
|
|
<th>Cert Fingerprint</th>
|
|
<th>Last Activity Time</th>
|
|
<th>Seat Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{clients_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Hermes AI Integration & Tokens -->
|
|
<div class="tab-pane" id="tabPane-hermes">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<div class="card-title" style="font-size: 15px;">Hermes AI Bearer Access Tokens</div>
|
|
<div class="subtitle" style="margin-top:4px;">Manage dedicated API tokens for Hermes AI to fetch real-time verified anomalies via <code>GET /api/hermes/report</code></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 20px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 13px; font-weight: 700; margin-bottom: 10px; color: #60a5fa;">+ Generate Dedicated Hermes Token</div>
|
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
<input type="text" id="newTokenName" class="form-control" style="flex: 1; min-width: 220px;" placeholder="Token Name (e.g. Hermes Production Agent)">
|
|
<button onclick="submitCreateToken()" class="btn btn-accent" style="white-space: nowrap;">Generate Token</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Token Name</th>
|
|
<th>Bearer Token</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Last Used</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tokens_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="margin-top: 24px; padding: 16px; background: #0f172a; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 13px; font-weight: 700; color: #34d399; margin-bottom: 8px;">🚀 Hermes AI Integration Quickstart</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 10px;">Configure Hermes to query the verified log telemetry stream with Bearer authorization:</div>
|
|
<pre style="background: #020617; padding: 12px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #38bdf8; overflow-x: auto;">curl -H "Authorization: Bearer <HERMES_TOKEN>" http://{detected_host}:{config.get('hermes_port', 8443)}/api/hermes/report</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: Verified Telemetry -->
|
|
<div class="tab-pane" id="tabPane-anomalies">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (Latest 50 Events • Rolling 12h Window)</span>
|
|
<span class="badge badge-active">Direct Hermes Intelligence</span>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Severity</th>
|
|
<th>Source Node</th>
|
|
<th>Persistence</th>
|
|
<th>Normalized Anomaly Message</th>
|
|
<th>Last Ingested</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{anomalies_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 4: Sub-CA & Licensing -->
|
|
<div class="tab-pane" id="tabPane-pki">
|
|
<div class="toolbar">
|
|
<div style="font-weight: 600; font-size: 13px; color: var(--text-muted);">PKI & Seat Synchronization:</div>
|
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
|
<button class="btn btn-accent" onclick="openImportLicModal()">📥 Import License (.lic)</button>
|
|
<button class="btn btn-secondary" onclick="triggerMtlsRenewal()">🔄 Sync & Renew via mTLS</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Subordinate Certificate Details</span>
|
|
<span class="badge {badge_status_cls}">{summary['status']}</span>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px;">
|
|
<div>
|
|
<div class="kpi-sub">Subordinate CA Common Name</div>
|
|
<div style="font-weight: 700; margin-top: 2px;">{summary['subject_cn']}</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Master Root Issuer</div>
|
|
<div style="font-weight: 700; margin-top: 2px;">{summary['issuer_cn']}</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Master License Authority</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code>{summary['issuer_url']}</code></div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Issued Seat Quota</div>
|
|
<div style="font-weight: 700; color: #34d399; margin-top: 2px;">{summary['max_seats']} Seats</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Certificate Validity</div>
|
|
<div style="font-weight: 700; margin-top: 2px;">{summary['not_valid_after'][:19] if summary['not_valid_after'] != 'N/A' else 'N/A'}</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Server Hub Fingerprint</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code style="cursor:pointer;" onclick="copyText('{summary['server_fingerprint']}', 'Server Hub Fingerprint')">{summary['server_fingerprint']}</code></div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">CA Certificate Fingerprint</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code style="cursor:pointer;" onclick="copyText('{summary['fingerprint']}', 'CA Certificate Fingerprint')">{summary['fingerprint']}</code></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{db_tab_pane_html}
|
|
</div>
|
|
|
|
<!-- Export Client Profile Modal -->
|
|
<div id="exportProfileModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-title">📥 Export Edge Client Profile</div>
|
|
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
|
|
Download a pre-configured <code>client_config.json</code> profile bundle for zero-touch Windows & Linux forwarder deployment.
|
|
</p>
|
|
<div class="form-group">
|
|
<label class="form-label">Server Target Host / IP</label>
|
|
<input type="text" id="exportHost" class="form-control" value="{detected_host}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">mTLS Ingestion Port</label>
|
|
<input type="number" id="exportPort" class="form-control" value="{config.get('tcp_port', 9443)}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Pre-allocated Client ID (Optional)</label>
|
|
<input type="text" id="exportClientId" class="form-control" placeholder="e.g. worker-01.corp.internal">
|
|
</div>
|
|
|
|
<div style="background: #0f172a; padding: 12px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 16px;">
|
|
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px;">Edge Client Quick Deploy Command</div>
|
|
<pre id="quickDeployCmd" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #38bdf8; word-break: break-all; white-space: pre-wrap;">sudo ./compilation/install_linux_client.sh "http://{detected_host}:{config.get('hermes_port', 8443)}" "{config.get('enrollment_secret')}"</pre>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('exportProfileModal')" class="btn btn-outline">Cancel</button>
|
|
<button onclick="copyProfileJson()" class="btn btn-secondary">Copy JSON</button>
|
|
<button onclick="downloadClientProfile()" class="btn btn-accent">Download client_config.json</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import License Modal -->
|
|
<div id="importLicModal" class="modal">
|
|
<div class="modal-content" style="max-width: 600px;">
|
|
<div class="modal-title">📥 Import Enterprise License (.lic)</div>
|
|
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
|
|
Select a signed <code>.lic</code> file from your computer or paste the raw license JSON / Sub-CA PEM certificate below to activate server seats.
|
|
</p>
|
|
<div class="form-group">
|
|
<label class="form-label">Upload .lic File</label>
|
|
<input type="file" id="licFileInput" class="form-control" accept=".lic,.json,.crt,.pem" onchange="handleLicFile(event)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Or Paste License JSON / PEM</label>
|
|
<textarea id="licRawText" class="form-control" rows="8" placeholder="Paste .lic JSON content or PEM certificate here..."></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('importLicModal')" class="btn btn-outline">Cancel</button>
|
|
<button onclick="submitImportLic()" class="btn btn-accent">Activate License</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password Modal -->
|
|
<div id="passwordModal" class="modal">
|
|
<div class="modal-content" style="max-width: 440px;">
|
|
<div class="modal-title">Change Password</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Current Password</label>
|
|
<input type="password" id="oldPass" class="form-control" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">New Password</label>
|
|
<input type="password" id="newPass" class="form-control" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Confirm New Password</label>
|
|
<input type="password" id="confirmPass" class="form-control" required>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" onclick="closeModal('passwordModal')">Cancel</button>
|
|
<button class="btn btn-primary" onclick="submitChangePassword()">Update Password</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Two-Factor Authentication Modal -->
|
|
<div id="twoFactorModal" class="modal">
|
|
<div class="modal-content" style="max-width: 480px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid var(--border); padding-bottom: 14px;">
|
|
<div class="modal-title" style="margin-bottom: 0; display: flex; align-items: center; gap: 8px;">
|
|
<span>🛡️</span> Two-Factor Authentication (2FA)
|
|
</div>
|
|
<div style="cursor: pointer; font-size: 22px; color: var(--text-muted); line-height: 1;" onclick="closeModal('twoFactorModal')">×</div>
|
|
</div>
|
|
|
|
<div id="twoFactorLoading" style="text-align: center; padding: 24px; color: var(--text-muted);">
|
|
Loading 2FA security status...
|
|
</div>
|
|
|
|
<!-- 2FA Active Section -->
|
|
<div id="twoFactorActiveSection" style="display: none;">
|
|
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px;">
|
|
<div style="font-size: 24px;">🟢</div>
|
|
<div>
|
|
<div style="font-weight: 700; color: #34d399;">2FA is Currently Active</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Your account is secured with TOTP authenticator verification.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom: 20px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 13px; font-weight: 700; color: #ef4444; margin-bottom: 8px;">Disable Two-Factor Authentication</div>
|
|
<p style="font-size: 12px; color: var(--text-muted); margin-bottom: 12px;">Enter your account password to confirm disabling 2FA:</p>
|
|
<div class="form-group" style="margin-bottom: 12px;">
|
|
<input type="password" id="twoFactorDisablePassword" class="form-control" placeholder="Current Account Password">
|
|
</div>
|
|
<button onclick="submitDisable2FA()" class="btn btn-danger" style="width: 100%;">Disable 2FA</button>
|
|
</div>
|
|
|
|
<div style="text-align: center; margin-top: 16px;">
|
|
<button onclick="start2FASetup()" class="btn btn-outline" style="font-size: 12px;">🔄 Re-configure on New Device</button>
|
|
</div>
|
|
|
|
<div class="modal-actions" style="margin-top: 20px;">
|
|
<button onclick="closeModal('twoFactorModal')" class="btn btn-primary">Close</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2FA Setup Section -->
|
|
<div id="twoFactorSetupSection" style="display: none;">
|
|
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
|
|
Scan this QR code with an authenticator app (Google Authenticator, Authy, 1Password, Bitwarden, etc.):
|
|
</p>
|
|
|
|
<div style="display: flex; justify-content: center; align-items: center; background: #ffffff; padding: 16px; border-radius: 12px; margin-bottom: 16px; min-height: 190px;">
|
|
<div id="qrcodeCanvas"></div>
|
|
</div>
|
|
|
|
<div style="background: #0f172a; padding: 12px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 18px;">
|
|
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 4px;">Manual Setup Secret Key</div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 8px;">
|
|
<code id="twoFactorSecretText" style="font-size: 13px; color: #60a5fa; word-break: break-all; user-select: all;"></code>
|
|
<button onclick="copy2FASecret()" class="btn btn-outline" style="padding: 4px 8px; font-size: 11px; white-space: nowrap;">📋 Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-bottom: 18px;">
|
|
<label class="form-label">Enter 6-Digit Authenticator Code</label>
|
|
<input type="text" id="twoFactorVerifyCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" style="text-align: center; letter-spacing: 4px; font-size: 20px; font-family: 'JetBrains Mono', monospace; font-weight: 700;">
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('twoFactorModal')" class="btn btn-outline">Cancel</button>
|
|
<button onclick="submitEnable2FA()" class="btn btn-accent">✅ Verify & Activate 2FA</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function toggleUserDropdown(e) {{
|
|
if (e) e.stopPropagation();
|
|
const d = document.getElementById('userDropdown');
|
|
if (d) d.classList.toggle('show');
|
|
}}
|
|
|
|
function hideUserDropdown() {{
|
|
const d = document.getElementById('userDropdown');
|
|
if (d) d.classList.remove('show');
|
|
}}
|
|
|
|
window.addEventListener('click', () => hideUserDropdown());
|
|
function switchTab(tabId) {{
|
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
|
|
const btn = document.getElementById('tabBtn-' + tabId);
|
|
const pane = document.getElementById('tabPane-' + tabId);
|
|
if (btn && pane) {{
|
|
btn.classList.add('active');
|
|
pane.classList.add('active');
|
|
}}
|
|
}}
|
|
|
|
function showAlert(msg, isSuccess = true) {{
|
|
const el = document.getElementById('statusAlert');
|
|
el.className = 'alert ' + (isSuccess ? 'alert-success' : 'alert-error');
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
setTimeout(() => {{ el.style.display = 'none'; }}, 5000);
|
|
}}
|
|
|
|
function openModal(id) {{ document.getElementById(id).style.display = 'flex'; }}
|
|
function closeModal(id) {{ document.getElementById(id).style.display = 'none'; }}
|
|
|
|
function copyText(text, label = 'Text') {{
|
|
navigator.clipboard.writeText(text);
|
|
showAlert(label + ' copied to clipboard!');
|
|
}}
|
|
|
|
function openExportProfileModal() {{ openModal('exportProfileModal'); }}
|
|
function openImportLicModal() {{ openModal('importLicModal'); }}
|
|
function openPasswordModal() {{ openModal('passwordModal'); }}
|
|
|
|
function downloadLicRequest() {{
|
|
window.location.href = '/api/license/export-lic';
|
|
}}
|
|
|
|
function handleLicFile(event) {{
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {{
|
|
document.getElementById('licRawText').value = e.target.result;
|
|
}};
|
|
reader.readAsText(file);
|
|
}}
|
|
|
|
async function submitImportLic() {{
|
|
const raw = document.getElementById('licRawText').value.trim();
|
|
if (!raw) return alert('Please select a .lic file or paste the license payload');
|
|
showAlert('Importing and activating license...');
|
|
try {{
|
|
let payload;
|
|
if (raw.startsWith('{{')) {{
|
|
try {{
|
|
payload = JSON.parse(raw);
|
|
}} catch (e) {{
|
|
payload = {{ "lic": raw }};
|
|
}}
|
|
}} else {{
|
|
payload = {{ "lic": raw }};
|
|
}}
|
|
|
|
const res = await fetch('/api/license/import', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify(payload)
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok) {{
|
|
showAlert('License Activated! ' + (data.message || ''));
|
|
closeModal('importLicModal');
|
|
setTimeout(() => location.reload(), 1000);
|
|
}} else {{
|
|
alert('License Import Failed: ' + (data.detail || 'Validation error'));
|
|
}}
|
|
}} catch (err) {{
|
|
alert('Import request failed: ' + err.message);
|
|
}}
|
|
}}
|
|
|
|
function downloadClientProfile() {{
|
|
const host = document.getElementById('exportHost').value.trim();
|
|
const port = document.getElementById('exportPort').value.trim();
|
|
const cid = document.getElementById('exportClientId').value.trim();
|
|
const url = `/api/client/export-profile?server_host=${{encodeURIComponent(host)}}&server_port=${{encodeURIComponent(port)}}&client_id=${{encodeURIComponent(cid)}}`;
|
|
window.location.href = url;
|
|
closeModal('exportProfileModal');
|
|
}}
|
|
|
|
async function copyProfileJson() {{
|
|
const host = document.getElementById('exportHost').value.trim();
|
|
const port = document.getElementById('exportPort').value.trim();
|
|
const cid = document.getElementById('exportClientId').value.trim();
|
|
const res = await fetch(`/api/client/export-profile?server_host=${{encodeURIComponent(host)}}&server_port=${{encodeURIComponent(port)}}&client_id=${{encodeURIComponent(cid)}}`);
|
|
if (res.ok) {{
|
|
const json = await res.text();
|
|
navigator.clipboard.writeText(json);
|
|
showAlert('Client profile JSON copied to clipboard!');
|
|
}}
|
|
}}
|
|
|
|
async function submitCreateToken() {{
|
|
const name = document.getElementById('newTokenName').value.trim();
|
|
if (!name) return alert('Please enter a token name');
|
|
const res = await fetch('/api/hermes/tokens/create', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ name: name }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert('Hermes token created successfully!');
|
|
setTimeout(() => location.reload(), 800);
|
|
}} else {{
|
|
alert('Failed to create token');
|
|
}}
|
|
}}
|
|
|
|
async function revokeToken(tok) {{
|
|
if (!confirm('Are you sure you want to revoke this Hermes token?')) return;
|
|
const res = await fetch('/api/hermes/tokens/revoke', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ token: tok }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert('Hermes token revoked');
|
|
setTimeout(() => location.reload(), 600);
|
|
}}
|
|
}}
|
|
|
|
async function deleteToken(tok) {{
|
|
if (!confirm('Delete this Hermes token record?')) return;
|
|
const res = await fetch('/api/hermes/tokens/delete', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ token: tok }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert('Hermes token deleted');
|
|
setTimeout(() => location.reload(), 600);
|
|
}}
|
|
}}
|
|
|
|
async function approveClient(cid) {{
|
|
const res = await fetch('/api/clients/approve', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ client_id: cid }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert(`Client ${{cid}} approved & activated.`);
|
|
setTimeout(() => location.reload(), 800);
|
|
}} else {{
|
|
const err = await res.json();
|
|
alert('Activation failed: ' + (err.detail || 'Quota limit reached'));
|
|
}}
|
|
}}
|
|
|
|
async function revokeClient(cid) {{
|
|
if (!confirm(`Revoke seat for ${{cid}}? The forwarder will be immediately blocked at TLS handshake.`)) return;
|
|
const res = await fetch('/api/clients/revoke', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ client_id: cid }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert(`Client ${{cid}} seat revoked.`);
|
|
setTimeout(() => location.reload(), 800);
|
|
}}
|
|
}}
|
|
|
|
async function deleteClient(cid) {{
|
|
if (!confirm(`Delete client record for ${{cid}}? This will permanently free the seat quota.`)) return;
|
|
const res = await fetch('/api/clients/delete', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ client_id: cid }})
|
|
}});
|
|
if (res.ok) {{
|
|
showAlert(`Client ${{cid}} removed from fleet.`);
|
|
setTimeout(() => location.reload(), 800);
|
|
}}
|
|
}}
|
|
|
|
async function triggerMtlsRenewal() {{
|
|
if (!confirm('Sync with master authority to renew certificate and check for seat increases?')) return;
|
|
showAlert('Syncing with Master Authority...');
|
|
const resp = await fetch('/api/license/sync', {{ method: 'POST' }});
|
|
const data = await resp.json();
|
|
if (resp.ok) {{
|
|
showAlert('Sync Successful! ' + data.message + ' (Active Seats: ' + data.seats + ')');
|
|
setTimeout(() => location.reload(), 1200);
|
|
}} else {{
|
|
alert('Sync Failed: ' + (data.detail || 'Sync rejected'));
|
|
}}
|
|
}}
|
|
|
|
async function submitChangePassword() {{
|
|
const oldP = document.getElementById('oldPass').value;
|
|
const newP = document.getElementById('newPass').value;
|
|
const confP = document.getElementById('confirmPass').value;
|
|
if (!oldP || !newP) return alert('Please fill in all password fields');
|
|
if (newP !== confP) return alert('New passwords do not match');
|
|
const res = await fetch('/api/auth/change-password', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ old_password: oldP, new_password: newP }})
|
|
}});
|
|
if (res.ok) {{
|
|
alert('Password changed successfully. Please log in again.');
|
|
window.location.href = '/logout';
|
|
}} else {{
|
|
const data = await res.json();
|
|
alert(data.detail || 'Failed to change password');
|
|
}}
|
|
}}
|
|
|
|
// --- Storage Engine & Buffer Functions ---
|
|
function toggleStorageEngineForm() {{
|
|
const isPg = document.getElementById('engineRadioPostgres') && document.getElementById('engineRadioPostgres').checked;
|
|
const sec = document.getElementById('pgConfigSection');
|
|
if (sec) sec.style.display = isPg ? 'block' : 'none';
|
|
}}
|
|
|
|
function toggleCacheModeParams() {{
|
|
const isBuf = document.getElementById('cacheModeBuffered') && document.getElementById('cacheModeBuffered').checked;
|
|
const sec = document.getElementById('bufferParamsSection');
|
|
if (sec) sec.style.display = isBuf ? 'grid' : 'none';
|
|
}}
|
|
|
|
function getFullDbConfigFromForm() {{
|
|
const isPg = document.getElementById('engineRadioPostgres') && document.getElementById('engineRadioPostgres').checked;
|
|
const isBuf = document.getElementById('cacheModeBuffered') && document.getElementById('cacheModeBuffered').checked;
|
|
const encryptAtRest = document.getElementById('encryptAtRestCheck') ? document.getElementById('encryptAtRestCheck').checked : false;
|
|
|
|
const flushInterval = document.getElementById('bufferFlushInterval') ? parseFloat(document.getElementById('bufferFlushInterval').value) || 2.0 : 2.0;
|
|
const batchSize = document.getElementById('bufferBatchSize') ? parseInt(document.getElementById('bufferBatchSize').value) || 100 : 100;
|
|
const maxQueue = document.getElementById('bufferMaxQueue') ? parseInt(document.getElementById('bufferMaxQueue').value) || 50000 : 50000;
|
|
|
|
const pgConfig = {{
|
|
host: document.getElementById('pgHost') ? document.getElementById('pgHost').value.trim() : 'localhost',
|
|
port: document.getElementById('pgPort') ? parseInt(document.getElementById('pgPort').value.trim()) || 5432 : 5432,
|
|
database: document.getElementById('pgDatabase') ? document.getElementById('pgDatabase').value.trim() : 'logar',
|
|
user: document.getElementById('pgUser') ? document.getElementById('pgUser').value.trim() : 'postgres',
|
|
password: document.getElementById('pgPassword') ? document.getElementById('pgPassword').value : '',
|
|
sslmode: document.getElementById('pgSslMode') ? document.getElementById('pgSslMode').value : 'prefer',
|
|
connect_timeout: document.getElementById('pgConnectTimeout') ? parseInt(document.getElementById('pgConnectTimeout').value.trim()) || 5 : 5,
|
|
pool_size: document.getElementById('pgPoolSize') ? parseInt(document.getElementById('pgPoolSize').value.trim()) || 10 : 10
|
|
}};
|
|
|
|
return {{
|
|
storage_engine: isPg ? 'postgres' : 'sqlite',
|
|
cache_mode: isBuf ? 'buffered' : 'direct',
|
|
flush_interval: flushInterval,
|
|
batch_size: batchSize,
|
|
max_queue: maxQueue,
|
|
encrypt_at_rest: encryptAtRest,
|
|
pg_config: pgConfig
|
|
}};
|
|
}}
|
|
|
|
async function saveDbConfig() {{
|
|
try {{
|
|
showAlert('Saving database and cache configuration...');
|
|
const payload = getFullDbConfigFromForm();
|
|
const res = await fetch('/api/db/config', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify(payload)
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok && data.status === 'success') {{
|
|
showAlert('✅ Database and cache settings successfully saved!');
|
|
setTimeout(() => location.reload(), 1000);
|
|
}} else {{
|
|
alert('❌ Save failed: ' + (data.detail || data.message || 'Unknown error'));
|
|
}}
|
|
}} catch (err) {{
|
|
alert('Request failed: ' + err.message);
|
|
}}
|
|
}}
|
|
|
|
async function flushIngestBuffer() {{
|
|
try {{
|
|
showAlert('Flushing in-memory ingestion buffer...');
|
|
const res = await fetch('/api/db/flush-buffer', {{ method: 'POST' }});
|
|
const data = await res.json();
|
|
if (res.ok) {{
|
|
showAlert('Buffer flushed: ' + (data.message || 'Flushed'));
|
|
setTimeout(() => location.reload(), 800);
|
|
}} else {{
|
|
alert('Flush failed: ' + (data.detail || 'Error'));
|
|
}}
|
|
}} catch (err) {{
|
|
alert('Buffer flush failed: ' + err.message);
|
|
}}
|
|
}}
|
|
|
|
function getPgConfigFromForm() {{
|
|
return {{
|
|
host: document.getElementById('pgHost') ? document.getElementById('pgHost').value.trim() : 'localhost',
|
|
port: document.getElementById('pgPort') ? parseInt(document.getElementById('pgPort').value.trim()) || 5432 : 5432,
|
|
database: document.getElementById('pgDatabase') ? document.getElementById('pgDatabase').value.trim() : 'logar',
|
|
user: document.getElementById('pgUser') ? document.getElementById('pgUser').value.trim() : 'postgres',
|
|
password: document.getElementById('pgPassword') ? document.getElementById('pgPassword').value : '',
|
|
sslmode: document.getElementById('pgSslMode') ? document.getElementById('pgSslMode').value : 'prefer',
|
|
connect_timeout: document.getElementById('pgConnectTimeout') ? parseInt(document.getElementById('pgConnectTimeout').value.trim()) || 5 : 5,
|
|
pool_size: document.getElementById('pgPoolSize') ? parseInt(document.getElementById('pgPoolSize').value.trim()) || 10 : 10
|
|
}};
|
|
}}
|
|
|
|
async function testPgConnection() {{
|
|
try {{
|
|
showAlert('Testing connection to PostgreSQL...');
|
|
const cfg = getPgConfigFromForm();
|
|
const res = await fetch('/api/db/test-connection', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify(cfg)
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok && data.status === 'success') {{
|
|
showAlert('✅ PostgreSQL connection successful: ' + data.message);
|
|
}} else {{
|
|
alert('❌ PostgreSQL connection test failed: ' + (data.message || data.detail || 'Unknown error'));
|
|
}}
|
|
}} catch (err) {{
|
|
alert('Test connection request failed: ' + err.message);
|
|
}}
|
|
}}
|
|
|
|
async function migrateToPostgres() {{
|
|
try {{
|
|
const cfg = getPgConfigFromForm();
|
|
if (!confirm('Migrate all data from SQLite database to PostgreSQL? This will transfer existing telemetry, seat records, and user credentials with optional encryption at rest.')) return;
|
|
|
|
showAlert('Migrating SQLite data to PostgreSQL...');
|
|
const res = await fetch('/api/db/migrate-to-postgres', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify(cfg)
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok && data.status === 'success') {{
|
|
showAlert('Migration completed! ' + JSON.stringify(data.counts));
|
|
setTimeout(() => location.reload(), 1500);
|
|
}} else {{
|
|
alert('Migration failed: ' + (data.message || data.detail || 'Error'));
|
|
}}
|
|
}} catch (err) {{
|
|
alert('Migration request failed: ' + err.message);
|
|
}}
|
|
}}
|
|
|
|
// --- 2FA Management & QRCode Logic ---
|
|
let current2FASecret = '';
|
|
|
|
async function openTwoFactorModal() {{
|
|
openModal('twoFactorModal');
|
|
document.getElementById('twoFactorLoading').style.display = 'block';
|
|
document.getElementById('twoFactorActiveSection').style.display = 'none';
|
|
document.getElementById('twoFactorSetupSection').style.display = 'none';
|
|
|
|
try {{
|
|
const res = await fetch('/api/user/2fa/status');
|
|
const data = await res.json();
|
|
document.getElementById('twoFactorLoading').style.display = 'none';
|
|
if (data.enabled) {{
|
|
document.getElementById('twoFactorActiveSection').style.display = 'block';
|
|
}} else {{
|
|
start2FASetup();
|
|
}}
|
|
}} catch(ex) {{
|
|
document.getElementById('twoFactorLoading').textContent = 'Failed to load 2FA status: ' + ex.message;
|
|
}}
|
|
}}
|
|
|
|
async function start2FASetup() {{
|
|
document.getElementById('twoFactorLoading').style.display = 'block';
|
|
document.getElementById('twoFactorActiveSection').style.display = 'none';
|
|
document.getElementById('twoFactorSetupSection').style.display = 'none';
|
|
|
|
try {{
|
|
const res = await fetch('/api/user/2fa/setup');
|
|
const data = await res.json();
|
|
current2FASecret = data.secret;
|
|
|
|
document.getElementById('twoFactorSecretText').textContent = data.secret;
|
|
document.getElementById('twoFactorVerifyCode').value = '';
|
|
|
|
// Render QR code
|
|
renderQRCode('qrcodeCanvas', data.otpauth_uri);
|
|
|
|
document.getElementById('twoFactorLoading').style.display = 'none';
|
|
document.getElementById('twoFactorSetupSection').style.display = 'block';
|
|
}} catch(ex) {{
|
|
document.getElementById('twoFactorLoading').textContent = 'Failed to generate 2FA setup: ' + ex.message;
|
|
}}
|
|
}}
|
|
|
|
function copy2FASecret() {{
|
|
if (!current2FASecret) return;
|
|
navigator.clipboard.writeText(current2FASecret);
|
|
showAlert('2FA Secret copied to clipboard!');
|
|
}}
|
|
|
|
async function submitEnable2FA() {{
|
|
const code = document.getElementById('twoFactorVerifyCode').value.trim();
|
|
if (!code || code.length !== 6) {{
|
|
alert('Please enter a valid 6-digit verification code.');
|
|
return;
|
|
}}
|
|
try {{
|
|
const res = await fetch('/api/user/2fa/enable', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ secret: current2FASecret, code: code }})
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok) {{
|
|
showAlert('Two-factor authentication (2FA) successfully activated!');
|
|
closeModal('twoFactorModal');
|
|
setTimeout(() => location.reload(), 1000);
|
|
}} else {{
|
|
alert(data.detail || 'Failed to verify code. Please check your authenticator clock.');
|
|
}}
|
|
}} catch(ex) {{
|
|
alert('Connection error: ' + ex.message);
|
|
}}
|
|
}}
|
|
|
|
async function submitDisable2FA() {{
|
|
const pwd = document.getElementById('twoFactorDisablePassword').value;
|
|
if (!pwd) {{
|
|
alert('Please enter your account password to confirm.');
|
|
return;
|
|
}}
|
|
if (!confirm('Are you sure you want to disable two-factor authentication?')) return;
|
|
|
|
try {{
|
|
const res = await fetch('/api/user/2fa/disable', {{
|
|
method: 'POST',
|
|
headers: {{ 'Content-Type': 'application/json' }},
|
|
body: JSON.stringify({{ password: pwd }})
|
|
}});
|
|
const data = await res.json();
|
|
if (res.ok) {{
|
|
showAlert('2FA disabled successfully.');
|
|
closeModal('twoFactorModal');
|
|
setTimeout(() => location.reload(), 1000);
|
|
}} else {{
|
|
alert(data.detail || 'Failed to disable 2FA. Incorrect password.');
|
|
}}
|
|
}} catch(ex) {{
|
|
alert('Connection error: ' + ex.message);
|
|
}}
|
|
}}
|
|
|
|
function renderQRCode(containerId, text) {{
|
|
const el = document.getElementById(containerId);
|
|
if (!el) return;
|
|
el.innerHTML = '';
|
|
const img = document.createElement('img');
|
|
img.style.width = '180px';
|
|
img.style.height = '180px';
|
|
img.style.display = 'block';
|
|
img.alt = '2FA QR Code';
|
|
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodeURIComponent(text);
|
|
el.appendChild(img);
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
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")
|
|
|
|
# Start mTLS / TCP Socket Server
|
|
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx)
|
|
mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP"
|
|
print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}")
|
|
|
|
# Start FastAPI / Uvicorn server for Hermes & Enrollment
|
|
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
|
|
uv_server = uvicorn.Server(uv_config)
|
|
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
|
|
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)
|
|
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")
|
|
|
|
# Handle 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)
|
|
|
|
# Handle 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()
|
|
|
|
# Regenerate server certificate signed by new CA
|
|
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
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)
|
|
|
|
# Handle 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:
|
|
# Fallback to mTLS HTTP sync
|
|
print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...")
|
|
http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "")
|
|
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
|
|
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 (Subordinate CA and Server TLS Certificate)
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
|
|
|
# Auto-extract seats if ca.crt contains custom enterprise OID
|
|
ca_info = enrollment.extract_license_info_from_cert(ca_pem)
|
|
if ca_info.get("seats"):
|
|
config["max_seats"] = ca_info["seats"]
|
|
conn = sqlite3.connect(config["db_path"])
|
|
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (ca_info["seats"],))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Ensure CA CSR and exportable .lic exist for convenience
|
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
|
if not os.path.exists(csr_path):
|
|
enrollment.generate_ca_csr(cert_dir=cert_dir)
|
|
export_license_request_lic(config)
|
|
|
|
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
|
|
)
|
|
|
|
# Initialize mTLS SSLContext if enabled
|
|
ssl_ctx = None
|
|
if config.get("tls_enabled", True):
|
|
ssl_ctx = init_mtls_server_context(cert_dir=cert_dir)
|
|
|
|
# Load OpenPGP private key into memory (legacy fallback)
|
|
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
|
SERVER_STATE["config"] = config
|
|
SERVER_STATE["private_key_obj"] = priv_key_obj
|
|
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['server_name']}")
|
|
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['server_fingerprint']}")
|
|
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() |