705 lines
25 KiB
Python
705 lines
25 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import uuid
|
|
import struct
|
|
import socket
|
|
import sqlite3
|
|
import argparse
|
|
import asyncio
|
|
import secrets
|
|
import warnings
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
# 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
|
|
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 = 4
|
|
|
|
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="2.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
|
|
|
|
|
|
def generate_server_keypair(server_name: str):
|
|
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
|
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
|
uid = pgpy.PGPUID.new(server_name)
|
|
key.add_uid(
|
|
uid,
|
|
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
|
hashes=[HashAlgorithm.SHA256],
|
|
ciphers=[SymmetricKeyAlgorithm.AES256],
|
|
compression=[CompressionAlgorithm.Uncompressed]
|
|
)
|
|
private_key_armored = str(key)
|
|
public_key_armored = str(key.pubkey)
|
|
fingerprint = str(key.pubkey.fingerprint)
|
|
return private_key_armored, public_key_armored, fingerprint
|
|
|
|
|
|
def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
|
"""Loads existing server_config.json or creates a new one on first run."""
|
|
if os.path.exists(config_path):
|
|
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
if "enrollment_secret" not in config:
|
|
config["enrollment_secret"] = secrets.token_hex(24)
|
|
if "max_seats" not in config:
|
|
config["max_seats"] = 10
|
|
if "cert_dir" not in config:
|
|
config["cert_dir"] = "certs"
|
|
if "tls_enabled" not in config:
|
|
config["tls_enabled"] = True
|
|
return config
|
|
|
|
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
|
server_name = "LOGAR-Cloud-Hub"
|
|
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
|
auth_token = secrets.token_hex(24)
|
|
enrollment_secret = secrets.token_hex(24)
|
|
|
|
config = {
|
|
"server_name": server_name,
|
|
"tcp_host": "0.0.0.0",
|
|
"tcp_port": 9443,
|
|
"hermes_host": "0.0.0.0",
|
|
"hermes_port": 8443,
|
|
"auth_token": auth_token,
|
|
"enrollment_secret": enrollment_secret,
|
|
"max_seats": 10,
|
|
"cert_dir": "certs",
|
|
"tls_enabled": True,
|
|
"db_path": DEFAULT_DB_FILE,
|
|
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
|
"min_persistence_runs": RUN_THRESHOLD,
|
|
"server_fingerprint": fingerprint,
|
|
"public_key": public_key,
|
|
"private_key": private_key
|
|
}
|
|
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
print(f"[+] Successfully generated new server config and OpenPGP keypair.")
|
|
print(f"[+] Server Encryption Fingerprint: {fingerprint}")
|
|
print(f"[+] Saved to: {os.path.abspath(config_path)}")
|
|
return config
|
|
|
|
|
|
def 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)
|
|
|
|
client_conf = {
|
|
"server_host": server_host,
|
|
"server_port": server_port,
|
|
"hermes_host": hermes_host or server_conf.get("hermes_host", "127.0.0.1"),
|
|
"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 init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10):
|
|
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, and license quota."""
|
|
conn = sqlite3.connect(db_path)
|
|
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
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS ingest_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
site_name TEXT,
|
|
server TEXT,
|
|
timestamp TEXT,
|
|
log_count INTEGER
|
|
)
|
|
""")
|
|
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
|
|
)
|
|
""")
|
|
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
|
|
)
|
|
""")
|
|
if enrollment_secret:
|
|
conn.execute("""
|
|
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
|
|
VALUES (1, ?, ?)
|
|
""", (max_seats, enrollment_secret))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]:
|
|
"""
|
|
Evaluates candidate issues against the 12-hour evaluation window and 4-run rule.
|
|
Zero-state clients send raw candidate entries; this engine handles temporal state.
|
|
"""
|
|
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
|
|
|
|
# Errors are always passed immediately; the 4-run rule only concerns warnings
|
|
is_error = severity in ["ERROR", "CRITICAL", "FATAL"]
|
|
|
|
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):
|
|
# Window elapsed: reset to new cycle
|
|
new_runs = 1
|
|
new_first_seen = now_iso
|
|
new_status = "VERIFIED" if is_error else "TRANSIENT"
|
|
else:
|
|
# Same run guard: only increment count once per distinct run batch
|
|
if last_run_id != run_id:
|
|
new_runs = run_count + 1
|
|
else:
|
|
new_runs = run_count
|
|
new_first_seen = first_seen_str
|
|
# 4-run rule applies to warnings; errors are always passed immediately as VERIFIED
|
|
new_status = "VERIFIED" if (is_error or new_runs >= min_runs) else "TRANSIENT"
|
|
|
|
if new_status == "VERIFIED" and 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" if (is_error or 1 >= min_runs) else "TRANSIENT"
|
|
if 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
|
|
|
|
|
|
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 4-byte big-endian length-prefixed JSON payload,
|
|
and ingests candidate logs into the temporal evaluation engine.
|
|
"""
|
|
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
|
|
c.execute("UPDATE clients SET last_seen = CURRENT_TIMESTAMP WHERE client_id = ?", (client_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
try:
|
|
# Read 4-byte length prefix
|
|
length_bytes = await reader.readexactly(4)
|
|
length = struct.unpack(">I", length_bytes)[0]
|
|
if length <= 0 or length > 10 * 1024 * 1024: # 10MB limit
|
|
raise ValueError(f"Invalid frame size: {length}")
|
|
|
|
payload_bytes = await reader.readexactly(length)
|
|
raw_payload = json.loads(payload_bytes.decode("utf-8"))
|
|
|
|
# Support both direct JSON payload over mTLS and legacy OpenPGP envelope
|
|
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
|
|
|
|
# Attach authenticated client_id if not present
|
|
if client_id and "server" not in log_payload:
|
|
log_payload["server"] = client_id
|
|
|
|
# Ingest and apply 12h window / 4-run rule
|
|
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
|
|
|
|
|
|
@app.post("/api/client/enroll")
|
|
def enroll_client(req: ClientEnrollRequest):
|
|
"""
|
|
Enrolls an edge client by validating the enrollment secret,
|
|
checking license seat limits, issuing a signed client certificate + key,
|
|
and recording the client in the SQLite accounting database.
|
|
"""
|
|
db_path = SERVER_STATE["config"]["db_path"]
|
|
conn = sqlite3.connect(db_path)
|
|
c = conn.cursor()
|
|
|
|
# 1. Validate enrollment secret against license_config
|
|
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
|
|
row = c.fetchone()
|
|
if not row:
|
|
conn.close()
|
|
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
|
|
|
expected_secret, max_seats = row
|
|
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
|
|
conn.close()
|
|
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
|
|
|
ca_cert = SERVER_STATE.get("ca_cert")
|
|
ca_key = SERVER_STATE.get("ca_key")
|
|
ca_cert_pem = SERVER_STATE.get("ca_cert_pem")
|
|
|
|
if not ca_cert or not ca_key:
|
|
conn.close()
|
|
raise HTTPException(status_code=500, detail="Root CA not loaded on server")
|
|
|
|
# 2. Check if client_id already registered
|
|
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
|
|
client_row = c.fetchone()
|
|
if client_row:
|
|
if client_row[0] == "revoked":
|
|
conn.close()
|
|
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
|
|
|
|
# Re-issue for existing active client
|
|
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
|
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
|
c.execute("""
|
|
UPDATE clients
|
|
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = CURRENT_TIMESTAMP
|
|
WHERE client_id = ?
|
|
""", (req.hostname, req.os, fp, 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)
|
|
c.execute("""
|
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status)
|
|
VALUES (?, ?, ?, ?, 'active')
|
|
""", (req.client_id, req.hostname, req.os, fp))
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
|
|
|
|
return {
|
|
"ca_cert": ca_cert_pem,
|
|
"client_cert": client_cert_pem,
|
|
"client_key": client_key_pem
|
|
}
|
|
|
|
|
|
@app.get("/api/clients")
|
|
def list_clients():
|
|
"""Returns all registered clients and license seat usage."""
|
|
db_path = SERVER_STATE["config"]["db_path"]
|
|
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")
|
|
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]
|
|
}
|
|
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.get("/api/hermes/report")
|
|
def get_verified_anomalies_for_hermes():
|
|
"""
|
|
Ingestion endpoint for Hermes agentic workflows.
|
|
Returns only verified anomalies that have satisfied the 4-run persistence rule
|
|
within the active 12-hour evaluation window. Transient blips (< 4 runs) are excluded.
|
|
"""
|
|
db_path = SERVER_STATE["config"]["db_path"]
|
|
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()
|
|
|
|
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"{SERVER_STATE['config']['evaluation_window_hours']}h",
|
|
"verified": True,
|
|
"status": r[10]
|
|
})
|
|
|
|
return report
|
|
|
|
|
|
@app.get("/api/hermes/all")
|
|
def get_all_issues():
|
|
"""Diagnostic endpoint to inspect both transient candidate blips and verified anomalies."""
|
|
db_path = SERVER_STATE["config"]["db_path"]
|
|
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
|
|
]
|
|
|
|
|
|
@app.get("/health")
|
|
def health_check():
|
|
return {
|
|
"status": "healthy",
|
|
"server_name": SERVER_STATE["config"]["server_name"],
|
|
"fingerprint": SERVER_STATE["config"]["server_fingerprint"],
|
|
"tcp_port": SERVER_STATE["config"]["tcp_port"],
|
|
"hermes_port": SERVER_STATE["config"]["hermes_port"],
|
|
"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")
|
|
|
|
await asyncio.gather(
|
|
tcp_server.serve_forever(),
|
|
uv_server.serve()
|
|
)
|
|
|
|
|
|
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")
|
|
args = parser.parse_args()
|
|
|
|
config = load_or_init_config(args.config)
|
|
init_db(
|
|
config["db_path"],
|
|
enrollment_secret=config.get("enrollment_secret"),
|
|
max_seats=config.get("max_seats", 10)
|
|
)
|
|
|
|
# Initialize dynamic PKI (Root CA and Server TLS Certificate)
|
|
cert_dir = config.get("cert_dir", "certs")
|
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
|
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
|
|
ca_cert, ca_key,
|
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
|
cert_dir=cert_dir
|
|
)
|
|
|
|
# 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)
|
|
|
|
print("=" * 60)
|
|
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")
|
|
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
|
|
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | 4-Run Rule: Warnings | Immediate Pass: Errors")
|
|
print("=" * 60)
|
|
|
|
try:
|
|
asyncio.run(run_server())
|
|
except KeyboardInterrupt:
|
|
print("\n[!] Server shutting down.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |