Files
LOGAR/src/Server.py
T
me0nline 4396052ed3
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 21s
feat(licensing): report active seat quota and usage to upstream license authority
2026-09-25 22:53:26 +02:00

2680 lines
113 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 warnings
import threading
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional, Tuple
# 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
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
import uvicorn
try:
from src import server_enrollment as enrollment
except ImportError:
import server_enrollment as enrollment
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"
SESSION_SECRET = os.environ.get("LOGAR_SESSION_SECRET", "logar_server_hub_session_secret_2026_!#*")
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="2.0.1")
# 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
# --- 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) -> 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}"
sig = hmac.new(SESSION_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def verify_session_token(token: str, max_age_hours: int = 24) -> 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}"
expected_sig = hmac.new(SESSION_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."""
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
user = verify_session_token(cookie)
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)
if user:
return user
return None
def require_admin_auth(request: Request) -> str:
"""Enforces administrator authentication for management endpoints."""
user = get_current_user_optional(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required. Please login.")
return user
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
# --- 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],
ciphers=[SymmetricKeyAlgorithm.AES256],
compression=[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 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."""
if os.path.exists(config_path):
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
with open(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"] = 10
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
return config
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)
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,
"max_seats": 10,
"license_server_url": "https://logar.eibl.tech",
"cert_dir": "certs",
"tls_enabled": True,
"db_path": DEFAULT_DB_FILE,
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
"min_persistence_runs": RUN_THRESHOLD,
"server_fingerprint": fingerprint,
"public_key": public_key,
"private_key": private_key
}
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
print(f"[+] 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": "2.0.1",
"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 = 10):
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota."""
conn = sqlite3.connect(db_path)
# 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 10,
enrollment_secret TEXT NOT NULL,
license_id TEXT,
license_server_url TEXT,
issued_at TEXT,
expires_at TEXT,
license_status TEXT DEFAULT 'active'
)
""")
# 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 'active'")
]:
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
)
""")
# 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))
if enrollment_secret:
conn.execute("""
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
VALUES (1, ?, ?)
""", (max_seats, enrollment_secret))
conn.commit()
conn.close()
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 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.
"""
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()
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Record the batch run
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()
# Edge forwarder filter safeguard: retain INFO to ERROR / CRITICAL; strip verbose debug noise
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}"
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
# 12-hour evaluation window expiry check
if (now - last_seen_dt) > timedelta(hours=window_hours):
new_runs = 1
new_first_seen = now_iso
else:
if last_run_id != run_id:
new_runs = run_count + 1
else:
new_runs = 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, 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 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)
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)
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'").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
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"]
)
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>
<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: 48px; height: 48px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; color: #fff; 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">🛰️</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">
<label class="form-label">Username</label>
<input type="text" id="username" class="form-control" placeholder="admin" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" id="password" class="form-control" placeholder="••••••••" required>
</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 errEl = document.getElementById('loginError');
const btn = document.getElementById('submitBtn');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'Authenticating...';
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
const data = await res.json();
if (res.ok && data.status === 'success') {
window.location.href = '/';
} else {
errEl.textContent = data.detail || 'Invalid username or password';
errEl.style.display = 'block';
btn.disabled = false;
btn.textContent = '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."""
username, password = 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")
except Exception:
pass
else:
form = await request.form()
username = form.get("username")
password = form.get("password")
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password are required")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not verify_user_credentials(username, password, db_path=db_path):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = generate_session_token(username)
response = JSONResponse(content={"status": "success", "username": username, "token": token})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
httponly=True,
max_age=86400,
samesite="lax"
)
return response
@app.get("/logout")
def process_logout():
"""Logs out and redirects to /login."""
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE_NAME)
return resp
@app.post("/api/auth/change-password")
async def api_change_password(req: ChangePasswordRequest, current_user: str = Depends(require_admin_auth)):
"""Allows authenticated administrator to change password."""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
if not verify_user_credentials(current_user, req.old_password, db_path=db_path):
raise HTTPException(status_code=403, detail="Current password incorrect")
if len(req.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
change_user_password(current_user, req.new_password, db_path=db_path)
return {"status": "success", "message": "Password updated successfully"}
@app.post("/api/client/enroll")
def enroll_client(req: ClientEnrollRequest):
"""
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.
"""
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path)
c = conn.cursor()
# 1. Validate enrollment secret against license_config
c.execute("SELECT enrollment_secret, max_seats 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
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
conn.close()
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
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")
# 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'")
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")
@app.post("/api/client/export-profile")
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")
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")
return {
"active_seats": active_count,
"max_seats": max_seats,
"clients": clients
}
@app.post("/api/clients/approve")
@app.post("/api/clients/reactivate")
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'")
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")
def revoke_client(action: ClientActionRequest):
"""Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes."""
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 = ?", (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")
@app.post("/api/clients/remove")
def delete_client(action: ClientActionRequest):
"""Permanently removes a client forwarder record from SQLite, immediately freeing its seat."""
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 = ?", (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": 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": 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", 10)
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
row = c.fetchone()
if row and row[0]:
max_seats = row[0]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
active_seats = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked'")
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-LOCAL"
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
status_label = "ACTIVE"
if info.get("is_expired"):
status_label = "EXPIRED"
elif not info.get("seats") and info.get("is_self_signed"):
status_label = "SELF-SIGNED"
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")
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")
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")
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")
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw")
if not raw_input:
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request")
if isinstance(raw_input, dict):
raw_text = json.dumps(raw_input)
else:
raw_text = str(raw_input)
config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs")
try:
res = enrollment.import_license_package(raw_text, cert_dir=cert_dir)
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", 10)
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")
@app.post("/api/license/sync")
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'")
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')}).")
return {
"status": "success",
"message": msg,
"seats": info.get("seats"),
"expires_at": info.get("not_valid_after"),
"days_remaining": info.get("days_remaining")
}
# --- 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)
# 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 = c.fetchall()
# 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
badge_cls = "badge-active" if st == "active" else "badge-revoked"
rel_time = format_relative_time(last)
last_display = f"<div><strong>{last[:19]}</strong></div><div style='font-size:11px; color:#94a3b8;'>{rel_time}</div>"
btn_action = (
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{cid}\')">Revoke</button>'
if st == "active" else
f'<button class="btn btn-accent" style="padding:4px 8px; font-size:11px;" onclick="approveClient(\'{cid}\')">Approve</button>'
)
btn_del = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteClient(\'{cid}\')" title="Remove client and free seat">Remove</button>'
client_rows.append(f"""
<tr>
<td><span class="badge {badge_cls}">{st.upper()}</span></td>
<td><strong>{host}</strong></td>
<td><code>{cid}</code></td>
<td><span class="badge badge-os">{ost.upper()}</span></td>
<td><code title="{fp}" style="cursor:pointer;" onclick="copyText('{fp}', 'Cert Fingerprint')">{fp[:16]}...</code></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
sev_cls = "badge-error" if sev in ("ERROR", "CRITICAL", "FATAL") else "badge-warning"
short_msg = msg[:80] + "..." if len(msg) > 80 else msg
anomaly_rows.append(f"""
<tr>
<td><span class="badge {sev_cls}">{sev}</span></td>
<td><strong>{srv}</strong></td>
<td><span class="badge badge-active">{rc} runs</span></td>
<td title="{msg}">{short_msg}</td>
<td>{ls[:19]}</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
tbadge = "badge-active" if tstatus == "active" else "badge-revoked"
masked = tok[:10] + "••••••••" + tok[-4:]
tok_action = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="revokeToken(\'{tok}\')">Revoke</button>' if tstatus == "active" else f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="deleteToken(\'{tok}\')">Delete</button>'
token_rows.append(f"""
<tr>
<td><strong>{tname}</strong></td>
<td><code style="cursor:pointer;" title="Click to copy full token" onclick="copyText('{tok}', 'Hermes API Token')">{masked}</code></td>
<td><span class="badge {tbadge}">{tstatus.upper()}</span></td>
<td style="color:#94a3b8; font-size:12px;">{tcreated[:19]}</td>
<td style="color:#94a3b8; font-size:12px;">{tlast[:19] if tlast else 'Never'}</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('{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")
total_nodes = summary["active_seats"] + summary["revoked_seats"]
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()
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 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: linear-gradient(135deg, #3b82f6, #8b5cf6);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800; color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
}}
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 {{ 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">🛰️</div>
<div>
<h1>LOGAR Fleet Hub</h1>
<div class="subtitle">Fleet Log Aggregation & Centralized AI 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">
<span>👤 <strong>{auth_user}</strong></span>
<span style="font-size: 10px; color: var(--text-muted);">▼</span>
</div>
<div class="user-dropdown">
<div class="user-dropdown-header">Logged in as {auth_user}</div>
<button class="user-dropdown-item" onclick="openPasswordModal()">🔑 Change Password</button>
<a href="/logout" class="user-dropdown-item danger">🚪 Logout</a>
</div>
</div>
</div>
</header>
<div id="statusAlert" class="alert"></div>
<!-- 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>
</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 class="kpi-sub" style="margin-top:4px;">Fingerprint: <code style="cursor:pointer;" onclick="copyText('{summary['server_fingerprint']}', 'Server Fingerprint')">{summary['server_fingerprint'][:16]}...</code></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 class="kpi-sub" style="margin-top:4px;">Authority: <code>{summary['issuer_url']}</code></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 &lt;HERMES_TOKEN&gt;" 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 (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="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">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>
</div>
</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 &amp; 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>
<!-- 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>
<script>
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 openPasswordModal() {{ openModal('passwordModal'); }}
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');
}}
}}
</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())
try:
await asyncio.gather(
tcp_server.serve_forever(),
uv_server.serve(),
watchdog_task
)
finally:
watchdog_task.cancel()
try:
await watchdog_task
except asyncio.CancelledError:
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)
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(f"[+] 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'").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(f"[+] 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()