feat(storage): implement in-memory ingestion queue, PostgreSQL enterprise backend, and gated DB WebUI tab
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m35s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m35s
This commit is contained in:
+1
-1
Submodule LOGAR-LICENSING updated: 3368901970...b6557528a6
+771
-13
@@ -155,6 +155,114 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class DbConnectionTestRequest(BaseModel):
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
database: str = "logar"
|
||||||
|
user: str = "postgres"
|
||||||
|
password: str = ""
|
||||||
|
sslmode: str = "prefer"
|
||||||
|
|
||||||
|
|
||||||
|
class DbSwitchBackendRequest(BaseModel):
|
||||||
|
db_type: str = "sqlite" # "sqlite" | "postgres"
|
||||||
|
host: Optional[str] = "localhost"
|
||||||
|
port: Optional[int] = 5432
|
||||||
|
database: Optional[str] = "logar"
|
||||||
|
user: Optional[str] = "postgres"
|
||||||
|
password: Optional[str] = ""
|
||||||
|
sslmode: Optional[str] = "prefer"
|
||||||
|
db_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IngestQueueManager:
|
||||||
|
"""
|
||||||
|
High-throughput in-memory buffering and batch processor for incoming telemetry and log anomalies.
|
||||||
|
Eliminates SQLite disk write contention by decoupling TCP frame ingress from physical DB writes.
|
||||||
|
"""
|
||||||
|
def __init__(self, flush_interval: float = 1.0, batch_size: int = 100, max_queue: int = 50000):
|
||||||
|
self.flush_interval = flush_interval
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.max_queue = max_queue
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._buffer: List[Dict[str, Any]] = []
|
||||||
|
self._total_ingested = 0
|
||||||
|
self._total_flushed = 0
|
||||||
|
self._dropped_count = 0
|
||||||
|
self._last_flush_time = "Never"
|
||||||
|
self._avg_flush_latency_ms = 0.0
|
||||||
|
self._flush_count = 0
|
||||||
|
|
||||||
|
def push(self, payload: Dict[str, Any]) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if len(self._buffer) >= self.max_queue:
|
||||||
|
self._dropped_count += 1
|
||||||
|
return False
|
||||||
|
self._buffer.append(payload)
|
||||||
|
self._total_ingested += len(payload.get("logs", []))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"queue_size": len(self._buffer),
|
||||||
|
"total_ingested": self._total_ingested,
|
||||||
|
"total_flushed": self._total_flushed,
|
||||||
|
"dropped_count": self._dropped_count,
|
||||||
|
"last_flush_time": self._last_flush_time,
|
||||||
|
"avg_flush_latency_ms": round(self._avg_flush_latency_ms, 2),
|
||||||
|
"flush_count": self._flush_count,
|
||||||
|
"flush_interval_seconds": self.flush_interval,
|
||||||
|
"batch_size_threshold": self.batch_size
|
||||||
|
}
|
||||||
|
|
||||||
|
def flush(self, db_path: Optional[str] = None, window_hours: int = 12, min_runs: int = 1) -> Dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
if not self._buffer:
|
||||||
|
return {"status": "noop", "flushed_runs": 0, "processed_logs": 0}
|
||||||
|
items_to_process = list(self._buffer)
|
||||||
|
self._buffer.clear()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||||
|
|
||||||
|
total_processed = 0
|
||||||
|
promoted_total = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for payload in items_to_process:
|
||||||
|
res = process_ingested_logs(
|
||||||
|
payload=payload,
|
||||||
|
db_path=target_db,
|
||||||
|
window_hours=window_hours,
|
||||||
|
min_runs=min_runs
|
||||||
|
)
|
||||||
|
total_processed += res.get("processed", 0)
|
||||||
|
promoted_total += res.get("promoted_verified", 0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error during batch flush to DB: {e}")
|
||||||
|
|
||||||
|
elapsed_ms = (time.time() - start_time) * 1000.0
|
||||||
|
with self._lock:
|
||||||
|
self._total_flushed += total_processed
|
||||||
|
self._flush_count += 1
|
||||||
|
self._avg_flush_latency_ms = (
|
||||||
|
(self._avg_flush_latency_ms * (self._flush_count - 1) + elapsed_ms) / self._flush_count
|
||||||
|
)
|
||||||
|
self._last_flush_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"flushed_runs": len(items_to_process),
|
||||||
|
"processed_logs": total_processed,
|
||||||
|
"promoted_verified": promoted_total,
|
||||||
|
"flush_latency_ms": round(elapsed_ms, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
INGEST_QUEUE = IngestQueueManager()
|
||||||
|
|
||||||
|
|
||||||
# --- Rate Limiting & Brute-Force Mitigation ---
|
# --- Rate Limiting & Brute-Force Mitigation ---
|
||||||
|
|
||||||
_RATE_LIMIT_LOCK = threading.Lock()
|
_RATE_LIMIT_LOCK = threading.Lock()
|
||||||
@@ -914,7 +1022,8 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
|
|||||||
("license_server_url", "TEXT"),
|
("license_server_url", "TEXT"),
|
||||||
("issued_at", "TEXT"),
|
("issued_at", "TEXT"),
|
||||||
("expires_at", "TEXT"),
|
("expires_at", "TEXT"),
|
||||||
("license_status", "TEXT DEFAULT 'unlicensed'")
|
("license_status", "TEXT DEFAULT 'unlicensed'"),
|
||||||
|
("features", "TEXT DEFAULT '[]'")
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}")
|
conn.execute(f"ALTER TABLE license_config ADD COLUMN {col} {col_def}")
|
||||||
@@ -1003,11 +1112,10 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
status = "active" if max_seats > 0 else "unlicensed"
|
status = "active" if max_seats > 0 else "unlicensed"
|
||||||
if enrollment_secret:
|
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status)
|
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status, features)
|
||||||
VALUES (1, ?, ?, ?)
|
VALUES (1, ?, ?, ?, '[]')
|
||||||
""", (max_seats, enrollment_secret, status))
|
""", (max_seats, enrollment_secret or "", status))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1044,6 +1152,306 @@ def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any],
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Checks whether a specific enterprise capability (e.g. 'enterprise_db') is unlocked
|
||||||
|
either via custom X.509 certificate extensions or the persistent license configuration.
|
||||||
|
"""
|
||||||
|
# 1. Check in-memory / active Subordinate CA certificate
|
||||||
|
ca_pem = SERVER_STATE.get("ca_cert_pem")
|
||||||
|
if not ca_pem:
|
||||||
|
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
|
||||||
|
ca_file = os.path.join(cert_dir, "ca.crt")
|
||||||
|
if os.path.exists(ca_file):
|
||||||
|
try:
|
||||||
|
with open(ca_file, "r", encoding="utf-8") as f:
|
||||||
|
ca_pem = f.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if ca_pem:
|
||||||
|
try:
|
||||||
|
info = enrollment.extract_license_info_from_cert(ca_pem)
|
||||||
|
cert_features = info.get("features", [])
|
||||||
|
if feature in cert_features:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Check license_config table in database
|
||||||
|
target_db = db_path or SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
|
||||||
|
if target_db and os.path.exists(target_db):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(target_db, timeout=3.0)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT features FROM license_config WHERE id = 1")
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row and row[0]:
|
||||||
|
feat_raw = row[0]
|
||||||
|
if isinstance(feat_raw, str):
|
||||||
|
if feat_raw.startswith("["):
|
||||||
|
feat_list = json.loads(feat_raw)
|
||||||
|
else:
|
||||||
|
feat_list = [f.strip() for f in feat_raw.split(",") if f.strip()]
|
||||||
|
if feature in feat_list:
|
||||||
|
return True
|
||||||
|
elif isinstance(feat_raw, list) and feature in feat_raw:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_storage_engine_info(db_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Returns metadata about the active storage backend and database telemetry."""
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
db_type = config.get("db_type", "sqlite").lower()
|
||||||
|
target_db = db_path or config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
|
||||||
|
table_stats = {"active_issues": 0, "ingest_runs": 0, "clients": 0, "users": 0}
|
||||||
|
is_unlocked = is_feature_licensed("enterprise_db", db_path=target_db)
|
||||||
|
|
||||||
|
licensed_features = []
|
||||||
|
if target_db and os.path.exists(target_db):
|
||||||
|
try:
|
||||||
|
conn_l = sqlite3.connect(target_db, timeout=3.0)
|
||||||
|
row_l = conn_l.execute("SELECT features FROM license_config WHERE id = 1").fetchone()
|
||||||
|
conn_l.close()
|
||||||
|
if row_l and row_l[0]:
|
||||||
|
feat_raw = row_l[0]
|
||||||
|
if isinstance(feat_raw, str):
|
||||||
|
if feat_raw.startswith("["):
|
||||||
|
licensed_features = json.loads(feat_raw)
|
||||||
|
else:
|
||||||
|
licensed_features = [f.strip() for f in feat_raw.split(",") if f.strip()]
|
||||||
|
elif isinstance(feat_raw, list):
|
||||||
|
licensed_features = list(feat_raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if db_type in ("postgres", "postgresql"):
|
||||||
|
pg_cfg = config.get("postgres", {})
|
||||||
|
host = pg_cfg.get("host", "localhost")
|
||||||
|
port = pg_cfg.get("port", 5432)
|
||||||
|
dbname = pg_cfg.get("database", "logar")
|
||||||
|
user = pg_cfg.get("user", "postgres")
|
||||||
|
return {
|
||||||
|
"engine": "postgres",
|
||||||
|
"engine_type": "postgres",
|
||||||
|
"backend_display": "PostgreSQL Enterprise Pool",
|
||||||
|
"connection_target": f"{user}@{host}:{port}/{dbname}",
|
||||||
|
"status": "Enterprise Connected",
|
||||||
|
"table_stats": table_stats,
|
||||||
|
"is_enterprise": True,
|
||||||
|
"feature_unlocked": is_unlocked,
|
||||||
|
"licensed_features": licensed_features
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
size_bytes = 0
|
||||||
|
if target_db and os.path.exists(target_db):
|
||||||
|
try:
|
||||||
|
size_bytes = os.path.getsize(target_db)
|
||||||
|
conn = sqlite3.connect(target_db, timeout=3.0)
|
||||||
|
for tbl in ["active_issues", "ingest_runs", "clients", "users"]:
|
||||||
|
try:
|
||||||
|
row = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()
|
||||||
|
table_stats[tbl] = row[0] if row else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
size_kb = round(size_bytes / 1024, 2)
|
||||||
|
return {
|
||||||
|
"engine": "sqlite",
|
||||||
|
"engine_type": "sqlite",
|
||||||
|
"backend_display": "SQLite 3 (WAL Mode)",
|
||||||
|
"connection_target": target_db,
|
||||||
|
"db_size_kb": size_kb,
|
||||||
|
"status": "Local WAL Optimized",
|
||||||
|
"table_stats": table_stats,
|
||||||
|
"is_enterprise": False,
|
||||||
|
"feature_unlocked": is_unlocked,
|
||||||
|
"licensed_features": licensed_features
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
|
"""Initializes table schema on a PostgreSQL database instance."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import psycopg as psycopg2
|
||||||
|
except ImportError:
|
||||||
|
return False, "PostgreSQL driver not installed on server (install psycopg2-binary or psycopg)"
|
||||||
|
|
||||||
|
host = pg_config.get("host", "localhost")
|
||||||
|
port = int(pg_config.get("port", 5432))
|
||||||
|
database = pg_config.get("database", "logar")
|
||||||
|
user = pg_config.get("user", "postgres")
|
||||||
|
password = pg_config.get("password", "")
|
||||||
|
sslmode = pg_config.get("sslmode", "prefer")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS active_issues (
|
||||||
|
fingerprint VARCHAR(255) PRIMARY KEY,
|
||||||
|
site_name VARCHAR(255),
|
||||||
|
server VARCHAR(255),
|
||||||
|
signature TEXT,
|
||||||
|
severity VARCHAR(50),
|
||||||
|
message TEXT,
|
||||||
|
os_type VARCHAR(50),
|
||||||
|
first_seen VARCHAR(50),
|
||||||
|
last_seen VARCHAR(50),
|
||||||
|
run_count INTEGER,
|
||||||
|
status VARCHAR(50),
|
||||||
|
last_run_id VARCHAR(100)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ingest_runs (
|
||||||
|
run_id VARCHAR(100) PRIMARY KEY,
|
||||||
|
site_name VARCHAR(255),
|
||||||
|
server VARCHAR(255),
|
||||||
|
timestamp VARCHAR(50),
|
||||||
|
log_count INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS license_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
max_seats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enrollment_secret TEXT NOT NULL,
|
||||||
|
license_id VARCHAR(100),
|
||||||
|
license_server_url VARCHAR(255),
|
||||||
|
issued_at VARCHAR(50),
|
||||||
|
expires_at VARCHAR(50),
|
||||||
|
license_status VARCHAR(50) DEFAULT 'unlicensed',
|
||||||
|
features TEXT DEFAULT '[]'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
client_id VARCHAR(100) PRIMARY KEY,
|
||||||
|
hostname VARCHAR(255) NOT NULL,
|
||||||
|
os_type VARCHAR(50) NOT NULL,
|
||||||
|
cert_fingerprint VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'active',
|
||||||
|
first_seen VARCHAR(50),
|
||||||
|
last_seen VARCHAR(50)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username VARCHAR(100) PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'admin',
|
||||||
|
created_at VARCHAR(50) NOT NULL,
|
||||||
|
totp_secret TEXT,
|
||||||
|
totp_enabled INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS hermes_tokens (
|
||||||
|
token VARCHAR(255) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at VARCHAR(50) NOT NULL,
|
||||||
|
last_used VARCHAR(50),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'active'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
ip VARCHAR(100) NOT NULL,
|
||||||
|
scope VARCHAR(50) NOT NULL,
|
||||||
|
attempt_time DOUBLE PRECISION NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS server_meta (
|
||||||
|
key VARCHAR(100) PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return True, "PostgreSQL schema successfully initialized"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"PostgreSQL connection/init failed: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_sqlite_to_postgres(sqlite_path: str, pg_config: Dict[str, Any]) -> Tuple[bool, str, Dict[str, int]]:
|
||||||
|
"""Transfers all tables and records from SQLite to PostgreSQL with data validation."""
|
||||||
|
if not os.path.exists(sqlite_path):
|
||||||
|
return False, f"SQLite database not found at {sqlite_path}", {}
|
||||||
|
|
||||||
|
ok, init_msg = init_postgres_db(pg_config)
|
||||||
|
if not ok:
|
||||||
|
return False, init_msg, {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
except ImportError:
|
||||||
|
import psycopg as psycopg2
|
||||||
|
|
||||||
|
host = pg_config.get("host", "localhost")
|
||||||
|
port = int(pg_config.get("port", 5432))
|
||||||
|
database = pg_config.get("database", "logar")
|
||||||
|
user = pg_config.get("user", "postgres")
|
||||||
|
password = pg_config.get("password", "")
|
||||||
|
sslmode = pg_config.get("sslmode", "prefer")
|
||||||
|
|
||||||
|
pg_conn = psycopg2.connect(
|
||||||
|
host=host, port=port, dbname=database, user=user, password=password, sslmode=sslmode, connect_timeout=5
|
||||||
|
)
|
||||||
|
sqlite_conn = sqlite3.connect(sqlite_path)
|
||||||
|
sqlite_conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
migrated_counts = {}
|
||||||
|
tables = ["active_issues", "ingest_runs", "license_config", "clients", "users", "hermes_tokens", "rate_limits", "server_meta"]
|
||||||
|
|
||||||
|
with pg_conn.cursor() as pg_cur:
|
||||||
|
for tbl in tables:
|
||||||
|
try:
|
||||||
|
s_cur = sqlite_conn.cursor()
|
||||||
|
s_cur.execute(f"SELECT * FROM {tbl}")
|
||||||
|
rows = s_cur.fetchall()
|
||||||
|
if rows:
|
||||||
|
col_names = [col[0] for col in s_cur.description]
|
||||||
|
cols_joined = ", ".join(col_names)
|
||||||
|
placeholders = ", ".join(["%s"] * len(col_names))
|
||||||
|
insert_query = f"INSERT INTO {tbl} ({cols_joined}) VALUES ({placeholders}) ON CONFLICT DO NOTHING;"
|
||||||
|
data = [[r[c] for c in col_names] for r in rows]
|
||||||
|
pg_cur.executemany(insert_query, data)
|
||||||
|
migrated_counts[tbl] = len(rows)
|
||||||
|
else:
|
||||||
|
migrated_counts[tbl] = 0
|
||||||
|
except Exception as tbl_err:
|
||||||
|
print(f"[!] Warning migrating table {tbl}: {tbl_err}")
|
||||||
|
migrated_counts[tbl] = 0
|
||||||
|
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
sqlite_conn.close()
|
||||||
|
pg_conn.close()
|
||||||
|
return True, "Migration completed successfully", migrated_counts
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Migration failed: {e}", {}
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_buffer_worker_loop():
|
||||||
|
"""Periodically flushes the in-memory ingestion buffer to persistent database storage."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(INGEST_QUEUE.flush_interval)
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
window_hours = config.get("evaluation_window_hours", 12)
|
||||||
|
min_runs = config.get("min_persistence_runs", 1)
|
||||||
|
INGEST_QUEUE.flush(db_path=db_path, window_hours=window_hours, min_runs=min_runs)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Ingestion buffer worker error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]:
|
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.
|
Evaluates ingested log anomalies and updates temporal run tracking.
|
||||||
@@ -1471,12 +1879,20 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
if client_id and "server" not in log_payload:
|
if client_id and "server" not in log_payload:
|
||||||
log_payload["server"] = client_id
|
log_payload["server"] = client_id
|
||||||
|
|
||||||
res = process_ingested_logs(
|
INGEST_QUEUE.push(log_payload)
|
||||||
log_payload,
|
if len(INGEST_QUEUE._buffer) >= INGEST_QUEUE.batch_size:
|
||||||
|
res = INGEST_QUEUE.flush(
|
||||||
db_path=SERVER_STATE["config"]["db_path"],
|
db_path=SERVER_STATE["config"]["db_path"],
|
||||||
window_hours=SERVER_STATE["config"]["evaluation_window_hours"],
|
window_hours=SERVER_STATE["config"]["evaluation_window_hours"],
|
||||||
min_runs=SERVER_STATE["config"]["min_persistence_runs"]
|
min_runs=SERVER_STATE["config"]["min_persistence_runs"]
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
res = {
|
||||||
|
"status": "success",
|
||||||
|
"buffered": True,
|
||||||
|
"processed": len(log_payload.get("logs", [])),
|
||||||
|
"queue_size": len(INGEST_QUEUE._buffer)
|
||||||
|
}
|
||||||
|
|
||||||
resp_bytes = json.dumps(res).encode("utf-8")
|
resp_bytes = json.dumps(res).encode("utf-8")
|
||||||
writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes)
|
writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes)
|
||||||
@@ -2356,12 +2772,112 @@ async def trigger_license_renewal(request: Request):
|
|||||||
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
||||||
print(f"[+] In-flight mTLS SSLContext successfully reloaded after renewal/sync (Seats: {info.get('seats')}).")
|
print(f"[+] In-flight mTLS SSLContext successfully reloaded after renewal/sync (Seats: {info.get('seats')}).")
|
||||||
|
|
||||||
|
# --- Database Engine & Ingestion Buffer Endpoints ---
|
||||||
|
|
||||||
|
@app.get("/api/db/stats", dependencies=[Depends(require_admin_auth)])
|
||||||
|
def api_db_stats():
|
||||||
|
"""Returns real-time buffer throughput, queue stats, and storage engine metrics."""
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": msg,
|
"queue": INGEST_QUEUE.get_stats(),
|
||||||
"seats": info.get("seats"),
|
"storage": get_storage_engine_info(),
|
||||||
"expires_at": info.get("not_valid_after"),
|
"enterprise_db_licensed": is_feature_licensed("enterprise_db")
|
||||||
"days_remaining": info.get("days_remaining")
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/db/flush-buffer", dependencies=[Depends(require_admin_auth)])
|
||||||
|
def api_db_flush_buffer():
|
||||||
|
"""Manually forces immediate drain and write of all buffered anomalies to the database."""
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
window_hours = config.get("evaluation_window_hours", 12)
|
||||||
|
min_runs = config.get("min_persistence_runs", 1)
|
||||||
|
res = INGEST_QUEUE.flush(db_path=db_path, window_hours=window_hours, min_runs=min_runs)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Successfully flushed {res.get('processed_logs', 0)} events across {res.get('flushed_runs', 0)} batch runs ({res.get('flush_latency_ms', 0)}ms).",
|
||||||
|
"flush_details": res,
|
||||||
|
"queue": INGEST_QUEUE.get_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/db/test-connection", dependencies=[Depends(require_admin_auth)])
|
||||||
|
def api_db_test_connection(req: DbConnectionTestRequest):
|
||||||
|
"""Tests connection to external PostgreSQL database. Requires enterprise_db license feature."""
|
||||||
|
if not is_feature_licensed("enterprise_db"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
||||||
|
)
|
||||||
|
ok, msg = init_postgres_db(req.model_dump())
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=msg)
|
||||||
|
return {"status": "success", "message": "Successfully connected to PostgreSQL and verified schema."}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/db/switch-backend", dependencies=[Depends(require_admin_auth)])
|
||||||
|
def api_db_switch_backend(req: DbSwitchBackendRequest):
|
||||||
|
"""Switches active database backend between SQLite and PostgreSQL. Requires enterprise_db license feature."""
|
||||||
|
if not is_feature_licensed("enterprise_db"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
||||||
|
)
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
if req.db_type == "postgres":
|
||||||
|
pg_cfg = {
|
||||||
|
"host": req.host or "localhost",
|
||||||
|
"port": req.port or 5432,
|
||||||
|
"database": req.database or "logar",
|
||||||
|
"user": req.user or "postgres",
|
||||||
|
"password": req.password or "",
|
||||||
|
"sslmode": req.sslmode or "prefer"
|
||||||
|
}
|
||||||
|
ok, msg = init_postgres_db(pg_cfg)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Cannot switch backend: {msg}")
|
||||||
|
config["db_type"] = "postgres"
|
||||||
|
config["postgres"] = pg_cfg
|
||||||
|
else:
|
||||||
|
config["db_type"] = "sqlite"
|
||||||
|
if req.db_path:
|
||||||
|
config["db_path"] = req.db_path
|
||||||
|
|
||||||
|
cfg_path = config.get("config_file_path", CONFIG_FILE_NAME)
|
||||||
|
if os.path.exists(cfg_path):
|
||||||
|
try:
|
||||||
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Storage backend successfully switched to {config.get('db_type', 'sqlite')}.",
|
||||||
|
"storage": get_storage_engine_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/db/migrate-to-postgres", dependencies=[Depends(require_admin_auth)])
|
||||||
|
def api_db_migrate_to_postgres(req: DbConnectionTestRequest):
|
||||||
|
"""Migrates all telemetry, runs, clients, users, and license configs from SQLite to PostgreSQL."""
|
||||||
|
if not is_feature_licensed("enterprise_db"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
||||||
|
)
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
sqlite_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
|
||||||
|
INGEST_QUEUE.flush(db_path=sqlite_path)
|
||||||
|
|
||||||
|
ok, msg, counts = migrate_sqlite_to_postgres(sqlite_path, req.model_dump())
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=400, detail=msg)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "SQLite database records successfully migrated to PostgreSQL enterprise storage.",
|
||||||
|
"counts": counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2383,6 +2899,12 @@ def serve_webui(request: Request = None):
|
|||||||
config = SERVER_STATE.get("config", {})
|
config = SERVER_STATE.get("config", {})
|
||||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
|
||||||
|
is_db_unlocked = is_feature_licensed("enterprise_db", db_path)
|
||||||
|
queue_stats = INGEST_QUEUE.get_stats()
|
||||||
|
storage_info = get_storage_engine_info()
|
||||||
|
pg_config = config.get("postgres", {})
|
||||||
|
db_tab_btn_badge = '<span class="badge badge-active" style="margin-left:4px; font-size:10px;">🟢 UNLOCKED</span>' if is_db_unlocked else '<span class="badge" style="background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.3); margin-left:4px; font-size:10px;">🔒 ENTERPRISE</span>'
|
||||||
|
|
||||||
|
|
||||||
# Fetch clients
|
# Fetch clients
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
@@ -2522,6 +3044,134 @@ def serve_webui(request: Request = None):
|
|||||||
if detected_host in ("0.0.0.0", "localhost", ""):
|
if detected_host in ("0.0.0.0", "localhost", ""):
|
||||||
detected_host = socket.gethostname()
|
detected_host = socket.gethostname()
|
||||||
|
|
||||||
|
if is_db_unlocked:
|
||||||
|
db_tab_pane_html = f"""
|
||||||
|
<!-- Tab 5: Database Engine -->
|
||||||
|
<div class="tab-pane" id="tabPane-db">
|
||||||
|
<!-- Ingestion Buffer Live Stats -->
|
||||||
|
<div class="card" style="border-color: rgba(0, 229, 255, 0.3);">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<span class="card-title" style="color: #00E5FF;">⚡ High-Throughput Ingestion Buffer</span>
|
||||||
|
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">In-memory log buffer decoupling TCP socket ingestion from disk transactions</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="flushIngestBuffer()" class="btn btn-accent" style="padding: 6px 14px; font-size: 12px;">⚡ Force Flush Buffer Now</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-top: 14px;">
|
||||||
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<div class="kpi-sub">Buffered Events in Memory</div>
|
||||||
|
<div class="kpi-value" id="liveQueueSize" style="color: #38bdf8; margin-top: 4px;">{queue_stats['queue_size']} <span style="font-size: 13px; color: var(--text-muted);">events</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<div class="kpi-sub">Total Events Ingested</div>
|
||||||
|
<div class="kpi-value" style="color: #34d399; margin-top: 4px;">{queue_stats['total_ingested']}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<div class="kpi-sub">Total Flushed to Storage</div>
|
||||||
|
<div class="kpi-value" style="color: #c084fc; margin-top: 4px;">{queue_stats['total_flushed']}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||||
|
<div class="kpi-sub">Avg Batch Flush Latency</div>
|
||||||
|
<div class="kpi-value" style="color: #fbbf24; margin-top: 4px;">{queue_stats['avg_flush_latency_ms']} ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Storage Engine Status -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Active Database Storage Backend</span>
|
||||||
|
<span class="badge badge-active">{storage_info['engine']}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 20px;">
|
||||||
|
<div>
|
||||||
|
<div class="kpi-sub">Engine Backend Type</div>
|
||||||
|
<div style="font-weight: 700; margin-top: 2px;">{storage_info['engine']}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="kpi-sub">Active Target / Path</div>
|
||||||
|
<div style="font-weight: 700; margin-top: 2px; font-family: 'JetBrains Mono', monospace; font-size: 12px; word-break: break-all;">{storage_info['connection_target']}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="kpi-sub">Status</div>
|
||||||
|
<div style="font-weight: 700; color: #34d399; margin-top: 2px;">{storage_info['status']}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="kpi-sub">Telemetry Records</div>
|
||||||
|
<div style="font-weight: 700; margin-top: 2px;">{storage_info['table_stats']['active_issues']} Issues / {storage_info['table_stats']['ingest_runs']} Runs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PostgreSQL Configuration Panel -->
|
||||||
|
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-top: 10px;">
|
||||||
|
<div style="font-size: 14px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span>🐘</span> Enterprise PostgreSQL Connection Configurator
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px;">
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">Host / IP</label>
|
||||||
|
<input type="text" id="pgHost" class="form-control" value="{pg_config.get('host', 'localhost')}" placeholder="localhost">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="number" id="pgPort" class="form-control" value="{pg_config.get('port', 5432)}" placeholder="5432">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">Database Name</label>
|
||||||
|
<input type="text" id="pgDatabase" class="form-control" value="{pg_config.get('database', 'logar')}" placeholder="logar">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" id="pgUser" class="form-control" value="{pg_config.get('user', 'postgres')}" placeholder="postgres">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" id="pgPassword" class="form-control" value="{pg_config.get('password', '')}" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="form-label">SSL Mode</label>
|
||||||
|
<select id="pgSslMode" class="form-control">
|
||||||
|
<option value="prefer">prefer</option>
|
||||||
|
<option value="require">require</option>
|
||||||
|
<option value="verify-ca">verify-ca</option>
|
||||||
|
<option value="verify-full">verify-full</option>
|
||||||
|
<option value="disable">disable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button onclick="testPgConnection()" class="btn btn-secondary">🔍 Test Connection</button>
|
||||||
|
<button onclick="switchStorageEngine('postgres')" class="btn btn-primary">💾 Save & Switch to PostgreSQL</button>
|
||||||
|
<button onclick="migrateToPostgres()" class="btn btn-accent">🚀 Migrate SQLite Data to PostgreSQL</button>
|
||||||
|
<button onclick="switchStorageEngine('sqlite')" class="btn btn-outline">↩️ Switch back to SQLite (WAL)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
db_tab_pane_html = f"""
|
||||||
|
<!-- Tab 5: Database Engine (Locked State) -->
|
||||||
|
<div class="tab-pane" id="tabPane-db">
|
||||||
|
<div class="card" style="text-align: center; padding: 48px 24px; border-color: rgba(239, 68, 68, 0.3);">
|
||||||
|
<div style="font-size: 52px; margin-bottom: 16px;">🔒</div>
|
||||||
|
<h2 style="font-size: 22px; font-weight: 800; margin-bottom: 8px;">Enterprise Database & High-Throughput Engine is Locked</h2>
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<span class="badge" style="background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); padding: 6px 14px; font-size: 12px;">
|
||||||
|
REQUIRES 'enterprise_db' LICENSE FEATURE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 14px; color: var(--text-muted); max-width: 640px; margin: 0 auto 24px;">
|
||||||
|
Direct PostgreSQL storage backend clustering, automatic schema synchronization, and customizable high-throughput batch-flush controls are gated behind the <strong>enterprise_db</strong> capability.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; justify-content: center; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button onclick="switchTab('pki')" class="btn btn-primary">📜 Go to Sub-CA & Licensing</button>
|
||||||
|
<button onclick="openImportLicModal()" class="btn btn-accent">📥 Import Enterprise .lic Package</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
html_content = f"""<!DOCTYPE html>
|
html_content = f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -2752,6 +3402,7 @@ def serve_webui(request: Request = None):
|
|||||||
<button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens ({len(hermes_tokens)})</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-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry ({len(anomalies)})</button>
|
||||||
<button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button>
|
<button class="tab-btn" id="tabBtn-pki" onclick="switchTab('pki')">📜 Sub-CA & Licensing</button>
|
||||||
|
<button class="tab-btn" id="tabBtn-db" onclick="switchTab('db')">💾 DB Engine {db_tab_btn_badge}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI Summary Cards -->
|
<!-- KPI Summary Cards -->
|
||||||
@@ -2939,6 +3590,8 @@ def serve_webui(request: Request = None):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{db_tab_pane_html}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Client Profile Modal -->
|
<!-- Export Client Profile Modal -->
|
||||||
@@ -3321,6 +3974,103 @@ def serve_webui(request: Request = None):
|
|||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
// --- Storage Engine & Buffer Functions ---
|
||||||
|
async function flushIngestBuffer() {{
|
||||||
|
try {{
|
||||||
|
showAlert('Flushing in-memory ingestion buffer...');
|
||||||
|
const res = await fetch('/api/db/flush-buffer', {{ method: 'POST' }});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {{
|
||||||
|
showAlert('Buffer flushed: ' + (data.message || 'Flushed'));
|
||||||
|
setTimeout(() => location.reload(), 800);
|
||||||
|
}} else {{
|
||||||
|
alert('Flush failed: ' + (data.detail || 'Error'));
|
||||||
|
}}
|
||||||
|
}} catch (err) {{
|
||||||
|
alert('Buffer flush failed: ' + err.message);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function getPgConfigFromForm() {{
|
||||||
|
return {{
|
||||||
|
host: document.getElementById('pgHost') ? document.getElementById('pgHost').value.trim() : 'localhost',
|
||||||
|
port: document.getElementById('pgPort') ? parseInt(document.getElementById('pgPort').value.trim()) || 5432 : 5432,
|
||||||
|
database: document.getElementById('pgDatabase') ? document.getElementById('pgDatabase').value.trim() : 'logar',
|
||||||
|
user: document.getElementById('pgUser') ? document.getElementById('pgUser').value.trim() : 'postgres',
|
||||||
|
password: document.getElementById('pgPassword') ? document.getElementById('pgPassword').value : '',
|
||||||
|
sslmode: document.getElementById('pgSslMode') ? document.getElementById('pgSslMode').value : 'prefer'
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function testPgConnection() {{
|
||||||
|
try {{
|
||||||
|
showAlert('Testing connection to PostgreSQL...');
|
||||||
|
const cfg = getPgConfigFromForm();
|
||||||
|
const res = await fetch('/api/db/test-connection', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success) {{
|
||||||
|
showAlert('✅ PostgreSQL connection successful: ' + data.message);
|
||||||
|
}} else {{
|
||||||
|
alert('❌ PostgreSQL connection test failed: ' + (data.message || data.detail || 'Unknown error'));
|
||||||
|
}}
|
||||||
|
}} catch (err) {{
|
||||||
|
alert('Test connection request failed: ' + err.message);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function switchStorageEngine(engineType) {{
|
||||||
|
try {{
|
||||||
|
const cfg = engineType === 'postgres' ? getPgConfigFromForm() : null;
|
||||||
|
const confirmMsg = engineType === 'postgres'
|
||||||
|
? 'Switch active storage engine to PostgreSQL? Make sure you have migrated your SQLite data if needed.'
|
||||||
|
: 'Switch back to SQLite database storage?';
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
showAlert('Switching database storage engine to ' + engineType + '...');
|
||||||
|
const res = await fetch('/api/db/switch-backend', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify({{ engine: engineType, pg_config: cfg }})
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success) {{
|
||||||
|
showAlert('Storage engine switched to ' + engineType + '!');
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
}} else {{
|
||||||
|
alert('Failed to switch engine: ' + (data.message || data.detail || 'Unknown error'));
|
||||||
|
}}
|
||||||
|
}} catch (err) {{
|
||||||
|
alert('Switch engine request failed: ' + err.message);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function migrateToPostgres() {{
|
||||||
|
try {{
|
||||||
|
const cfg = getPgConfigFromForm();
|
||||||
|
if (!confirm('Migrate all data from SQLite database to PostgreSQL? This will transfer existing telemetry, seat records, and user credentials.')) return;
|
||||||
|
|
||||||
|
showAlert('Migrating SQLite data to PostgreSQL...');
|
||||||
|
const res = await fetch('/api/db/migrate-to-postgres', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/json' }},
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
}});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success) {{
|
||||||
|
showAlert('Migration completed! ' + JSON.stringify(data.migrated_counts));
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
}} else {{
|
||||||
|
alert('Migration failed: ' + (data.message || data.detail || 'Error'));
|
||||||
|
}}
|
||||||
|
}} catch (err) {{
|
||||||
|
alert('Migration request failed: ' + err.message);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
// --- 2FA Management & QRCode Logic ---
|
// --- 2FA Management & QRCode Logic ---
|
||||||
let current2FASecret = '';
|
let current2FASecret = '';
|
||||||
|
|
||||||
@@ -3477,22 +4227,30 @@ async def run_server():
|
|||||||
|
|
||||||
watchdog_task = asyncio.create_task(cert_validity_watchdog())
|
watchdog_task = asyncio.create_task(cert_validity_watchdog())
|
||||||
collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30))
|
collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30))
|
||||||
|
buffer_task = asyncio.create_task(ingest_buffer_worker_loop())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
tcp_server.serve_forever(),
|
tcp_server.serve_forever(),
|
||||||
uv_server.serve(),
|
uv_server.serve(),
|
||||||
watchdog_task,
|
watchdog_task,
|
||||||
collector_task
|
collector_task,
|
||||||
|
buffer_task
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
watchdog_task.cancel()
|
watchdog_task.cancel()
|
||||||
collector_task.cancel()
|
collector_task.cancel()
|
||||||
for t in [watchdog_task, collector_task]:
|
buffer_task.cancel()
|
||||||
|
for t in [watchdog_task, collector_task, buffer_task]:
|
||||||
try:
|
try:
|
||||||
await t
|
await t
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
db_p = config.get("db_path", DEFAULT_DB_FILE)
|
||||||
|
INGEST_QUEUE.flush(db_path=db_p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
|||||||
OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1")
|
OID_LOGAR_SEATS = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.1")
|
||||||
OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2")
|
OID_LOGAR_LICENSE_ID = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.2")
|
||||||
OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3")
|
OID_LOGAR_ISSUER_URL = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.3")
|
||||||
|
OID_LOGAR_FEATURES = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.4")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -448,6 +449,19 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
|||||||
except (x509.ExtensionNotFound, Exception):
|
except (x509.ExtensionNotFound, Exception):
|
||||||
issuer_url = None
|
issuer_url = None
|
||||||
|
|
||||||
|
features = []
|
||||||
|
try:
|
||||||
|
ext_feat = cert.extensions.get_extension_for_oid(OID_LOGAR_FEATURES)
|
||||||
|
if ext_feat and ext_feat.value:
|
||||||
|
raw_val = ext_feat.value.value if hasattr(ext_feat.value, "value") else str(ext_feat.value)
|
||||||
|
feat_str = raw_val.decode("utf-8").strip() if isinstance(raw_val, bytes) else str(raw_val).strip()
|
||||||
|
if feat_str.startswith("["):
|
||||||
|
features = json.loads(feat_str)
|
||||||
|
else:
|
||||||
|
features = [f.strip() for f in feat_str.split(",") if f.strip()]
|
||||||
|
except Exception:
|
||||||
|
features = []
|
||||||
|
|
||||||
not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc)
|
not_before = getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before.replace(tzinfo=datetime.timezone.utc)
|
||||||
not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
|
not_after = getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
@@ -482,6 +496,7 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
|||||||
"seats": seats,
|
"seats": seats,
|
||||||
"license_id": license_id,
|
"license_id": license_id,
|
||||||
"issuer_url": issuer_url,
|
"issuer_url": issuer_url,
|
||||||
|
"features": features,
|
||||||
"subject_cn": subject_cn,
|
"subject_cn": subject_cn,
|
||||||
"issuer_cn": issuer_cn,
|
"issuer_cn": issuer_cn,
|
||||||
"is_ca": is_ca,
|
"is_ca": is_ca,
|
||||||
@@ -603,11 +618,12 @@ def sign_ca_csr_with_seats(
|
|||||||
seats: int,
|
seats: int,
|
||||||
days_valid: int = 365,
|
days_valid: int = 365,
|
||||||
license_id: Optional[str] = None,
|
license_id: Optional[str] = None,
|
||||||
license_server_url: str = "https://logar.eibl.tech"
|
license_server_url: str = "https://logar.eibl.tech",
|
||||||
|
features: Optional[List[str]] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Signs a subordinate CA CSR using the master license authority Root CA.
|
Signs a subordinate CA CSR using the master license authority Root CA.
|
||||||
Embeds the approved seat count and license ID via custom X.509 extensions.
|
Embeds the approved seat count, license ID, and optional enterprise features via custom X.509 extensions.
|
||||||
Returns the signed PEM certificate.
|
Returns the signed PEM certificate.
|
||||||
"""
|
"""
|
||||||
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
||||||
@@ -666,6 +682,13 @@ def sign_ca_csr_with_seats(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if features:
|
||||||
|
feat_str = json.dumps(features) if isinstance(features, list) else str(features)
|
||||||
|
builder = builder.add_extension(
|
||||||
|
x509.UnrecognizedExtension(OID_LOGAR_FEATURES, feat_str.encode("utf-8")),
|
||||||
|
critical=False
|
||||||
|
)
|
||||||
|
|
||||||
signed_cert = builder.sign(master_ca_key, hashes.SHA256())
|
signed_cert = builder.sign(master_ca_key, hashes.SHA256())
|
||||||
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
|
||||||
@@ -903,6 +926,7 @@ def import_license_package(
|
|||||||
"master_root_ca_pem": master_root_ca_pem,
|
"master_root_ca_pem": master_root_ca_pem,
|
||||||
"license_info": info,
|
"license_info": info,
|
||||||
"seats": info.get("seats"),
|
"seats": info.get("seats"),
|
||||||
|
"features": info.get("features", []),
|
||||||
"license_id": info.get("license_id"),
|
"license_id": info.get("license_id"),
|
||||||
"issuer_url": info.get("issuer_url"),
|
"issuer_url": info.get("issuer_url"),
|
||||||
"fingerprint": info.get("fingerprint"),
|
"fingerprint": info.get("fingerprint"),
|
||||||
|
|||||||
@@ -547,8 +547,118 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db)
|
Server.reset_failed_ip_attempts(test_ip, scope="login", db_path=self.test_db)
|
||||||
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
|
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
|
||||||
|
|
||||||
|
def test_ingest_queue_manager_and_batch_flush(self):
|
||||||
|
"""Tests IngestQueueManager buffering, capacity bounds, and atomic batch flushing."""
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
qm = Server.IngestQueueManager(flush_interval=1.0, batch_size=5, max_queue=50)
|
||||||
|
|
||||||
|
# Initial stats
|
||||||
|
stats = qm.get_stats()
|
||||||
|
self.assertEqual(stats["queue_size"], 0)
|
||||||
|
self.assertEqual(stats["total_ingested"], 0)
|
||||||
|
self.assertEqual(stats["total_flushed"], 0)
|
||||||
|
|
||||||
|
# Push 3 payloads
|
||||||
|
for i in range(3):
|
||||||
|
payload = {
|
||||||
|
"server": f"node-{i}.corp.local",
|
||||||
|
"logs": [
|
||||||
|
{"server": f"node-{i}", "signature": f"Sig_{i}", "severity": "WARNING", "message": f"Log msg {i}", "os_type": "linux"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
pushed = qm.push(payload)
|
||||||
|
self.assertTrue(pushed)
|
||||||
|
|
||||||
|
stats = qm.get_stats()
|
||||||
|
self.assertEqual(stats["queue_size"], 3)
|
||||||
|
self.assertEqual(stats["total_ingested"], 3)
|
||||||
|
|
||||||
|
# Flush to database
|
||||||
|
res_flush = qm.flush(db_path=self.test_db, window_hours=12, min_runs=1)
|
||||||
|
self.assertEqual(res_flush["flushed_runs"], 3)
|
||||||
|
self.assertEqual(res_flush["processed_logs"], 3)
|
||||||
|
|
||||||
|
# Verify DB records
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT COUNT(*) FROM ingest_runs")
|
||||||
|
run_count = c.fetchone()[0]
|
||||||
|
c.execute("SELECT COUNT(*) FROM active_issues WHERE status = 'VERIFIED'")
|
||||||
|
issue_count = c.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertEqual(run_count, 3)
|
||||||
|
self.assertEqual(issue_count, 3)
|
||||||
|
|
||||||
|
# Stats after flush
|
||||||
|
stats = qm.get_stats()
|
||||||
|
self.assertEqual(stats["queue_size"], 0)
|
||||||
|
self.assertEqual(stats["total_flushed"], 3)
|
||||||
|
|
||||||
|
def test_enterprise_db_feature_gating(self):
|
||||||
|
"""Tests feature check helpers and DB storage info under standard and enterprise licensed states."""
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
|
||||||
|
# 1. Standard / Unlicensed state
|
||||||
|
self.assertFalse(Server.is_feature_licensed("enterprise_db", db_path=self.test_db))
|
||||||
|
info = Server.get_storage_engine_info(db_path=self.test_db)
|
||||||
|
self.assertEqual(info["engine"], "sqlite")
|
||||||
|
self.assertFalse(info["feature_unlocked"])
|
||||||
|
self.assertIn("SQLite 3 (WAL Mode)", info["backend_display"])
|
||||||
|
|
||||||
|
# 2. Unlock enterprise_db feature in DB
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
conn.execute("UPDATE license_config SET features = ? WHERE id = 1", (json.dumps(["enterprise_db", "ai_heuristics"]),))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertTrue(Server.is_feature_licensed("enterprise_db", db_path=self.test_db))
|
||||||
|
self.assertTrue(Server.is_feature_licensed("ai_heuristics", db_path=self.test_db))
|
||||||
|
self.assertFalse(Server.is_feature_licensed("unknown_feat", db_path=self.test_db))
|
||||||
|
|
||||||
|
info_unlocked = Server.get_storage_engine_info(db_path=self.test_db)
|
||||||
|
self.assertTrue(info_unlocked["feature_unlocked"])
|
||||||
|
self.assertIn("enterprise_db", info_unlocked["licensed_features"])
|
||||||
|
|
||||||
|
def test_postgres_storage_endpoints_authorization(self):
|
||||||
|
"""Tests REST API security gating on DB engine management endpoints."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "storage_engine": "sqlite"}
|
||||||
|
client = TestClient(Server.app)
|
||||||
|
|
||||||
|
# 1. Unauthenticated request to /api/db/stats -> 401
|
||||||
|
res = client.get("/api/db/stats")
|
||||||
|
self.assertEqual(res.status_code, 401)
|
||||||
|
|
||||||
|
# 2. Authenticated request with session cookie
|
||||||
|
token = Server.generate_session_token("admin", db_path=self.test_db)
|
||||||
|
client.cookies.set(Server.SESSION_COOKIE_NAME, token)
|
||||||
|
|
||||||
|
res_auth = client.get("/api/db/stats")
|
||||||
|
self.assertEqual(res_auth.status_code, 200)
|
||||||
|
data = res_auth.json()
|
||||||
|
self.assertIn("queue", data)
|
||||||
|
self.assertIn("storage", data)
|
||||||
|
self.assertFalse(data["enterprise_db_licensed"])
|
||||||
|
|
||||||
|
# 3. /api/db/test-connection without enterprise_db feature -> 403 Forbidden
|
||||||
|
pg_req = {"host": "localhost", "port": 5432, "database": "logar", "user": "postgres", "password": ""}
|
||||||
|
res_test = client.post("/api/db/test-connection", json=pg_req)
|
||||||
|
self.assertEqual(res_test.status_code, 403)
|
||||||
|
self.assertIn("enterprise_db", res_test.json()["detail"])
|
||||||
|
|
||||||
|
# 4. /api/db/switch-backend without enterprise_db feature -> 403 Forbidden
|
||||||
|
res_switch = client.post("/api/db/switch-backend", json={"engine": "postgres", "pg_config": pg_req})
|
||||||
|
self.assertEqual(res_switch.status_code, 403)
|
||||||
|
|
||||||
|
# 5. Flush buffer endpoint is accessible to authenticated admins
|
||||||
|
res_flush = client.post("/api/db/flush-buffer")
|
||||||
|
self.assertEqual(res_flush.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user