2916 lines
124 KiB
Python
2916 lines
124 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 time
|
|
import html
|
|
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") or secrets.token_hex(32)
|
|
DEFAULT_ADMIN_USER = os.environ.get("LOGAR_ADMIN_USER", "admin")
|
|
DEFAULT_ADMIN_PASS = os.environ.get("LOGAR_ADMIN_PASS", "admin_logar_2026!")
|
|
|
|
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="3.0.0")
|
|
|
|
# Global context holding server state
|
|
SERVER_STATE: Dict[str, Any] = {}
|
|
|
|
|
|
class ClientEnrollRequest(BaseModel):
|
|
client_id: str
|
|
hostname: str
|
|
os: str
|
|
enrollment_secret: str
|
|
|
|
|
|
class ClientActionRequest(BaseModel):
|
|
client_id: str
|
|
|
|
|
|
class LicenseImportRequest(BaseModel):
|
|
ca_cert: str
|
|
upstream_ca: Optional[str] = None
|
|
|
|
|
|
class HermesTokenCreateRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
class HermesTokenRevokeRequest(BaseModel):
|
|
token: str
|
|
|
|
|
|
class ClientProfileExportRequest(BaseModel):
|
|
server_host: Optional[str] = None
|
|
server_port: Optional[int] = None
|
|
client_id: Optional[str] = None
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
old_password: str
|
|
new_password: str
|
|
|
|
|
|
# --- Rate Limiting & Brute-Force Mitigation ---
|
|
|
|
_RATE_LIMIT_LOCK = threading.Lock()
|
|
_LOGIN_FAILURES: Dict[str, List[float]] = {}
|
|
_ENROLL_FAILURES: Dict[str, List[float]] = {}
|
|
|
|
|
|
def get_client_ip(request: Optional[Request] = None) -> str:
|
|
"""Extracts client IP address respecting reverse-proxy headers."""
|
|
if not request:
|
|
return "127.0.0.1"
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
real_ip = request.headers.get("x-real-ip")
|
|
if real_ip:
|
|
return real_ip.strip()
|
|
if request.client:
|
|
return request.client.host
|
|
return "127.0.0.1"
|
|
|
|
|
|
def check_ip_rate_limit(ip: str, store: Dict[str, List[float]], max_attempts: int = 10, window_seconds: int = 60) -> bool:
|
|
"""Checks whether the client IP has exceeded failed attempts within the sliding time window."""
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
with _RATE_LIMIT_LOCK:
|
|
history = store.get(ip, [])
|
|
history = [ts for ts in history if now - ts < window_seconds]
|
|
store[ip] = history
|
|
return len(history) < max_attempts
|
|
|
|
|
|
def record_failed_ip_attempt(ip: str, store: Dict[str, List[float]]):
|
|
"""Records a failed attempt timestamp for the client IP."""
|
|
now = datetime.now(timezone.utc).timestamp()
|
|
with _RATE_LIMIT_LOCK:
|
|
if ip not in store:
|
|
store[ip] = []
|
|
store[ip].append(now)
|
|
|
|
|
|
def reset_failed_ip_attempts(ip: str, store: Dict[str, List[float]]):
|
|
"""Resets failed attempt records for client IP upon successful authentication."""
|
|
with _RATE_LIMIT_LOCK:
|
|
if ip in store:
|
|
del store[ip]
|
|
|
|
|
|
# --- Authentication & Password Hashing Utilities ---
|
|
|
|
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
|
|
"""Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations."""
|
|
if not salt:
|
|
salt = secrets.token_hex(16)
|
|
pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex()
|
|
return pwd_hash, salt
|
|
|
|
|
|
def verify_password(password: str, salt: str, password_hash: str) -> bool:
|
|
"""Verifies a password against the stored salt and PBKDF2 hash."""
|
|
test_hash, _ = hash_password(password, salt=salt)
|
|
return hmac.compare_digest(test_hash, password_hash)
|
|
|
|
|
|
def generate_session_token(username: str) -> 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."""
|
|
# 1. Check if logged in admin user via session cookie or Bearer session token
|
|
user = get_current_user_optional(request)
|
|
if user:
|
|
return user
|
|
|
|
# 2. Check Bearer token or token query param matching config auth_token
|
|
token = None
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
if not token:
|
|
token = request.query_params.get("token")
|
|
|
|
if token:
|
|
cfg_token = SERVER_STATE.get("config", {}).get("auth_token")
|
|
if cfg_token and secrets.compare_digest(str(token), str(cfg_token)):
|
|
return "config_auth_token"
|
|
|
|
raise HTTPException(status_code=401, detail="Authentication required. Please login or provide admin token.")
|
|
|
|
|
|
def verify_user_credentials(username: str, password: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Checks user credentials against SQLite users table."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,))
|
|
row = c.fetchone()
|
|
conn.close()
|
|
if not row:
|
|
return False
|
|
return verify_password(password, row[1], row[0])
|
|
|
|
|
|
def change_user_password(username: str, new_password: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Updates a user's password."""
|
|
pwd_hash, salt = hash_password(new_password)
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
# --- Hermes Token Management ---
|
|
|
|
def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]:
|
|
"""Creates or saves a named Hermes API token."""
|
|
if not token:
|
|
token = f"hermes_{secrets.token_hex(20)}"
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute(
|
|
"INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')",
|
|
(token, name, now_iso)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return {"token": token, "name": name, "created_at": now_iso, "status": "active"}
|
|
|
|
|
|
def list_hermes_tokens(db_path: str = DEFAULT_DB_FILE) -> List[Dict[str, Any]]:
|
|
"""Lists all registered Hermes access tokens."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC")
|
|
rows = [
|
|
{"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]}
|
|
for r in c.fetchall()
|
|
]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
def revoke_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Revokes an active Hermes token."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
def delete_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Deletes a Hermes token."""
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,))
|
|
changed = c.rowcount > 0
|
|
conn.commit()
|
|
conn.close()
|
|
return changed
|
|
|
|
|
|
def verify_hermes_token(token: str, db_path: str = DEFAULT_DB_FILE) -> bool:
|
|
"""Verifies a Hermes token and updates last_used timestamp."""
|
|
if not token:
|
|
return False
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,))
|
|
row = c.fetchone()
|
|
if row:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token))
|
|
conn.commit()
|
|
conn.close()
|
|
return row is not None
|
|
|
|
|
|
def require_hermes_or_admin_auth(request: Request) -> str:
|
|
"""
|
|
Authorizes Hermes API access via Bearer token, query parameter,
|
|
active admin session cookie, or legacy auth_token.
|
|
"""
|
|
# 1. Check if logged in admin user
|
|
user = get_current_user_optional(request)
|
|
if user:
|
|
return f"user:{user}"
|
|
|
|
# 2. Check Bearer token or token query param
|
|
token = None
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.startswith("Bearer "):
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
if not token:
|
|
token = request.query_params.get("token")
|
|
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
|
|
if token:
|
|
# Check against hermes_tokens table
|
|
if verify_hermes_token(token, db_path=db_path):
|
|
return f"hermes_token:{token[:8]}..."
|
|
# Check against config legacy auth_token
|
|
cfg_token = SERVER_STATE.get("config", {}).get("auth_token")
|
|
if cfg_token and secrets.compare_digest(str(token), str(cfg_token)):
|
|
return "config_auth_token"
|
|
|
|
# Backward compatibility: if no hermes tokens are defined in database yet, allow unauthenticated read
|
|
try:
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT COUNT(*) FROM hermes_tokens WHERE status = 'active'")
|
|
active_count = c.fetchone()[0]
|
|
conn.close()
|
|
if active_count == 0:
|
|
return "anonymous_fallback"
|
|
except Exception:
|
|
pass
|
|
|
|
raise HTTPException(status_code=401, detail="Valid Hermes Bearer Token or Administrator Login Required")
|
|
|
|
|
|
# --- Client Profile Export & Helper Utilities ---
|
|
|
|
def generate_server_keypair(server_name: str):
|
|
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
|
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
|
uid = pgpy.PGPUID.new(server_name)
|
|
key.add_uid(
|
|
uid,
|
|
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
|
hashes=[HashAlgorithm.SHA256, HashAlgorithm.SHA512],
|
|
ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.AES128],
|
|
compression=[
|
|
CompressionAlgorithm.ZIP,
|
|
CompressionAlgorithm.ZLIB,
|
|
CompressionAlgorithm.BZ2,
|
|
CompressionAlgorithm.Uncompressed
|
|
]
|
|
)
|
|
private_key_armored = str(key)
|
|
public_key_armored = str(key.pubkey)
|
|
fingerprint = str(key.pubkey.fingerprint)
|
|
return private_key_armored, public_key_armored, fingerprint
|
|
|
|
|
|
def is_path_writable(filepath: str) -> bool:
|
|
"""Checks if a file or directory path is actually writable by the current process."""
|
|
try:
|
|
if os.path.exists(filepath):
|
|
if os.path.isdir(filepath):
|
|
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
|
with open(test_f, "w") as f:
|
|
f.write("1")
|
|
os.remove(test_f)
|
|
return True
|
|
else:
|
|
with open(filepath, "a") as f:
|
|
pass
|
|
return True
|
|
else:
|
|
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
|
os.makedirs(parent, exist_ok=True)
|
|
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
|
with open(test_f, "w") as f:
|
|
f.write("1")
|
|
os.remove(test_f)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_default_data_dir(app_name: str = "LOGAR-Server") -> str:
|
|
"""Returns a suitable writable application data directory."""
|
|
if sys.platform == "win32":
|
|
# 1. ProgramData (C:\ProgramData\LOGAR-Server)
|
|
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
|
target_prog = os.path.join(prog_data, app_name)
|
|
if is_path_writable(target_prog):
|
|
return target_prog
|
|
|
|
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR-Server)
|
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
|
target_local = os.path.join(local_app, app_name)
|
|
if is_path_writable(target_local):
|
|
return target_local
|
|
|
|
# 3. Temp directory (%TEMP%\LOGAR-Server)
|
|
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
|
if is_path_writable(temp_dir):
|
|
return temp_dir
|
|
|
|
cwd = os.getcwd()
|
|
if is_path_writable(cwd):
|
|
return cwd
|
|
return os.path.expanduser("~")
|
|
|
|
|
|
def resolve_writable_path(path: str, app_name: str = "LOGAR-Server", default_filename: str = "logar_state.db") -> str:
|
|
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
|
if not path:
|
|
path = default_filename
|
|
|
|
# If already absolute and writable, keep it
|
|
if os.path.isabs(path) and is_path_writable(path):
|
|
return path
|
|
|
|
# If relative and CWD is writable and not in Program Files, use CWD
|
|
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
|
return os.path.abspath(path)
|
|
|
|
# Otherwise route into the guaranteed writable data directory
|
|
data_dir = get_default_data_dir(app_name)
|
|
fname = os.path.basename(path)
|
|
return os.path.join(data_dir, fname)
|
|
|
|
|
|
def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
|
"""Loads existing server_config.json or creates a new one on first run."""
|
|
actual_config_path = config_path
|
|
if not os.path.isabs(actual_config_path) and not os.path.exists(actual_config_path):
|
|
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path))
|
|
if os.path.exists(data_dir_cfg):
|
|
actual_config_path = data_dir_cfg
|
|
|
|
if os.path.exists(actual_config_path):
|
|
print(f"[*] Loading server configuration from: {os.path.abspath(actual_config_path)}")
|
|
with open(actual_config_path, "r", encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
if "enrollment_secret" not in config:
|
|
config["enrollment_secret"] = secrets.token_hex(24)
|
|
if "max_seats" not in config:
|
|
config["max_seats"] = 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
|
|
if "session_secret" not in config:
|
|
config["session_secret"] = secrets.token_hex(32)
|
|
if "db_path" not in config:
|
|
config["db_path"] = DEFAULT_DB_FILE
|
|
|
|
# Resolve relative db_path and cert_dir to writable paths
|
|
config["db_path"] = resolve_writable_path(config["db_path"], "LOGAR-Server", DEFAULT_DB_FILE)
|
|
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR-Server", "certs")
|
|
|
|
global SESSION_SECRET
|
|
if not os.environ.get("LOGAR_SESSION_SECRET"):
|
|
SESSION_SECRET = config["session_secret"]
|
|
return config
|
|
|
|
# Fallback to writable data dir if CWD is read-only / Program Files
|
|
if not os.path.isabs(config_path) and ("Program Files" in os.getcwd() or not is_path_writable(os.getcwd())):
|
|
config_path = os.path.join(get_default_data_dir("LOGAR-Server"), os.path.basename(config_path))
|
|
|
|
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
|
server_name = "LOGAR-Cloud-Hub"
|
|
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
|
auth_token = secrets.token_hex(24)
|
|
enrollment_secret = secrets.token_hex(24)
|
|
session_sec = secrets.token_hex(32)
|
|
|
|
db_path = resolve_writable_path(DEFAULT_DB_FILE, "LOGAR-Server", DEFAULT_DB_FILE)
|
|
cert_dir = resolve_writable_path("certs", "LOGAR-Server", "certs")
|
|
|
|
config = {
|
|
"server_name": server_name,
|
|
"tcp_host": "0.0.0.0",
|
|
"tcp_port": 9443,
|
|
"hermes_host": "0.0.0.0",
|
|
"hermes_port": 8443,
|
|
"admin_user": DEFAULT_ADMIN_USER,
|
|
"auth_token": auth_token,
|
|
"enrollment_secret": enrollment_secret,
|
|
"session_secret": session_sec,
|
|
"max_seats": 10,
|
|
"license_server_url": "https://logar.eibl.tech",
|
|
"cert_dir": cert_dir,
|
|
"tls_enabled": True,
|
|
"db_path": db_path,
|
|
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
|
"min_persistence_runs": RUN_THRESHOLD,
|
|
"server_fingerprint": fingerprint,
|
|
"public_key": public_key,
|
|
"private_key": private_key
|
|
}
|
|
if not os.environ.get("LOGAR_SESSION_SECRET"):
|
|
SESSION_SECRET = session_sec
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True)
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
print("[+] Successfully generated new server config and OpenPGP keypair.")
|
|
print(f"[+] Server Encryption Fingerprint: {fingerprint}")
|
|
print(f"[+] Saved to: {os.path.abspath(config_path)}")
|
|
return config
|
|
|
|
|
|
def generate_client_profile_dict(
|
|
server_host: str,
|
|
server_port: int,
|
|
client_id: Optional[str] = None,
|
|
config_dict: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Generates a complete standalone client profile dictionary for export."""
|
|
cfg = config_dict or SERVER_STATE.get("config", {})
|
|
ca_pem = SERVER_STATE.get("ca_cert_pem", "")
|
|
if not ca_pem:
|
|
cert_dir = cfg.get("cert_dir", "certs")
|
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
if os.path.exists(ca_path):
|
|
with open(ca_path, "r", encoding="utf-8") as f:
|
|
ca_pem = f.read()
|
|
|
|
return {
|
|
"format": "LOGAR_CLIENT_PROFILE",
|
|
"version": "3.0.0",
|
|
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
|
"server_host": server_host,
|
|
"server_port": int(server_port),
|
|
"hermes_host": server_host if cfg.get("hermes_host") in ("0.0.0.0", "::", None) else cfg.get("hermes_host", server_host),
|
|
"hermes_port": int(cfg.get("hermes_port", 8443)),
|
|
"enrollment_secret": cfg.get("enrollment_secret", ""),
|
|
"server_fingerprint": cfg.get("server_fingerprint", ""),
|
|
"server_public_key": cfg.get("public_key", ""),
|
|
"auth_token": cfg.get("auth_token", ""),
|
|
"ca_cert": ca_pem,
|
|
"client_id": client_id or ""
|
|
}
|
|
|
|
|
|
def create_client_config(
|
|
server_host: str,
|
|
server_port: int,
|
|
output_path: str,
|
|
config_path: str = CONFIG_FILE_NAME,
|
|
hermes_host: Optional[str] = None,
|
|
hermes_port: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
|
|
server_conf = load_or_init_config(config_path)
|
|
|
|
effective_hermes_host = hermes_host
|
|
if not effective_hermes_host:
|
|
cfg_hermes = server_conf.get("hermes_host")
|
|
effective_hermes_host = server_host if cfg_hermes in ("0.0.0.0", "::", None) else cfg_hermes
|
|
|
|
client_conf = {
|
|
"server_host": server_host,
|
|
"server_port": server_port,
|
|
"hermes_host": effective_hermes_host,
|
|
"hermes_port": hermes_port or server_conf.get("hermes_port", 8443),
|
|
"enrollment_secret": server_conf.get("enrollment_secret"),
|
|
"cert_dir": "certs",
|
|
"server_fingerprint": server_conf["server_fingerprint"],
|
|
"server_public_key": server_conf["public_key"],
|
|
"auth_token": server_conf["auth_token"]
|
|
}
|
|
|
|
out_dir = os.path.dirname(os.path.abspath(output_path))
|
|
if out_dir and not os.path.exists(out_dir):
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(client_conf, f, indent=2)
|
|
|
|
print(f"[+] Client configuration successfully written to: {os.path.abspath(output_path)}")
|
|
print(f" - Server Target: {server_host}:{server_port}")
|
|
print(f" - Encryption Fingerprint: {server_conf['server_fingerprint']}")
|
|
return client_conf
|
|
|
|
|
|
def format_relative_time(dt_str: Optional[str]) -> str:
|
|
"""Calculates human-readable relative time from ISO or SQL timestamp string."""
|
|
if not dt_str or dt_str in ("N/A", "Never"):
|
|
return "Never"
|
|
try:
|
|
clean_str = dt_str.replace(" UTC", "")
|
|
if "T" in clean_str:
|
|
dt = datetime.fromisoformat(clean_str)
|
|
else:
|
|
dt = datetime.strptime(clean_str, "%Y-%m-%d %H:%M:%S")
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
now = datetime.now(timezone.utc)
|
|
diff = now - dt
|
|
seconds = int(diff.total_seconds())
|
|
if seconds < 0:
|
|
return "Just now"
|
|
if seconds < 60:
|
|
return f"{seconds}s ago"
|
|
minutes = seconds // 60
|
|
if minutes < 60:
|
|
return f"{minutes}m ago"
|
|
hours = minutes // 60
|
|
if hours < 24:
|
|
return f"{hours}h ago"
|
|
days = hours // 24
|
|
return f"{days}d ago"
|
|
except Exception:
|
|
return dt_str[:16] if dt_str else "N/A"
|
|
|
|
|
|
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10) -> str:
|
|
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, users, and license quota."""
|
|
def _execute_schema(target_path: str):
|
|
target_dir = os.path.dirname(os.path.abspath(target_path))
|
|
if target_dir:
|
|
os.makedirs(target_dir, exist_ok=True)
|
|
conn = sqlite3.connect(target_path, timeout=10.0)
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL;")
|
|
conn.execute("PRAGMA busy_timeout=5000;")
|
|
except Exception:
|
|
pass
|
|
|
|
# 1. Active Issues
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS active_issues (
|
|
fingerprint TEXT PRIMARY KEY,
|
|
site_name TEXT,
|
|
server TEXT,
|
|
signature TEXT,
|
|
severity TEXT,
|
|
message TEXT,
|
|
os_type TEXT,
|
|
first_seen TEXT,
|
|
last_seen TEXT,
|
|
run_count INTEGER,
|
|
status TEXT,
|
|
last_run_id TEXT
|
|
)
|
|
""")
|
|
|
|
# 2. Ingest Runs
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS ingest_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
site_name TEXT,
|
|
server TEXT,
|
|
timestamp TEXT,
|
|
log_count INTEGER
|
|
)
|
|
""")
|
|
|
|
# 3. License Config
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS license_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
max_seats INTEGER NOT NULL DEFAULT 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()
|
|
|
|
try:
|
|
_execute_schema(db_path)
|
|
return db_path
|
|
except (sqlite3.OperationalError, PermissionError, OSError) as e:
|
|
if "readonly" in str(e).lower() or "permission" in str(e).lower() or "locked" in str(e).lower() or isinstance(e, PermissionError):
|
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
|
fallback_dir = os.path.join(local_app, "LOGAR-Server")
|
|
os.makedirs(fallback_dir, exist_ok=True)
|
|
fallback_path = os.path.join(fallback_dir, "logar_state.db")
|
|
if fallback_path != db_path:
|
|
print(f"[!] Warning: Database path '{db_path}' is not writable ({e}). Falling back to '{fallback_path}'...")
|
|
_execute_schema(fallback_path)
|
|
return fallback_path
|
|
raise
|
|
|
|
|
|
def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
|
"""Generates CA CSR and packages into exportable .lic dictionary and JSON string."""
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
hostname = socket.gethostname()
|
|
seats = config.get("max_seats", 10)
|
|
fp = config.get("server_fingerprint", "")
|
|
return enrollment.create_license_request_lic(
|
|
cert_dir=cert_dir,
|
|
license_server=lic_server,
|
|
server_fingerprint=fp,
|
|
hostname=hostname,
|
|
requested_seats=seats
|
|
)
|
|
|
|
|
|
def 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."""
|
|
client_ip = get_client_ip(request)
|
|
if not check_ip_rate_limit(client_ip, _LOGIN_FAILURES, max_attempts=10, window_seconds=60):
|
|
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
|
|
|
|
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):
|
|
record_failed_ip_attempt(client_ip, _LOGIN_FAILURES)
|
|
await asyncio.sleep(0.5)
|
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
|
|
|
reset_failed_ip_attempts(client_ip, _LOGIN_FAILURES)
|
|
token = generate_session_token(username)
|
|
is_https = request.url.scheme == "https" or request.headers.get("x-forwarded-proto") == "https"
|
|
response = JSONResponse(content={"status": "success", "username": username, "token": token})
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=token,
|
|
httponly=True,
|
|
secure=is_https,
|
|
max_age=86400,
|
|
samesite="lax"
|
|
)
|
|
return response
|
|
|
|
|
|
@app.get("/logout")
|
|
def process_logout():
|
|
"""Logs out and redirects to /login."""
|
|
resp = RedirectResponse(url="/login", status_code=303)
|
|
resp.delete_cookie(SESSION_COOKIE_NAME)
|
|
return resp
|
|
|
|
|
|
@app.post("/api/auth/change-password")
|
|
async def api_change_password(req: ChangePasswordRequest, current_user: str = Depends(require_admin_auth)):
|
|
"""Allows authenticated administrator to change password."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
if not verify_user_credentials(current_user, req.old_password, db_path=db_path):
|
|
raise HTTPException(status_code=403, detail="Current password incorrect")
|
|
if len(req.new_password) < 6:
|
|
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
|
change_user_password(current_user, req.new_password, db_path=db_path)
|
|
return {"status": "success", "message": "Password updated successfully"}
|
|
|
|
|
|
@app.post("/api/client/enroll")
|
|
def enroll_client(req: ClientEnrollRequest, request: Request = None):
|
|
"""
|
|
Enrolls an edge client by validating the enrollment secret,
|
|
checking license seat limits, issuing a signed client certificate + key,
|
|
and recording the client in the SQLite accounting database.
|
|
"""
|
|
client_ip = get_client_ip(request)
|
|
if not check_ip_rate_limit(client_ip, _ENROLL_FAILURES, max_attempts=15, window_seconds=60):
|
|
raise HTTPException(status_code=429, detail="Too many failed enrollment attempts. Please try again later.")
|
|
|
|
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()
|
|
record_failed_ip_attempt(client_ip, _ENROLL_FAILURES)
|
|
time.sleep(0.5)
|
|
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
|
|
|
reset_failed_ip_attempts(client_ip, _ENROLL_FAILURES)
|
|
|
|
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", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/client/export-profile", dependencies=[Depends(require_admin_auth)])
|
|
def export_client_profile(
|
|
server_host: Optional[str] = None,
|
|
server_port: Optional[int] = None,
|
|
client_id: Optional[str] = None
|
|
):
|
|
"""
|
|
Exports a standalone client profile JSON configuration bundle
|
|
for zero-touch edge client bootstrapping.
|
|
"""
|
|
config = SERVER_STATE.get("config", {})
|
|
host = server_host or config.get("tcp_host", "127.0.0.1")
|
|
if host in ("0.0.0.0", ""):
|
|
host = socket.gethostname()
|
|
port = int(server_port or config.get("tcp_port", 9443))
|
|
|
|
profile = generate_client_profile_dict(
|
|
server_host=host,
|
|
server_port=port,
|
|
client_id=client_id,
|
|
config_dict=config
|
|
)
|
|
profile_json = json.dumps(profile, indent=2)
|
|
|
|
return Response(
|
|
content=profile_json,
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": 'attachment; filename="client_config.json"'}
|
|
)
|
|
|
|
|
|
@app.get("/api/clients", dependencies=[Depends(require_admin_auth)])
|
|
def list_clients():
|
|
"""Returns all registered clients, seat usage, and activity timestamps."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
|
|
lic_row = c.fetchone()
|
|
max_seats = lic_row[0] if lic_row else 10
|
|
|
|
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients ORDER BY last_seen DESC")
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
|
|
clients = [
|
|
{
|
|
"client_id": r[0],
|
|
"hostname": r[1],
|
|
"os_type": r[2],
|
|
"cert_fingerprint": r[3],
|
|
"status": r[4],
|
|
"first_seen": r[5],
|
|
"last_seen": r[6],
|
|
"last_activity_relative": format_relative_time(r[6])
|
|
}
|
|
for r in rows
|
|
]
|
|
active_count = sum(1 for cl in clients if cl["status"] == "active")
|
|
return {
|
|
"active_seats": active_count,
|
|
"max_seats": max_seats,
|
|
"clients": clients
|
|
}
|
|
|
|
|
|
@app.post("/api/clients/approve", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/clients/reactivate", dependencies=[Depends(require_admin_auth)])
|
|
def approve_client(action: ClientActionRequest):
|
|
"""Approves / reactivates a client certificate if within seat quota."""
|
|
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
|
|
row = c.fetchone()
|
|
max_seats = row[0] if row else 10
|
|
|
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
|
|
active_count = c.fetchone()[0]
|
|
if active_count >= max_seats:
|
|
conn.close()
|
|
raise HTTPException(status_code=403, detail=f"Cannot activate seat: maximum quota ({max_seats}) reached.")
|
|
|
|
c.execute("UPDATE clients SET status = 'active' WHERE client_id = ?", (action.client_id,))
|
|
affected = c.rowcount
|
|
conn.commit()
|
|
conn.close()
|
|
if affected == 0:
|
|
raise HTTPException(status_code=404, detail="Client ID not found")
|
|
print(f"[+] Approved/Reactivated client seat: {action.client_id}")
|
|
trigger_async_seat_report()
|
|
return {"status": "active", "client_id": action.client_id}
|
|
|
|
|
|
# Backward-compatible function alias for test suite
|
|
reactivate_client = approve_client
|
|
|
|
|
|
|
|
@app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)])
|
|
def revoke_client(action: ClientActionRequest):
|
|
"""Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes."""
|
|
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", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)])
|
|
def delete_client(action: ClientActionRequest):
|
|
"""Permanently removes a client forwarder record from SQLite, immediately freeing its seat."""
|
|
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", dependencies=[Depends(require_admin_auth)])
|
|
def export_lic_file():
|
|
"""Generates and downloads the logar_server.lic file containing CA CSR and authority coordinates."""
|
|
config = SERVER_STATE.get("config", {})
|
|
lic_dict, lic_json = export_license_request_lic(config)
|
|
return Response(
|
|
content=lic_json,
|
|
media_type="application/json",
|
|
headers={"Content-Disposition": 'attachment; filename="logar_server.lic"'}
|
|
)
|
|
|
|
|
|
@app.get("/api/license/csr", dependencies=[Depends(require_admin_auth)])
|
|
def get_ca_csr():
|
|
"""Returns raw PEM Certificate Signing Request (CSR) for subordinate CA."""
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
|
if not os.path.exists(csr_path):
|
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=cert_dir)
|
|
else:
|
|
with open(csr_path, "r", encoding="utf-8") as f:
|
|
csr_pem = f.read()
|
|
return {"csr": csr_pem}
|
|
|
|
|
|
@app.post("/api/license/import", dependencies=[Depends(require_admin_auth)])
|
|
async def import_signed_license(req: Request):
|
|
"""Imports and activates a signed CA certificate or .lic file."""
|
|
try:
|
|
body = await req.json()
|
|
except Exception:
|
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
|
|
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", dependencies=[Depends(require_admin_auth)])
|
|
@app.post("/api/license/sync", dependencies=[Depends(require_admin_auth)])
|
|
async def trigger_license_renewal(request: Request):
|
|
"""Explicitly triggers automated mTLS sync and renewal with logar.eibl.tech."""
|
|
requested_seats = None
|
|
seat_increment = 0
|
|
try:
|
|
body = await request.json()
|
|
if isinstance(body, dict):
|
|
requested_seats = body.get("requested_seats")
|
|
seat_increment = int(body.get("seat_increment", 0))
|
|
except Exception:
|
|
pass
|
|
|
|
config = SERVER_STATE.get("config", {})
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
|
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
|
|
|
active_seats = 0
|
|
try:
|
|
conn_cnt = sqlite3.connect(db_path)
|
|
c_cnt = conn_cnt.cursor()
|
|
c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
|
|
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
|
|
esc_cid = html.escape(str(cid))
|
|
esc_host = html.escape(str(host))
|
|
esc_ost = html.escape(str(ost))
|
|
esc_fp = html.escape(str(fp))
|
|
esc_st = html.escape(str(st))
|
|
badge_cls = "badge-active" if st == "active" else "badge-revoked"
|
|
rel_time = format_relative_time(last)
|
|
last_display = f"<div><strong>{html.escape(str(last[:19]))}</strong></div><div style='font-size:11px; color:#94a3b8;'>{html.escape(rel_time)}</div>"
|
|
|
|
btn_action = (
|
|
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>'
|
|
if st == "active" else
|
|
f'<button class="btn btn-accent" style="padding:4px 8px; font-size:11px;" onclick="approveClient(\'{esc_cid}\')">Approve</button>'
|
|
)
|
|
btn_del = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteClient(\'{esc_cid}\')" title="Remove client and free seat">Remove</button>'
|
|
|
|
client_rows.append(f"""
|
|
<tr>
|
|
<td><span class="badge {badge_cls}">{esc_st.upper()}</span></td>
|
|
<td><strong>{esc_host}</strong></td>
|
|
<td><code>{esc_cid}</code></td>
|
|
<td><span class="badge badge-os">{esc_ost.upper()}</span></td>
|
|
<td><code title="{esc_fp}" style="cursor:pointer;" onclick="copyText('{esc_fp}', 'Cert Fingerprint')">{esc_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
|
|
esc_srv = html.escape(str(srv))
|
|
esc_sev = html.escape(str(sev))
|
|
esc_msg = html.escape(str(msg))
|
|
esc_ls = html.escape(str(ls[:19]))
|
|
sev_cls = "badge-error" if sev in ("ERROR", "CRITICAL", "FATAL") else "badge-warning"
|
|
short_msg = msg[:80] + "..." if len(msg) > 80 else msg
|
|
esc_short_msg = html.escape(str(short_msg))
|
|
anomaly_rows.append(f"""
|
|
<tr>
|
|
<td><span class="badge {sev_cls}">{esc_sev}</span></td>
|
|
<td><strong>{esc_srv}</strong></td>
|
|
<td><span class="badge badge-active">{int(rc)} runs</span></td>
|
|
<td title="{esc_msg}">{esc_short_msg}</td>
|
|
<td>{esc_ls}</td>
|
|
</tr>
|
|
""")
|
|
anomalies_html = "".join(anomaly_rows) if anomaly_rows else '<tr><td colspan="5" class="empty-state">No verified anomalies currently reported.</td></tr>'
|
|
|
|
# Build hermes tokens rows
|
|
token_rows = []
|
|
for tk in hermes_tokens:
|
|
tok, tname, tcreated, tlast, tstatus = tk
|
|
esc_tok = html.escape(str(tok))
|
|
esc_tname = html.escape(str(tname))
|
|
esc_tstatus = html.escape(str(tstatus))
|
|
esc_tcreated = html.escape(str(tcreated[:19]))
|
|
esc_tlast = html.escape(str(tlast[:19])) if tlast else 'Never'
|
|
tbadge = "badge-active" if tstatus == "active" else "badge-revoked"
|
|
masked = esc_tok[:10] + "••••••••" + esc_tok[-4:]
|
|
tok_action = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="revokeToken(\'{esc_tok}\')">Revoke</button>' if tstatus == "active" else f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="deleteToken(\'{esc_tok}\')">Delete</button>'
|
|
token_rows.append(f"""
|
|
<tr>
|
|
<td><strong>{esc_tname}</strong></td>
|
|
<td><code style="cursor:pointer;" title="Click to copy full token" onclick="copyText('{esc_tok}', 'Hermes API Token')">{masked}</code></td>
|
|
<td><span class="badge {tbadge}">{esc_tstatus.upper()}</span></td>
|
|
<td style="color:#94a3b8; font-size:12px;">{esc_tcreated}</td>
|
|
<td style="color:#94a3b8; font-size:12px;">{esc_tlast}</td>
|
|
<td>
|
|
<div style="display:flex; gap:6px; align-items:center;">
|
|
<button class="btn btn-secondary" style="padding:4px 8px; font-size:11px;" onclick="copyText('{esc_tok}', 'Hermes API Token')">Copy</button>
|
|
{tok_action}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
""")
|
|
tokens_html = "".join(token_rows) if token_rows else '<tr><td colspan="6" class="empty-state">No Hermes API tokens created yet.</td></tr>'
|
|
|
|
# Progress bar color logic
|
|
pct = summary["percentage"]
|
|
bar_color = "#10b981" if pct < 70 else ("#f59e0b" if pct < 90 else "#ef4444")
|
|
badge_status_cls = "badge-active" if summary["status"] == "ACTIVE" else ("badge-warning" if summary["status"] == "SELF-SIGNED" else "badge-revoked")
|
|
|
|
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>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Subordinate CA</span>
|
|
<span class="badge badge-os">X.509 SUB-CA</span>
|
|
</div>
|
|
<div class="kpi-value" style="font-size: 16px; word-break: break-all; color:#c084fc;">{summary['license_id']}</div>
|
|
<div class="kpi-sub">Expires: <strong>{summary['not_valid_after'][:10] if summary['not_valid_after'] != 'N/A' else 'N/A'}</strong> ({summary['days_remaining']} days left)</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Fleet Forwarders</span>
|
|
<span class="badge badge-active">{len(clients)} Total</span>
|
|
</div>
|
|
<div class="kpi-value">{summary['active_seats']} <span style="font-size: 14px; color: var(--text-muted);">Active Nodes</span></div>
|
|
<div class="kpi-sub">{summary['revoked_seats']} revoked nodes blocked at TLS socket</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 1: Fleet Forwarders -->
|
|
<div class="tab-pane active" id="tabPane-fleet">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title" style="font-size: 15px;">Enrolled Forwarders & Seat Management</span>
|
|
<button onclick="openExportProfileModal()" class="btn btn-accent" style="padding:6px 12px; font-size:12px;">+ Export New Client Profile</button>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Host Node</th>
|
|
<th>Client Identifier</th>
|
|
<th>OS</th>
|
|
<th>Cert Fingerprint</th>
|
|
<th>Last Activity Time</th>
|
|
<th>Seat Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{clients_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Hermes AI Integration & Tokens -->
|
|
<div class="tab-pane" id="tabPane-hermes">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div>
|
|
<div class="card-title" style="font-size: 15px;">Hermes AI Bearer Access Tokens</div>
|
|
<div class="subtitle" style="margin-top:4px;">Manage dedicated API tokens for Hermes AI to fetch real-time verified anomalies via <code>GET /api/hermes/report</code></div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 20px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 13px; font-weight: 700; margin-bottom: 10px; color: #60a5fa;">+ Generate Dedicated Hermes Token</div>
|
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
<input type="text" id="newTokenName" class="form-control" style="flex: 1; min-width: 220px;" placeholder="Token Name (e.g. Hermes Production Agent)">
|
|
<button onclick="submitCreateToken()" class="btn btn-accent" style="white-space: nowrap;">Generate Token</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Token Name</th>
|
|
<th>Bearer Token</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Last Used</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tokens_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="margin-top: 24px; padding: 16px; background: #0f172a; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 13px; font-weight: 700; color: #34d399; margin-bottom: 8px;">🚀 Hermes AI Integration Quickstart</div>
|
|
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 10px;">Configure Hermes to query the verified log telemetry stream with Bearer authorization:</div>
|
|
<pre style="background: #020617; padding: 12px; border-radius: 6px; font-family: 'JetBrains Mono', monospace; font-size: 12px; color: #38bdf8; overflow-x: auto;">curl -H "Authorization: Bearer <HERMES_TOKEN>" http://{detected_host}:{config.get('hermes_port', 8443)}/api/hermes/report</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: Verified Telemetry -->
|
|
<div class="tab-pane" id="tabPane-anomalies">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title" style="font-size: 15px;">Verified Anomaly Stream (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">Master License Authority</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code>{summary['issuer_url']}</code></div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Issued Seat Quota</div>
|
|
<div style="font-weight: 700; color: #34d399; margin-top: 2px;">{summary['max_seats']} Seats</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Certificate Validity</div>
|
|
<div style="font-weight: 700; margin-top: 2px;">{summary['not_valid_after'][:19] if summary['not_valid_after'] != 'N/A' else 'N/A'}</div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">Server Hub Fingerprint</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code style="cursor:pointer;" onclick="copyText('{summary['server_fingerprint']}', 'Server Hub Fingerprint')">{summary['server_fingerprint']}</code></div>
|
|
</div>
|
|
<div>
|
|
<div class="kpi-sub">CA Certificate Fingerprint</div>
|
|
<div style="font-weight: 700; margin-top: 2px; word-break: break-all;"><code style="cursor:pointer;" onclick="copyText('{summary['fingerprint']}', 'CA Certificate Fingerprint')">{summary['fingerprint']}</code></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export Client Profile Modal -->
|
|
<div id="exportProfileModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-title">📥 Export Edge Client Profile</div>
|
|
<p style="font-size: 13px; color: var(--text-muted); margin-bottom: 16px;">
|
|
Download a pre-configured <code>client_config.json</code> profile bundle for zero-touch Windows & Linux forwarder deployment.
|
|
</p>
|
|
<div class="form-group">
|
|
<label class="form-label">Server Target Host / IP</label>
|
|
<input type="text" id="exportHost" class="form-control" value="{detected_host}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">mTLS Ingestion Port</label>
|
|
<input type="number" id="exportPort" class="form-control" value="{config.get('tcp_port', 9443)}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Pre-allocated Client ID (Optional)</label>
|
|
<input type="text" id="exportClientId" class="form-control" placeholder="e.g. worker-01.corp.internal">
|
|
</div>
|
|
|
|
<div style="background: #0f172a; padding: 12px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 16px;">
|
|
<div style="font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-muted); margin-bottom: 6px;">Edge Client Quick Deploy Command</div>
|
|
<pre id="quickDeployCmd" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #38bdf8; word-break: break-all; white-space: pre-wrap;">sudo ./compilation/install_linux_client.sh "http://{detected_host}:{config.get('hermes_port', 8443)}" "{config.get('enrollment_secret')}"</pre>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('exportProfileModal')" class="btn btn-outline">Cancel</button>
|
|
<button onclick="copyProfileJson()" class="btn btn-secondary">Copy JSON</button>
|
|
<button onclick="downloadClientProfile()" class="btn btn-accent">Download client_config.json</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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)
|
|
config["db_path"] = init_db(
|
|
config["db_path"],
|
|
enrollment_secret=config.get("enrollment_secret"),
|
|
max_seats=config.get("max_seats", 10)
|
|
)
|
|
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
|
|
# Handle standalone CLI license export
|
|
if args.export_lic:
|
|
lic_dict, lic_json = export_license_request_lic(config)
|
|
with open(args.export_lic, "w", encoding="utf-8") as f:
|
|
f.write(lic_json)
|
|
print(f"[+] License request file successfully exported to: {os.path.abspath(args.export_lic)}")
|
|
sys.exit(0)
|
|
|
|
# Handle standalone CLI CA / .lic import
|
|
lic_file = args.import_lic or args.import_ca
|
|
if lic_file:
|
|
try:
|
|
res = enrollment.import_license_package(
|
|
lic_file,
|
|
cert_dir=cert_dir,
|
|
server_fingerprint=config.get("server_fingerprint", ""),
|
|
hostname=config.get("server_name", "")
|
|
)
|
|
except Exception as e:
|
|
print(f"[-] Invalid license file {lic_file}: {e}")
|
|
sys.exit(1)
|
|
|
|
info = res.get("license_info", {})
|
|
new_seats = res.get("seats") or info.get("seats") or config.get("max_seats", 10)
|
|
config["max_seats"] = new_seats
|
|
conn = sqlite3.connect(config["db_path"])
|
|
conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, res.get("license_id") or info.get("license_id")))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Regenerate server certificate signed by new CA
|
|
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir,
|
|
force_renew=True
|
|
)
|
|
|
|
print("[+] Successfully imported and activated signed license!")
|
|
print(f" - Granted Seats: {new_seats}")
|
|
print(f" - License ID: {res.get('license_id') or info.get('license_id')}")
|
|
print(f" - Expires: {info.get('not_valid_after') or res.get('expires_at') or 'N/A'}")
|
|
sys.exit(0)
|
|
|
|
# Handle standalone CLI license sync / update against master server
|
|
if args.sync_license:
|
|
lic_server = config.get("license_server_url", "wss://logar.eibl.tech/ws/license")
|
|
print(f"[*] Connecting to master authority at {lic_server} via WebSocket to sync license...")
|
|
|
|
ca_info = {}
|
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
if os.path.exists(ca_path):
|
|
with open(ca_path, "r", encoding="utf-8") as f:
|
|
ca_info = enrollment.extract_license_info_from_cert(f.read())
|
|
|
|
ws_url = lic_server if ("ws://" in lic_server or "wss://" in lic_server) else f"{lic_server.replace('https://', 'wss://').replace('http://', 'ws://').rstrip('/')}/ws/license"
|
|
|
|
active_seats = 0
|
|
if os.path.exists(config.get("db_path", "server_logs.db")):
|
|
try:
|
|
conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db"))
|
|
row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'").fetchone()
|
|
if row:
|
|
active_seats = row[0]
|
|
conn_tmp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
success, msg, res = enrollment.enroll_and_sync_license_ws(
|
|
cert_dir=cert_dir,
|
|
license_id=ca_info.get("license_id"),
|
|
license_authority_ws_url=ws_url,
|
|
server_fingerprint=config.get("server_fingerprint", ""),
|
|
hostname=config.get("server_name", ""),
|
|
seats_taken=active_seats
|
|
)
|
|
|
|
if not success or not res:
|
|
# Fallback to mTLS HTTP sync
|
|
print(f"[!] WebSocket sync returned: {msg}. Falling back to mTLS renewal...")
|
|
http_url = lic_server.replace("wss://", "https://").replace("ws://", "http://").replace("/ws/license", "")
|
|
success, msg, new_pem = enrollment.renew_ca_cert_mtls(
|
|
cert_dir=cert_dir,
|
|
license_server_url=http_url,
|
|
seats_taken=active_seats,
|
|
active_seats=active_seats,
|
|
hostname=config.get("server_name", ""),
|
|
server_fingerprint=config.get("server_fingerprint", "")
|
|
)
|
|
if not success or not new_pem:
|
|
print(f"[-] License sync failed: {msg}")
|
|
sys.exit(1)
|
|
info = enrollment.extract_license_info_from_cert(new_pem)
|
|
new_seats = info.get("seats") or config.get("max_seats", 10)
|
|
else:
|
|
info = res.get("license_info", {})
|
|
new_seats = res.get("seats", config.get("max_seats", 10))
|
|
|
|
config["max_seats"] = new_seats
|
|
conn = sqlite3.connect(config["db_path"])
|
|
conn.execute("UPDATE license_config SET max_seats = ?, license_id = ? WHERE id = 1", (new_seats, info.get("license_id")))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
ca_cert, ca_key, _, _ = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
|
enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir,
|
|
force_renew=True
|
|
)
|
|
print("[+] License successfully synchronized with master server!")
|
|
print(f" - Active Seat Quota: {new_seats}")
|
|
print(f" - License ID: {info.get('license_id')}")
|
|
print(f" - Expires: {info.get('not_valid_after')}")
|
|
sys.exit(0)
|
|
|
|
# Initialize dynamic PKI (Subordinate CA and Server TLS Certificate)
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
|
|
|
# Auto-extract seats if ca.crt contains custom enterprise OID
|
|
ca_info = enrollment.extract_license_info_from_cert(ca_pem)
|
|
if ca_info.get("seats"):
|
|
config["max_seats"] = ca_info["seats"]
|
|
conn = sqlite3.connect(config["db_path"])
|
|
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (ca_info["seats"],))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Ensure CA CSR and exportable .lic exist for convenience
|
|
csr_path = os.path.join(cert_dir, "ca.csr")
|
|
if not os.path.exists(csr_path):
|
|
enrollment.generate_ca_csr(cert_dir=cert_dir)
|
|
export_license_request_lic(config)
|
|
|
|
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir
|
|
)
|
|
|
|
# Initialize mTLS SSLContext if enabled
|
|
ssl_ctx = None
|
|
if config.get("tls_enabled", True):
|
|
ssl_ctx = init_mtls_server_context(cert_dir=cert_dir)
|
|
|
|
# Load OpenPGP private key into memory (legacy fallback)
|
|
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
|
SERVER_STATE["config"] = config
|
|
SERVER_STATE["private_key_obj"] = priv_key_obj
|
|
SERVER_STATE["ca_cert"] = ca_cert
|
|
SERVER_STATE["ca_key"] = ca_key
|
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
|
SERVER_STATE["ssl_ctx"] = ssl_ctx
|
|
SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True)
|
|
|
|
if args.create_client_config:
|
|
port = args.server_port or config["tcp_port"]
|
|
create_client_config(
|
|
server_host=args.server_host,
|
|
server_port=port,
|
|
output_path=args.client_out,
|
|
config_path=args.config
|
|
)
|
|
sys.exit(0)
|
|
|
|
hermes_host = config.get("hermes_host", "0.0.0.0")
|
|
hermes_port = config.get("hermes_port", 8443)
|
|
display_host = "localhost" if hermes_host in ("0.0.0.0", "127.0.0.1") else hermes_host
|
|
|
|
print("=" * 64)
|
|
print(f" LOGAR Server Hub: {config['server_name']}")
|
|
print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}")
|
|
print(f" License Quota: {config.get('max_seats', 10)} Active Seats ({ca_info.get('license_id') or 'Local Sub-CA'})")
|
|
print(f" Upstream Authority: {config.get('license_server_url', 'https://logar.eibl.tech')}")
|
|
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
|
|
print(f" WebUI & Fleet Dashboard: http://{display_host}:{hermes_port}/webui")
|
|
print(f" Hermes Reporting API: http://{display_host}:{hermes_port}/api/hermes/report")
|
|
print("=" * 64)
|
|
|
|
try:
|
|
asyncio.run(run_server())
|
|
except KeyboardInterrupt:
|
|
print("\n[!] Server shutting down.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |