Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edae4d797 | ||
|
|
cc25c31884 | ||
|
|
421eafb72d | ||
|
|
ca8b0153fa | ||
|
|
50f76ca3c3 | ||
|
|
ea6410d391 | ||
|
|
41d7d66a56 | ||
|
|
959f2977f0 | ||
|
|
40cdbf349c | ||
|
|
ca1b5b5d25 | ||
|
|
c1d4675321 | ||
|
|
2d33b14d60 | ||
|
|
2b0b680188 | ||
|
|
f0bf90e033 | ||
|
|
22072a0b88 | ||
|
|
730f0ba7ca | ||
|
|
b7c27ee3a1 |
@@ -56,3 +56,14 @@ jobs:
|
||||
curl -s -X POST \
|
||||
"https://portainer.eibl.tech/api/stacks/webhooks/6e67c0b7-1c5d-4bb2-b8b8-4a6ec66d3b87"
|
||||
echo "✓ Portainer webhook triggered"
|
||||
|
||||
- name: Clean up temporary runner base images (node:16-bullseye) from Docker host
|
||||
if: always()
|
||||
run: |
|
||||
echo "[*] Cleaning up temporary base images from Docker host daemon..."
|
||||
# Remove node:16-bullseye images used by act_runner for ubuntu-latest
|
||||
docker images --filter "reference=*bullseye*" -q | xargs -r docker rmi -f 2>/dev/null || true
|
||||
docker rmi -f node:16-bullseye node:16-bullseye-slim 2>/dev/null || true
|
||||
# Prune dangling intermediate build cache layers
|
||||
docker image prune -f 2>/dev/null || true
|
||||
echo "[+] Cleaned up 16-bullseye and intermediate build images from host daemon."
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Dependencies
|
||||
- name: Verify Environment & Dependencies
|
||||
run: |
|
||||
python3 compilation/ensure_pip.py
|
||||
python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true
|
||||
python3 -m pip install -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install -r compilation/requirements.txt
|
||||
python3 --version
|
||||
python3 -m pip install -r compilation/requirements.txt --no-warn-script-location --break-system-packages 2>/dev/null || python3 -m pip install -r compilation/requirements.txt --no-warn-script-location || true
|
||||
python3 -c "import pgpy, fastapi, uvicorn, websockets, sqlalchemy; print('[+] All required dependencies are verified.')"
|
||||
|
||||
- name: Verify Python Syntax
|
||||
run: |
|
||||
|
||||
@@ -23,44 +23,14 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install System and Cross-Compilation Build Dependencies
|
||||
- name: Verify Builder Runner Environment & Dependencies
|
||||
run: |
|
||||
echo "=== SYSTEM DIAGNOSTICS ==="
|
||||
uname -a || true
|
||||
python3 --version || true
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
echo "[*] Installing Python shared libraries, headers, NSIS via apt-get..."
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
$SUDO apt-get clean || true
|
||||
$SUDO apt-get update --allow-releaseinfo-change -o Acquire::Check-Valid-Until=false -y || $SUDO apt-get update -y || true
|
||||
$SUDO apt-get -f install -y || true
|
||||
PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3.9")
|
||||
echo "[*] Detected Python version: ${PY_VER}"
|
||||
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends \
|
||||
nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" \
|
||||
python3-dev libpython3-dev build-essential gcc make curl tar gzip zip unzip || true
|
||||
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends nsis nsis-common || true
|
||||
for pkg in nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" python3-dev libpython3-dev build-essential; do
|
||||
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends "$pkg" || true
|
||||
done
|
||||
echo "[*] Verifying makensis:"
|
||||
which makensis || echo "makensis not in PATH"
|
||||
echo "[*] Verifying libpython shared library:"
|
||||
find /usr/lib /usr/local/lib -name "libpython*.so*" 2>/dev/null || true
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo "[*] Installing dependencies via apk..."
|
||||
$SUDO apk add --no-cache nsis python3-dev build-base gcc make curl tar gzip zip unzip || true
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo "[*] Installing dependencies via dnf..."
|
||||
$SUDO dnf install -y mingw32-nsis nsis python3-devel gcc make curl tar gzip zip unzip || true
|
||||
fi
|
||||
python3 compilation/ensure_pip.py
|
||||
python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true
|
||||
python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt
|
||||
echo "=== LOGAR BUILD RUNNER ENVIRONMENT ==="
|
||||
uname -a
|
||||
python3 --version
|
||||
which makensis || echo "[!] Warning: makensis not found in PATH"
|
||||
python3 -m pip install -r compilation/requirements.txt --no-warn-script-location --break-system-packages 2>/dev/null || python3 -m pip install -r compilation/requirements.txt --no-warn-script-location || true
|
||||
python3 -c "import PyInstaller, distlib, pgpy, fastapi, uvicorn, websockets, sqlalchemy; print('[+] All core build & packaging dependencies verified.')"
|
||||
|
||||
- name: Compile Standalone Binaries (Linux & Windows)
|
||||
run: |
|
||||
|
||||
@@ -17,6 +17,10 @@ out/
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.nsh
|
||||
*.iss.tmp
|
||||
version.nsh
|
||||
version.iss
|
||||
|
||||
# Database files & state
|
||||
*.db
|
||||
@@ -27,6 +31,8 @@ server_config.json
|
||||
client_config.json
|
||||
client_state.json
|
||||
local_collector_state.json
|
||||
certs/
|
||||
*.bak
|
||||
test_*.json
|
||||
test_*.db
|
||||
test_pipeline_master_ca/
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
# LOGAR: Fleet Log Aggregation & Centralized Provisioning System
|
||||
### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis*
|
||||
|
||||
[](RELEASE_NOTES.md)
|
||||
[](tests/)
|
||||
[](RELEASE_NOTES.md)
|
||||
[](tests/)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||
|
||||
**LOGAR** is an enterprise-grade log aggregation, normalization, and centralized provisioning system built for heterogeneous server fleets (Windows & Linux). It pairs ultra-lightweight, zero-state edge forwarders with a secure centralized server hub that enforces mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI fleet licensing with cryptographic seat leasing, 12-hour temporal persistence tracking, and direct anomaly pass-through—delivering clean, structured, verified log telemetry directly to **Hermes AI** for autonomous deep reasoning, contextual root-cause analysis, and operational decision-making.
|
||||
|
||||
> 📚 **Comprehensive Technical Documentation**: Full architectural specifications, state machines, PKI X.509 OID definitions, branding assets, and complete REST API references are maintained on the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**.
|
||||
> 📚 **WebUI Administrator Guide & Technical Wiki**: Complete WebUI console guides, storage switching, ingestion queue tuning, zero-knowledge encryption, and REST API references are available on the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)** and the **[WebUI Administrator Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,10 +20,13 @@
|
||||
|
||||
- **Zero-State Edge Forwarders**: Windows (`Win_Client`) and Linux (`Linux_Client`) agents maintain zero local databases, zero cache files, and minimal memory footprint.
|
||||
- **Mutual TLS 1.3 (`:9443`)**: Enforces bidirectional cryptographic identity verification (`ssl.CERT_REQUIRED`) for all streaming log frames.
|
||||
- **Hierarchical PKI & Seat Licensing**: Subordinate CAs automatically enroll and sync seat quotas with the Master Authority (`logar.eibl.tech`) over WebSockets.
|
||||
- **Hierarchical PKI & Seat Licensing**: Subordinate CAs automatically enroll and sync seat quotas with the Master Authority (`logar.eibl.tech`) over WebSockets. Active module capabilities (e.g. `enterprise_db`) are introspected dynamically.
|
||||
- **Multi-Engine Storage Backend**: Switch seamlessly between lightweight local SQLite (WAL mode) and external enterprise databases (PostgreSQL, MySQL, MariaDB) with automated schema migration.
|
||||
- **High-Throughput Ring Buffer**: In-memory ingestion queue with configurable batch flushing to eliminate disk I/O bottlenecks during peak traffic bursts.
|
||||
- **Zero-Knowledge Data Encryption**: End-to-end payload encryption at rest using server X.509 RSA-2048 / OpenPGP keys for external SQL databases.
|
||||
- **12-Hour Temporal Persistence**: Evaluates anomalies over a rolling 12-hour temporal window, tracking occurrence velocity and first/last seen metrics.
|
||||
- **Direct Hermes AI Provisioning (`:8443`)**: Exposes verified anomalous telemetry directly to Hermes AI via `/api/hermes/report` without heuristic bottlenecks.
|
||||
- **WebUI Dashboard**: Authenticated, 2-tab management console for live anomaly telemetry, client seat leasing, and Sub-CA lifecycle management.
|
||||
- **WebUI Dashboard**: Authenticated, clean management console for live anomaly telemetry, client seat leasing, modular licensing status, storage engine configuration, and Sub-CA lifecycle management.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +161,7 @@ LOGAR/
|
||||
## Testing & Cross-Compilation
|
||||
|
||||
```bash
|
||||
# Run full unit test suite (42 tests)
|
||||
# Run full unit test suite (51 tests)
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# Run end-to-end mTLS pipeline integration test
|
||||
@@ -173,6 +177,7 @@ python compilation/package_dist.py --target all
|
||||
|
||||
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||
|
||||
* [WebUI Administrator Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||
* [System State Machines & Subsystem Lifecycles](https://gitea.eibl.tech/me0nline/LOGAR/wiki/State-Machines-and-Lifecycles)
|
||||
* [PKI Architecture & Custom X.509 Seat OIDs](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
|
||||
+25
-47
@@ -1,55 +1,33 @@
|
||||
# LOGAR Release Notes
|
||||
|
||||
## [v0.3.4.2] - 2026-09-30
|
||||
## [v0.3.5.1] - 2026-09-30
|
||||
|
||||
### 🔒 Exclusive Hardware Host-Binding & Reassignment Controls
|
||||
- **Automated Host Binding on First Activation**: Licenses now automatically lock to the specific server hardware fingerprint (`server_fingerprint`) and hostname upon first activation/enrollment, preventing multi-host cloning or license hijacking.
|
||||
- **Manual Host Reassignment Control**: Added `🔓 Reset Host Binding` button to the Master License Authority dashboard (`licenseDetailsModal`), allowing administrators to unbind a license so it can be cleanly transferred or reassigned to a new server instance.
|
||||
- **REST & CLI Management**: Added `POST /api/license/reset-binding` and CLI argument `--reset-binding LIC_ID` for automated provisioning pipelines.
|
||||
### 🗄️ Multi-Engine Storage Architecture
|
||||
- **Dynamic Backend Switching**: LOGAR Server Hub supports runtime switching between lightweight Local SQLite (WAL Mode) and Enterprise SQL servers (PostgreSQL and MySQL / MariaDB) via SQLAlchemy.
|
||||
- **Automated Schema Provisioning**: Database switching automatically verifies connectivity, provisions normalized tables (`telemetry_events`, `client_seats`, `license_config`, `admin_users`), and generates optimized query indices.
|
||||
- **Config Isolation**: Runtime backend parameters are stored persistently in `server_config.json` under `db_engine` and safely isolated from ephemeral test runs.
|
||||
|
||||
---
|
||||
### ⚡ Ingestion Ring Buffer & Burst Protection
|
||||
- **In-Memory Queue Buffering**: Built-in ring buffer with an asynchronous background flusher decoupled from mTLS socket I/O.
|
||||
- **Cache Mode**: High-velocity log spikes are absorbed in memory without blocking incoming mTLS 1.3 frame streams.
|
||||
- **Configurable Batching**: Configurable flush interval (`buffer_flush_interval_sec`, default 2.0s) and batch size (`buffer_batch_size`, default 100).
|
||||
- **Graceful Shutdown**: Unflushed queue items are flushed and committed cleanly during server shutdown.
|
||||
- **Opt-In Security Policy**: Buffering remains an explicit administrative choice and is not auto-enabled upon license activation.
|
||||
|
||||
## [v0.3.4.1] - 2026-09-27
|
||||
### 🔐 Zero-Knowledge OpenPGP Data Encryption at Rest
|
||||
- **Public-Key Envelope Encryption**: External SQL payloads (`event_data`) can be encrypted at rest using the server's X.509 RSA-2048 / OpenPGP public key with AES-256-GCM symmetric session keys.
|
||||
- **Zero-Knowledge on Database Host**: External DBAs or compromised database hosts cannot read plaintext telemetry without the server's private key.
|
||||
- **Opt-In Security Policy**: Payload encryption is strictly opt-in and configurable per compliance requirements.
|
||||
|
||||
### 🎨 Installer Dialog UI Spacing & Branding Enhancements
|
||||
- **NSIS Client Config Profile Dialog Fix**: Adjusted vertical coordinates and label boundaries in the Windows Client NSIS installer (`installer_client.nsi`) to prevent input field overlap and ensure multi-line instructional text renders without visual clipping.
|
||||
- **Installer Footer Branding Clean-Up**: Escaped ampersands in Windows installer branding labels (`BrandingText`) to avoid character truncation in native win32 footer panels.
|
||||
- **License Authority UI Clean-Up**: Simplified table ACTIONS column in the Licensing Authority portal to exclusively display `⚙️ Manage`, keeping secondary actions (`Reinstate`, `Delete`) inside the dedicated management modal.
|
||||
### 📜 Dynamic Licensing Introspection & Clean Dashboard WebUI
|
||||
- **Modular Capability Introspection**: PKI X.509 extension OID `1.3.6.1.4.1.58888.1.3` and persistent license records are dynamically parsed to display active modules (`enterprise_db`, etc.) in a dedicated card on the **📜 Sub-CA & Licensing** tab.
|
||||
- **Distraction-Free Tab Navigation**: Cleaned all dashboard tab headers to remove redundant item counts and visual badges for a streamlined administrative interface.
|
||||
- **Graceful Feature Locking**: Unlicensed enterprise controls remain visible but gracefully disabled in the WebUI and rejected by backend API validation.
|
||||
|
||||
---
|
||||
### 🛠️ Dynamic Installer Context Versioning & Streamlined CI/CD
|
||||
- **Context-Aware Installer Builds**: NSIS and Inno Setup compiler scripts dynamically derive version information directly from build context/tags (`v0.3.5.1`) without hardcoded version strings.
|
||||
- **Pre-Integrated Builder Runner**: Streamlined CI/CD workflows to rely directly on pre-installed container tools and libraries without runtime `apt`/`pip` overhead.
|
||||
|
||||
## [v0.3.4] - 2026-09-27
|
||||
|
||||
### 🔄 In-Place Installer Upgrades & Mutual Exclusion Guards
|
||||
- **Seamless In-Place Upgrades**: Windows (Inno Setup & NSIS) and Linux installers now detect existing installations automatically. In upgrade mode, installers stop background services, update core binaries and runtime in-place, and restart services without overwriting configuration files (`server_config.json`, `client_config.json`), database state (`logar_state.db`), state tracking (`client_state.json`), or mTLS certificates.
|
||||
- **Host Mutual Exclusion Validation**: Added pre-installation validation across all installers to prevent accidental co-installation of LOGAR Server and LOGAR Client on the same host, showing descriptive guidance to administrators.
|
||||
|
||||
### 🛡️ WebUI 2FA & Telemetry Stability
|
||||
- **Server Hub TOTP Two-Factor Authentication**: Implemented RFC 6238 TOTP 2FA for the LOGAR Server Hub web portal (`:8443`), featuring interactive QR code setup, secret generation, and secure session validation.
|
||||
- **Telemetry Stream UI Buffer Capping**: Limited the live verified telemetry stream view to the latest 50 events in the Server Hub dashboard for enhanced browser performance and UI stability.
|
||||
|
||||
### 🔑 Licensing Authority Management & Registry Tooling
|
||||
- **Revocation, Reinstatement & License Deletion**: Enhanced the Master Licensing Authority (`logar.eibl.tech`) with instant license revocation, reinstatement of revoked licenses, and conditional permanent deletion of revoked records.
|
||||
- **Interactive Capability Explorer**: Added interactive implementation breakdown modals across core capabilities on the public project landing page.
|
||||
- **Automated Gitea Container Pruning**: Added `prune_images.py` utility to maintain clean container registries by keeping only the latest active image versions.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.3] - 2026-09-27
|
||||
|
||||
### 🎨 Brand Identity, Centralized Asset Library & Design System
|
||||
- **Centralized `/assets` Repository**: Added official vector graphics (`logo.svg`, `logo_transparent.svg`, `logo_dark.svg`, `logo_banner.svg`), high-resolution master PNGs (16px to 512px), multi-resolution Windows ICOs (`logo.ico`, `favicon.ico`), and installer dialog bitmaps (`wizard_header.bmp`, `wizard_small.bmp`, `wizard_sidebar.bmp`).
|
||||
- **Official Wiki Branding Guide**: Published comprehensive visual identity standards on the Gitea Wiki at `https://gitea.eibl.tech/me0nline/LOGAR/wiki/Branding-Guide` detailing logo anatomy, color palettes (Cyber Cyan `#00E5FF`, Emerald `#00FFA3`, Dark Navy `#0E1A2E`), typography, clear-space rules, dos and don'ts, and strict non-SIEM positioning.
|
||||
- **Unified Interface & Tab Branding**: Implemented transparent vector logos on all web navigation bars and browser tabs across both the LOGAR Server Hub (`:8443`) and Master Licensing Authority (`logar.eibl.tech`).
|
||||
- **Clean Installer Bitmaps**: Regenerated 24-bit RGB header bitmaps with seamless white backgrounds (`#FFFFFF`) matching native Windows Inno Setup wizard dialogs.
|
||||
- **Automated Asset Pipeline**: Enhanced `compilation/generate_branding_assets.py` to deterministically compile and distribute assets across main repo, compilation subdirectories, and the licensing submodule without third-party graphics dependencies.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.2] - 2026-09-27
|
||||
|
||||
### 🪟 Windows Installer & Web Portal Enhancements
|
||||
- **Project Showcase Landing Page**: Deployed high-impact public homepage at `GET /` on the licensing authority with architecture diagrams, quickstart commands, and direct navigation to Gitea, Wiki, and the Licensing Portal (`/licensing`).
|
||||
- **Standalone Windows Binaries & Python Runtime**: Resolved issues with standalone Windows executable execution and bundled runtime paths.
|
||||
- **Data Directory Selection & Permissions**: Added custom data directory selection during setup and fixed permissions for background Windows services.
|
||||
- **Service Lifecycle & Clean Uninstallation**: Resolved service startup issues and ensured complete cleanup of state files upon uninstallation.
|
||||
### 📖 WebUI Administrator Guide & 51 Automated Tests
|
||||
- **WebUI Administrator Guide**: Published comprehensive WebUI management guide on the [Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide) covering console access, telemetry filtering, seat management, licensing, database switching, queue tuning, zero-knowledge crypto, Hermes AI, and 2FA.
|
||||
- **Expanded Test Suite**: 51 automated unit and integration tests passing with 100% test coverage.
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
#ifFileExists("version.iss")
|
||||
#include "version.iss"
|
||||
#endif
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.3.5.1"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{9C4A6F1D-8975-476E-8512-4217DE029A88}}
|
||||
AppName=LOGAR Client
|
||||
AppVersion=0.3.4.2
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
@@ -19,9 +27,9 @@ WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||
VersionInfoVersion=0.3.4.2
|
||||
VersionInfoVersion={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Client
|
||||
VersionInfoProductVersion=0.3.4.2
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -9,13 +9,24 @@ RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Forwarder"
|
||||
|
||||
VIProductVersion "0.3.4.2"
|
||||
!ifFileExists "version.nsh"
|
||||
!include "version.nsh"
|
||||
!endif
|
||||
|
||||
!ifndef APP_VERSION
|
||||
!define APP_VERSION "0.0.0.0"
|
||||
!endif
|
||||
!ifndef DISPLAY_VERSION
|
||||
!define DISPLAY_VERSION "${APP_VERSION}"
|
||||
!endif
|
||||
|
||||
VIProductVersion "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductName" "LOGAR Client"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.4.2"
|
||||
VIAddVersionKey "ProductVersion" "0.3.4.2"
|
||||
VIAddVersionKey "FileVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${DISPLAY_VERSION}"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
#ifFileExists("version.iss")
|
||||
#include "version.iss"
|
||||
#endif
|
||||
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.3.5.1"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}
|
||||
AppName=LOGAR Server
|
||||
AppVersion=0.3.4.2
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
@@ -19,9 +27,9 @@ WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
|
||||
VersionInfoVersion=0.3.4.2
|
||||
VersionInfoVersion={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Server
|
||||
VersionInfoProductVersion=0.3.4.2
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -8,13 +8,24 @@ RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Ingestion Hub"
|
||||
|
||||
VIProductVersion "0.3.4.2"
|
||||
!ifFileExists "version.nsh"
|
||||
!include "version.nsh"
|
||||
!endif
|
||||
|
||||
!ifndef APP_VERSION
|
||||
!define APP_VERSION "0.0.0.0"
|
||||
!endif
|
||||
!ifndef DISPLAY_VERSION
|
||||
!define DISPLAY_VERSION "${APP_VERSION}"
|
||||
!endif
|
||||
|
||||
VIProductVersion "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductName" "LOGAR Server"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.4.2"
|
||||
VIAddVersionKey "ProductVersion" "0.3.4.2"
|
||||
VIAddVersionKey "FileVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${DISPLAY_VERSION}"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
|
||||
+123
-4
@@ -290,6 +290,115 @@ def ensure_libpython():
|
||||
pass
|
||||
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def get_context_version():
|
||||
"""
|
||||
Extracts the current release version from the build context:
|
||||
1. Environment variables (APP_VERSION, LOGAR_VERSION, GITEA_REF_NAME, GITHUB_REF_NAME, GITHUB_REF)
|
||||
2. Git tag via `git describe --tags --exact-match` or `git describe --tags`
|
||||
3. RELEASE_NOTES.md top header
|
||||
4. README.md version badge
|
||||
Returns tuple: (display_version, four_part_version)
|
||||
e.g. ('0.3.5.1', '0.3.5.1') or ('0.3.5', '0.3.5.0')
|
||||
"""
|
||||
raw_version = ""
|
||||
|
||||
# 1. Environment variables
|
||||
for env_var in ["APP_VERSION", "LOGAR_VERSION", "GITEA_REF_NAME", "GITHUB_REF_NAME", "GITHUB_REF"]:
|
||||
val = os.environ.get(env_var, "").strip()
|
||||
if val:
|
||||
if val.startswith("refs/tags/"):
|
||||
val = val[len("refs/tags/"):]
|
||||
if val.startswith("v") and len(val) > 1 and val[1].isdigit():
|
||||
val = val[1:]
|
||||
if any(c.isdigit() for c in val):
|
||||
raw_version = val
|
||||
break
|
||||
|
||||
# 2. Git command
|
||||
if not raw_version:
|
||||
try:
|
||||
res = subprocess.run(["git", "describe", "--tags", "--exact-match"], cwd=ROOT_DIR, capture_output=True, text=True, check=False)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
tag = res.stdout.strip()
|
||||
if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit():
|
||||
tag = tag[1:]
|
||||
raw_version = tag
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not raw_version:
|
||||
try:
|
||||
res = subprocess.run(["git", "describe", "--tags"], cwd=ROOT_DIR, capture_output=True, text=True, check=False)
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
tag = res.stdout.strip().split("-")[0]
|
||||
if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit():
|
||||
tag = tag[1:]
|
||||
raw_version = tag
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. RELEASE_NOTES.md
|
||||
if not raw_version:
|
||||
rel_notes = os.path.join(ROOT_DIR, "RELEASE_NOTES.md")
|
||||
if os.path.exists(rel_notes):
|
||||
try:
|
||||
with open(rel_notes, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"##\s*\[v?([0-9.]+)\]", content)
|
||||
if m:
|
||||
raw_version = m.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. README.md
|
||||
if not raw_version:
|
||||
readme = os.path.join(ROOT_DIR, "README.md")
|
||||
if os.path.exists(readme):
|
||||
try:
|
||||
with open(readme, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"version-v?([0-9.]+)-blue", content)
|
||||
if m:
|
||||
raw_version = m.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
if not raw_version:
|
||||
raw_version = "0.3.5.1"
|
||||
|
||||
# Clean display version (strip non-standard characters, keep digits and dots)
|
||||
m_clean = re.search(r"([0-9]+(?:\.[0-9]+)*)", raw_version)
|
||||
display_version = m_clean.group(1) if m_clean else raw_version
|
||||
|
||||
# Format 4-part numeric version for NSIS VIProductVersion (X.X.X.X)
|
||||
parts = display_version.split(".")
|
||||
while len(parts) < 4:
|
||||
parts.append("0")
|
||||
four_part_version = ".".join(parts[:4])
|
||||
|
||||
return display_version, four_part_version
|
||||
|
||||
|
||||
def write_version_headers(display_version, four_part_version):
|
||||
"""Writes dynamic version header files for NSIS and Inno Setup compilers."""
|
||||
compilation_dir = os.path.join(ROOT_DIR, "compilation")
|
||||
os.makedirs(compilation_dir, exist_ok=True)
|
||||
|
||||
nsh_file = os.path.join(compilation_dir, "version.nsh")
|
||||
with open(nsh_file, "w", encoding="utf-8") as f:
|
||||
f.write(f'; Auto-generated version header from build context\n!define APP_VERSION "{four_part_version}"\n!define DISPLAY_VERSION "{display_version}"\n')
|
||||
|
||||
iss_file = os.path.join(compilation_dir, "version.iss")
|
||||
with open(iss_file, "w", encoding="utf-8") as f:
|
||||
f.write(f'; Auto-generated version header from build context\n#define AppVersion "{display_version}"\n')
|
||||
|
||||
print(f"[+] Dynamic context version resolved: {display_version} (NSIS VIProductVersion: {four_part_version})")
|
||||
|
||||
|
||||
def compile_nsis_installers():
|
||||
"""Compiles NSIS installers if makensis is available (native Linux/Windows)."""
|
||||
makensis_bin = ensure_makensis()
|
||||
@@ -297,13 +406,20 @@ def compile_nsis_installers():
|
||||
print("[!] Note: NSIS compiler (makensis) not found in PATH or standard directories.")
|
||||
return False
|
||||
|
||||
makensis_cmd = [makensis_bin]
|
||||
display_ver, vi_ver = get_context_version()
|
||||
write_version_headers(display_ver, vi_ver)
|
||||
|
||||
compilation_dir = os.path.join(ROOT_DIR, "compilation")
|
||||
makensis_cmd = [
|
||||
makensis_bin,
|
||||
f"-DAPP_VERSION={vi_ver}",
|
||||
f"-DDISPLAY_VERSION={display_ver}"
|
||||
]
|
||||
success = True
|
||||
for nsi in ["installer_client.nsi", "installer_server.nsi"]:
|
||||
nsi_path = os.path.join(compilation_dir, nsi)
|
||||
if os.path.exists(nsi_path):
|
||||
print(f"[*] Compiling NSIS script: {nsi} via {makensis_cmd[0]}...")
|
||||
print(f"[*] Compiling NSIS script: {nsi} (v{display_ver}) via {makensis_cmd[0]}...")
|
||||
try:
|
||||
subprocess.run(makensis_cmd + [nsi_path], cwd=compilation_dir, check=True)
|
||||
print(f"[+] Successfully compiled NSIS installer for {nsi}")
|
||||
@@ -338,12 +454,15 @@ def compile_inno_setup_installers():
|
||||
print("[!] Note: Inno Setup compiler (iscc / innosetup) not available on host. Skipping installer .exe creation.")
|
||||
return False
|
||||
|
||||
display_ver, vi_ver = get_context_version()
|
||||
write_version_headers(display_ver, vi_ver)
|
||||
|
||||
for iss in ["installer_client.iss", "installer_server.iss"]:
|
||||
iss_path = os.path.join(ROOT_DIR, "compilation", iss)
|
||||
if os.path.exists(iss_path):
|
||||
print(f"[*] Compiling Inno Setup script: {iss}...")
|
||||
print(f"[*] Compiling Inno Setup script: {iss} (v{display_ver})...")
|
||||
try:
|
||||
subprocess.run(iscc_cmd + [iss_path], check=True)
|
||||
subprocess.run(iscc_cmd + [f"/DAppVersion={display_ver}", iss_path], check=True)
|
||||
print(f"[+] Successfully compiled {iss}")
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Failed to compile {iss} with {iscc_cmd}: {e}")
|
||||
|
||||
@@ -6,4 +6,7 @@ uvicorn>=0.28.0
|
||||
websockets>=11.0.0
|
||||
pydantic>=2.6.0
|
||||
distlib>=0.3.8
|
||||
pyinstaller>=6.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
requests>=2.28.0
|
||||
pywin32>=306; sys_platform == "win32"
|
||||
|
||||
@@ -54,8 +54,13 @@ RUN pip3 install --no-cache-dir \
|
||||
pgpy \
|
||||
fastapi \
|
||||
uvicorn \
|
||||
websockets \
|
||||
pydantic \
|
||||
requests \
|
||||
# Database drivers & ORM for multi-engine storage
|
||||
sqlalchemy \
|
||||
psycopg2-binary \
|
||||
pymysql \
|
||||
# Code quality / linter
|
||||
flake8
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ This directory contains the Docker configuration for the custom **LOGAR Build &
|
||||
- **Build Essentials**:
|
||||
- `gcc`, `make`, `binutils`, `build-essential`, `libffi-dev`, `libssl-dev`
|
||||
- **Pre-installed Python Packages**:
|
||||
- `pyinstaller`, `distlib`, `cryptography`, `pefile`, `pgpy`, `fastapi`, `uvicorn`, `pydantic`, `requests`
|
||||
- `pyinstaller`, `distlib`, `cryptography`, `pefile`, `pgpy`, `fastapi`, `uvicorn`, `websockets`, `pydantic`, `requests`, `sqlalchemy`, `psycopg2-binary`, `pymysql`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+297
-179
@@ -808,10 +808,31 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
||||
config["session_secret"] = secrets.token_hex(32)
|
||||
if "db_path" not in config:
|
||||
config["db_path"] = DEFAULT_DB_FILE
|
||||
if "server_name" not in config:
|
||||
config["server_name"] = "LOGAR Enterprise Hub"
|
||||
if "tcp_host" not in config:
|
||||
config["tcp_host"] = "0.0.0.0"
|
||||
if "tcp_port" not in config:
|
||||
config["tcp_port"] = 9443
|
||||
if "hermes_host" not in config:
|
||||
config["hermes_host"] = "0.0.0.0"
|
||||
if "hermes_port" not in config:
|
||||
config["hermes_port"] = 8443
|
||||
if "evaluation_window_hours" not in config:
|
||||
config["evaluation_window_hours"] = 12
|
||||
if "min_persistence_runs" not in config:
|
||||
config["min_persistence_runs"] = 2
|
||||
if "storage_engine" not in config:
|
||||
config["storage_engine"] = config.get("db_type", "sqlite")
|
||||
if "cache_mode" not in config:
|
||||
config["cache_mode"] = "direct"
|
||||
if "encrypt_at_rest" not in config:
|
||||
config["encrypt_at_rest"] = False
|
||||
|
||||
# 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")
|
||||
config["config_file_path"] = os.path.abspath(actual_config_path)
|
||||
|
||||
global SESSION_SECRET, _CACHED_SESSION_SECRET
|
||||
if not os.environ.get("LOGAR_SESSION_SECRET") and "session_secret" in config:
|
||||
@@ -853,7 +874,8 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
||||
"min_persistence_runs": RUN_THRESHOLD,
|
||||
"server_fingerprint": fingerprint,
|
||||
"public_key": public_key,
|
||||
"private_key": private_key
|
||||
"private_key": private_key,
|
||||
"config_file_path": os.path.abspath(config_path)
|
||||
}
|
||||
if not os.environ.get("LOGAR_SESSION_SECRET"):
|
||||
SESSION_SECRET = session_sec
|
||||
@@ -1166,11 +1188,13 @@ 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:
|
||||
def get_active_licensed_features(db_path: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Checks whether a specific enterprise capability (e.g. 'enterprise_db') is unlocked
|
||||
either via custom X.509 certificate extensions or the persistent license configuration.
|
||||
Returns the list of all active enterprise capability identifiers
|
||||
authorized either via X.509 certificate extensions or the persistent database license config.
|
||||
"""
|
||||
features = set()
|
||||
|
||||
# 1. Check in-memory / active Subordinate CA certificate
|
||||
ca_pem = SERVER_STATE.get("ca_cert_pem")
|
||||
if not ca_pem:
|
||||
@@ -1186,9 +1210,9 @@ def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool:
|
||||
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
|
||||
for feat in info.get("features", []):
|
||||
if feat:
|
||||
features.add(str(feat).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1208,14 +1232,24 @@ def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool:
|
||||
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
|
||||
for f_item in feat_list:
|
||||
if f_item:
|
||||
features.add(str(f_item).strip())
|
||||
elif isinstance(feat_raw, list):
|
||||
for f_item in feat_raw:
|
||||
if f_item:
|
||||
features.add(str(f_item).strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
return sorted(list(features))
|
||||
|
||||
|
||||
def is_feature_licensed(feature: str, db_path: Optional[str] = None) -> bool:
|
||||
"""Checks whether a specific enterprise capability (e.g. 'enterprise_db') is authorized."""
|
||||
if not feature:
|
||||
return False
|
||||
return feature in get_active_licensed_features(db_path)
|
||||
|
||||
|
||||
def get_server_public_key() -> Optional[pgpy.PGPKey]:
|
||||
@@ -1239,14 +1273,13 @@ def get_server_public_key() -> Optional[pgpy.PGPKey]:
|
||||
|
||||
|
||||
def encrypt_field_for_storage(text: str, force: bool = False) -> str:
|
||||
"""
|
||||
Encrypts a text payload using the server's OpenPGP public key if encrypt_at_rest is enabled.
|
||||
Guarantees zero-knowledge database hosting on external database servers.
|
||||
"""
|
||||
"""Encrypts a text payload using the server's OpenPGP public key if encrypt_at_rest is enabled."""
|
||||
if not text or not isinstance(text, str):
|
||||
return text
|
||||
config = SERVER_STATE.get("config", {})
|
||||
should_encrypt = force or config.get("encrypt_at_rest", False)
|
||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||
is_unlocked = is_feature_licensed("enterprise_db", db_path)
|
||||
should_encrypt = force or (is_unlocked and bool(config.get("encrypt_at_rest", False)))
|
||||
if not should_encrypt:
|
||||
return text
|
||||
|
||||
@@ -2842,7 +2875,8 @@ def get_license_summary() -> Dict[str, Any]:
|
||||
"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")
|
||||
"server_fingerprint": config.get("server_fingerprint", "N/A"),
|
||||
"features": get_active_licensed_features(db_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -3053,7 +3087,9 @@ def api_db_flush_buffer():
|
||||
@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"):
|
||||
config = SERVER_STATE.get("config", {})
|
||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||
if not is_feature_licensed("enterprise_db", db_path):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Enterprise Database feature is locked. This capability requires a valid license with the 'enterprise_db' feature."
|
||||
@@ -3067,12 +3103,13 @@ def api_db_test_connection(req: DbConnectionTestRequest):
|
||||
@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"):
|
||||
config = SERVER_STATE.get("config", {})
|
||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||
if not is_feature_licensed("enterprise_db", db_path):
|
||||
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",
|
||||
@@ -3092,8 +3129,8 @@ def api_db_switch_backend(req: DbSwitchBackendRequest):
|
||||
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):
|
||||
cfg_path = config.get("config_file_path")
|
||||
if cfg_path and os.path.exists(cfg_path):
|
||||
try:
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
@@ -3110,13 +3147,30 @@ def api_db_switch_backend(req: DbSwitchBackendRequest):
|
||||
@app.post("/api/db/config", dependencies=[Depends(require_admin_auth)])
|
||||
def api_db_update_config(req: DbFullConfigRequest):
|
||||
"""Updates complete database storage engine, cache mode, PostgreSQL parameters, and encryption at rest."""
|
||||
if req.storage_engine == "postgres" and not is_feature_licensed("enterprise_db"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="External PostgreSQL storage is locked. This capability requires the 'enterprise_db' license feature."
|
||||
)
|
||||
|
||||
config = SERVER_STATE.get("config", {})
|
||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||
is_unlocked = is_feature_licensed("enterprise_db", db_path)
|
||||
|
||||
if not is_unlocked:
|
||||
if req.storage_engine == "postgres":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="External PostgreSQL storage is locked. This capability requires the 'enterprise_db' license feature."
|
||||
)
|
||||
if req.cache_mode == "buffered":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="In-memory ingestion buffering is locked. This capability requires the 'enterprise_db' license feature."
|
||||
)
|
||||
if req.encrypt_at_rest:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Data-at-rest OpenPGP encryption is locked. This capability requires the 'enterprise_db' license feature."
|
||||
)
|
||||
req.storage_engine = "sqlite"
|
||||
req.cache_mode = "direct"
|
||||
req.encrypt_at_rest = False
|
||||
|
||||
config["storage_engine"] = req.storage_engine
|
||||
config["db_type"] = req.storage_engine
|
||||
config["cache_mode"] = req.cache_mode
|
||||
@@ -3129,7 +3183,7 @@ def api_db_update_config(req: DbFullConfigRequest):
|
||||
config["batch_size"] = INGEST_QUEUE.batch_size
|
||||
config["max_queue"] = INGEST_QUEUE.max_queue
|
||||
|
||||
if req.pg_config:
|
||||
if req.pg_config and is_unlocked:
|
||||
pg_dict = req.pg_config.model_dump()
|
||||
config["postgres"] = pg_dict
|
||||
if req.storage_engine == "postgres":
|
||||
@@ -3137,8 +3191,8 @@ def api_db_update_config(req: DbFullConfigRequest):
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"PostgreSQL connection/init failed: {msg}")
|
||||
|
||||
cfg_path = config.get("config_file_path", CONFIG_FILE_NAME)
|
||||
if os.path.exists(cfg_path):
|
||||
cfg_path = config.get("config_file_path")
|
||||
if cfg_path and os.path.exists(cfg_path):
|
||||
try:
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
@@ -3155,12 +3209,13 @@ def api_db_update_config(req: DbFullConfigRequest):
|
||||
@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"):
|
||||
config = SERVER_STATE.get("config", {})
|
||||
db_path = config.get("db_path", DEFAULT_DB_FILE)
|
||||
if not is_feature_licensed("enterprise_db", db_path):
|
||||
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)
|
||||
@@ -3197,8 +3252,6 @@ def serve_webui(request: Request = None):
|
||||
queue_stats = INGEST_QUEUE.get_stats()
|
||||
storage_info = get_storage_engine_info()
|
||||
pg_config = config.get("postgres", {})
|
||||
db_tab_btn_badge = '<span class="badge badge-active" style="margin-left:4px; font-size:10px;">🟢 UNLOCKED</span>' if is_db_unlocked else '<span class="badge" style="background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.3); margin-left:4px; font-size:10px;">🔒 ENTERPRISE</span>'
|
||||
|
||||
|
||||
# Fetch clients
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -3343,70 +3396,191 @@ def serve_webui(request: Request = None):
|
||||
if detected_host in ("0.0.0.0", "localhost", ""):
|
||||
detected_host = socket.gethostname()
|
||||
|
||||
is_encrypt_at_rest = config.get("encrypt_at_rest", False)
|
||||
is_encrypt_at_rest = config.get("encrypt_at_rest", False) and is_db_unlocked
|
||||
encrypt_status_label = "🔒 OpenPGP Encrypted" if is_encrypt_at_rest else "🔓 Plaintext Storage"
|
||||
encrypt_status_color = "#34d399" if is_encrypt_at_rest else "#94a3b8"
|
||||
|
||||
if is_db_unlocked:
|
||||
db_tab_pane_html = f"""
|
||||
<!-- Tab 5: Database Engine -->
|
||||
<div class="tab-pane" id="tabPane-db">
|
||||
<!-- Ingestion Buffer Live Stats -->
|
||||
<div class="card" style="border-color: rgba(0, 229, 255, 0.3);">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title" style="color: #00E5FF;">💾 Storage Engine & Live Telemetry Metrics</span>
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Centralized high-availability SQL pooling, buffer telemetry, and encryption at rest</div>
|
||||
# Grayed-out styling & attributes for unlicensed enterprise features
|
||||
ent_badge = '<span class="badge" style="background:rgba(239,68,68,0.15); color:#f87171; border:1px solid rgba(239,68,68,0.3); font-size:10px; margin-left:6px;">ENTERPRISE LICENSE REQUIRED</span>'
|
||||
buf_disabled_attr = "" if is_db_unlocked else "disabled"
|
||||
buf_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;"
|
||||
enc_disabled_attr = "" if is_db_unlocked else "disabled"
|
||||
enc_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;"
|
||||
pg_disabled_attr = "" if is_db_unlocked else "disabled"
|
||||
pg_label_style = "" if is_db_unlocked else "opacity: 0.55; cursor: not-allowed;"
|
||||
|
||||
active_features = summary.get("features", [])
|
||||
feature_cards = []
|
||||
if active_features:
|
||||
for feat in active_features:
|
||||
if feat == "enterprise_db":
|
||||
feature_cards.append("""
|
||||
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.35); display: flex; align-items: flex-start; gap: 14px;">
|
||||
<div style="font-size: 22px; line-height: 1;">🐘</div>
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 3px;">
|
||||
<strong style="color: #34d399; font-size: 14px;">enterprise_db</strong>
|
||||
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.4;">
|
||||
<strong>Enterprise Database & High-Throughput Engine:</strong> Unlocks external PostgreSQL storage clustering, customizable in-memory RAM buffering with batch flushing, and OpenPGP zero-knowledge data-at-rest encryption.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge badge-active">{storage_info['engine'].upper()} BACKEND</span>
|
||||
""")
|
||||
else:
|
||||
esc_feat = html.escape(str(feat))
|
||||
feat_title = html.escape(str(feat).replace("_", " ").title())
|
||||
feature_cards.append(f"""
|
||||
<div style="background: #0f172a; padding: 14px 18px; border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.35); display: flex; align-items: flex-start; gap: 14px;">
|
||||
<div style="font-size: 22px; line-height: 1;">✨</div>
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 3px;">
|
||||
<strong style="color: #34d399; font-size: 14px;">{esc_feat}</strong>
|
||||
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.4;">
|
||||
<strong>{feat_title}:</strong> Advanced capability authorized by your license certificate.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
""")
|
||||
else:
|
||||
feature_cards.append("""
|
||||
<div style="background: #0f172a; padding: 16px 20px; border-radius: 8px; border: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||||
<div>
|
||||
<div style="font-weight: 700; color: #94a3b8; font-size: 14px; margin-bottom: 2px;">No Enterprise Capabilities Active</div>
|
||||
<div style="font-size: 12px; color: var(--text-muted);">Standard Community Edition is running. External PostgreSQL pooling, RAM queue buffering, and zero-knowledge encryption require an <code>enterprise_db</code> capability package.</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-top: 14px;">
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Active Storage Backend</div>
|
||||
<div style="font-weight: 700; color: #38bdf8; margin-top: 4px; font-size: 15px;">{storage_info['backend_display']}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">{storage_info['status']}</div>
|
||||
<button onclick="openImportLicModal()" class="btn btn-accent" style="padding: 6px 12px; font-size: 11px;">📥 Import Enterprise .lic</button>
|
||||
</div>
|
||||
""")
|
||||
|
||||
features_block_html = "".join(feature_cards)
|
||||
|
||||
ent_community_notice = "" if is_db_unlocked else f"""
|
||||
<div style="margin-bottom: 20px; background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.25); border-radius: 8px; padding: 12px 16px; font-size: 13px; color: #94a3b8; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;">
|
||||
<div>
|
||||
<strong style="color: #60a5fa;">💡 Standard Local Storage:</strong> Embedded SQLite 3 storage with synchronous logging is active. High-throughput in-memory buffering, zero-knowledge OpenPGP at-rest encryption, and external PostgreSQL clustering require an <code>enterprise_db</code> license capability.
|
||||
</div>
|
||||
<button onclick="switchTab('pki')" class="btn btn-primary" style="padding: 4px 10px; font-size: 11px;">📜 Manage License</button>
|
||||
</div>
|
||||
"""
|
||||
|
||||
db_tab_pane_html = f"""
|
||||
<!-- Tab 5: Database Engine -->
|
||||
<div class="tab-pane" id="tabPane-db">
|
||||
<!-- Ingestion Buffer Live Stats -->
|
||||
<div class="card" style="border-color: rgba(0, 229, 255, 0.3);">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title" style="color: #00E5FF;">💾 Storage Engine & Live Telemetry Metrics</span>
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Centralized high-availability SQL pooling, buffer telemetry, and encryption at rest</div>
|
||||
</div>
|
||||
<span class="badge badge-active">{storage_info['engine'].upper()} BACKEND</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-top: 14px;">
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Active Storage Backend</div>
|
||||
<div style="font-weight: 700; color: #38bdf8; margin-top: 4px; font-size: 15px;">{storage_info['backend_display']}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">{storage_info['status']}</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Telemetry Records</div>
|
||||
<div class="kpi-value" style="color: #34d399; margin-top: 4px; font-size: 18px;">{storage_info['table_stats']['active_issues']} <span style="font-size: 12px; color: var(--text-muted);">Issues</span> / {storage_info['table_stats']['ingest_runs']} <span style="font-size: 12px; color: var(--text-muted);">Runs</span></div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Clients: {storage_info['table_stats']['clients']} | Users: {storage_info['table_stats']['users']}</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Buffered Events in RAM</div>
|
||||
<div class="kpi-value" id="liveQueueSize" style="color: #c084fc; margin-top: 4px; font-size: 18px;">{queue_stats['queue_size']} <span style="font-size: 12px; color: var(--text-muted);">events</span></div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Ingested: {queue_stats['total_ingested']} | Flushed: {queue_stats['total_flushed']}</div>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Data-at-Rest Security</div>
|
||||
<div style="font-weight: 700; color: {encrypt_status_color}; margin-top: 4px; font-size: 15px;">{encrypt_status_label}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">{'Zero-Knowledge Storage on External DB' if is_encrypt_at_rest else 'Local Encrypted/Plaintext Storage'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Engine & SQL Configurator Form -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Database Storage Engine Configuration</span>
|
||||
<span class="badge badge-os">CONFIG & TUNING</span>
|
||||
</div>
|
||||
|
||||
{ent_community_notice}
|
||||
|
||||
<!-- Telemetry Ingestion Cache Mode & Buffer Tuning (Top) -->
|
||||
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="display: flex; align-items: center; gap: 8px;">⚡ Ingestion Cache & Buffer Mode</span>
|
||||
<button type="button" onclick="flushIngestBuffer()" class="btn btn-accent" style="padding: 4px 10px; font-size: 11px; {'' if is_db_unlocked else 'opacity: 0.5; pointer-events: none;'}" {'' if is_db_unlocked else 'disabled'}>🌊 Flush Buffer Now</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="cacheModeRadio" id="cacheModeDirect" value="direct" {'checked' if (config.get('cache_mode', 'direct') == 'direct' or not is_db_unlocked) else ''} onchange="toggleCacheModeParams()">
|
||||
<span>⚡ <strong>Immediate Direct Write</strong> <span style="color: var(--text-muted); font-size: 12px;">(Low Latency / Synchronous Verification)</span></span>
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px; {buf_label_style}">
|
||||
<input type="radio" name="cacheModeRadio" id="cacheModeBuffered" value="buffered" {'checked' if (config.get('cache_mode', 'direct') == 'buffered' and is_db_unlocked) else ''} onchange="toggleCacheModeParams()" {buf_disabled_attr}>
|
||||
<span>🚀 <strong>In-Memory Queue Buffer</strong> <span style="color: var(--text-muted); font-size: 12px;">(High-Throughput Batch Flushing)</span> {'' if is_db_unlocked else ent_badge}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Telemetry Records</div>
|
||||
<div class="kpi-value" style="color: #34d399; margin-top: 4px; font-size: 18px;">{storage_info['table_stats']['active_issues']} <span style="font-size: 12px; color: var(--text-muted);">Issues</span> / {storage_info['table_stats']['ingest_runs']} <span style="font-size: 12px; color: var(--text-muted);">Runs</span></div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Clients: {storage_info['table_stats']['clients']} | Users: {storage_info['table_stats']['users']}</div>
|
||||
</div>
|
||||
|
||||
<div id="bufferParamsSection" style="display: {'grid' if (config.get('cache_mode', 'direct') == 'buffered' and is_db_unlocked) else 'none'}; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; padding-top: 10px; border-top: 1px solid var(--border);">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Flush Interval (seconds)</label>
|
||||
<input type="number" id="bufferFlushInterval" step="0.5" class="form-control" value="{config.get('flush_interval', queue_stats.get('flush_interval_seconds', 2.0))}" placeholder="2.0" {buf_disabled_attr}>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Buffered Events in RAM</div>
|
||||
<div class="kpi-value" id="liveQueueSize" style="color: #c084fc; margin-top: 4px; font-size: 18px;">{queue_stats['queue_size']} <span style="font-size: 12px; color: var(--text-muted);">events</span></div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Ingested: {queue_stats['total_ingested']} | Flushed: {queue_stats['total_flushed']}</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Batch Size Threshold</label>
|
||||
<input type="number" id="bufferBatchSize" class="form-control" value="{config.get('batch_size', queue_stats.get('batch_size_threshold', 100))}" placeholder="100" {buf_disabled_attr}>
|
||||
</div>
|
||||
<div style="background: #0f172a; padding: 14px; border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div class="kpi-sub">Data-at-Rest Security</div>
|
||||
<div style="font-weight: 700; color: {encrypt_status_color}; margin-top: 4px; font-size: 15px;">{encrypt_status_label}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">Zero-Knowledge Storage on External DB</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Max Buffer Capacity</label>
|
||||
<input type="number" id="bufferMaxQueue" class="form-control" value="{config.get('max_queue', 50000)}" placeholder="50000" {buf_disabled_attr}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Engine & SQL Configurator Form -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">Database Storage Engine Configuration</span>
|
||||
<span class="badge badge-os">STORAGE SELECTION</span>
|
||||
<!-- Zero-Knowledge OpenPGP Data Encryption at Rest (Middle) -->
|
||||
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px;">
|
||||
<span>🔐</span> Zero-Knowledge Data Encryption at Rest
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label class="form-label" style="font-size: 14px; font-weight: 700; margin-bottom: 8px;">Select Storage Engine Backend:</label>
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 18px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="dbEngineRadio" id="engineRadioSqlite" value="sqlite" {'checked' if storage_info['engine'] == 'sqlite' else ''} onchange="toggleStorageEngineForm()">
|
||||
<span>💾 <strong>Local SQLite 3 (WAL Mode)</strong> <span style="color: var(--text-muted); font-size: 12px;">(Embedded, Zero Configuration)</span></span>
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 18px; background: #0f172a; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="dbEngineRadio" id="engineRadioPostgres" value="postgres" {'checked' if storage_info['engine'] == 'postgres' else ''} onchange="toggleStorageEngineForm()">
|
||||
<span>🐘 <strong>External PostgreSQL Database Server</strong> <span style="color: #34d399; font-size: 12px; font-weight: 600;">(Enterprise Clustering)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 10px; margin-bottom: 8px; {enc_label_style}">
|
||||
<input type="checkbox" id="encryptAtRestCheck" {'checked' if (config.get('encrypt_at_rest', False) and is_db_unlocked) else ''} style="width: 18px; height: 18px; accent-color: var(--accent);" {enc_disabled_attr}>
|
||||
<span style="font-weight: 600; font-size: 14px;">Encrypt Sensitive Telemetry & Log Messages with Server OpenPGP Key</span>
|
||||
{'' if is_db_unlocked else ent_badge}
|
||||
</label>
|
||||
<p style="font-size: 12px; color: var(--text-muted); line-height: 1.5; margin-left: 28px;">
|
||||
When enabled, anomaly message payloads stored in the external database are encrypted with the server's OpenPGP public key before writing. Only this hub instance can decrypt records for authorized WebUI and Hermes AI usage, ensuring zero-knowledge privacy on remote database servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Select Storage Engine Backend (Bottom) -->
|
||||
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;">
|
||||
<span>🗄️</span> Select Storage Engine Backend
|
||||
</div>
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 18px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="dbEngineRadio" id="engineRadioSqlite" value="sqlite" {'checked' if (storage_info['engine'] == 'sqlite' or not is_db_unlocked) else ''} onchange="toggleStorageEngineForm()">
|
||||
<span>💾 <strong>Local SQLite 3 (WAL Mode)</strong> <span style="color: var(--text-muted); font-size: 12px;">(Embedded, Zero Configuration)</span></span>
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; padding: 12px 18px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px; {pg_label_style}">
|
||||
<input type="radio" name="dbEngineRadio" id="engineRadioPostgres" value="postgres" {'checked' if (storage_info['engine'] == 'postgres' and is_db_unlocked) else ''} onchange="toggleStorageEngineForm()" {pg_disabled_attr}>
|
||||
<span>🐘 <strong>External PostgreSQL Database Server</strong> <span style="color: #34d399; font-size: 12px; font-weight: 600;">(Enterprise Clustering)</span> {'' if is_db_unlocked else ent_badge}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- External PostgreSQL Parameters -->
|
||||
<div id="pgConfigSection" style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px; display: {'block' if storage_info['engine'] == 'postgres' else 'none'};">
|
||||
<div id="pgConfigSection" style="background: #151c2e; padding: 20px; border-radius: 10px; border: 1px solid var(--border); display: {'block' if (storage_info['engine'] == 'postgres' and is_db_unlocked) else 'none'};">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="display: flex; align-items: center; gap: 8px;">🐘 External PostgreSQL Connection Parameters</span>
|
||||
<span class="badge badge-active">ENTERPRISE POOL</span>
|
||||
@@ -3414,27 +3588,27 @@ def serve_webui(request: Request = None):
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 16px;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Host / IP Address</label>
|
||||
<input type="text" id="pgHost" class="form-control" value="{pg_config.get('host', 'localhost')}" placeholder="localhost">
|
||||
<input type="text" id="pgHost" class="form-control" value="{pg_config.get('host', 'localhost')}" placeholder="localhost" {pg_disabled_attr}>
|
||||
</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">
|
||||
<input type="number" id="pgPort" class="form-control" value="{pg_config.get('port', 5432)}" placeholder="5432" {pg_disabled_attr}>
|
||||
</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">
|
||||
<input type="text" id="pgDatabase" class="form-control" value="{pg_config.get('database', 'logar')}" placeholder="logar" {pg_disabled_attr}>
|
||||
</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">
|
||||
<input type="text" id="pgUser" class="form-control" value="{pg_config.get('user', 'postgres')}" placeholder="postgres" {pg_disabled_attr}>
|
||||
</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="••••••••">
|
||||
<input type="password" id="pgPassword" class="form-control" value="{pg_config.get('password', '')}" placeholder="••••••••" {pg_disabled_attr}>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">SSL Mode</label>
|
||||
<select id="pgSslMode" class="form-control">
|
||||
<select id="pgSslMode" class="form-control" {pg_disabled_attr}>
|
||||
<option value="prefer" {'selected' if pg_config.get('sslmode') == 'prefer' else ''}>prefer</option>
|
||||
<option value="require" {'selected' if pg_config.get('sslmode') == 'require' else ''}>require</option>
|
||||
<option value="verify-ca" {'selected' if pg_config.get('sslmode') == 'verify-ca' else ''}>verify-ca</option>
|
||||
@@ -3445,100 +3619,29 @@ def serve_webui(request: Request = None):
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Connect Timeout (sec)</label>
|
||||
<input type="number" id="pgConnectTimeout" class="form-control" value="{pg_config.get('connect_timeout', 5)}" placeholder="5">
|
||||
<input type="number" id="pgConnectTimeout" class="form-control" value="{pg_config.get('connect_timeout', 5)}" placeholder="5" {pg_disabled_attr}>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Connection Pool Size</label>
|
||||
<input type="number" id="pgPoolSize" class="form-control" value="{pg_config.get('pool_size', 10)}" placeholder="10">
|
||||
<input type="number" id="pgPoolSize" class="form-control" value="{pg_config.get('pool_size', 10)}" placeholder="10" {pg_disabled_attr}>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="button" onclick="testPgConnection()" class="btn btn-secondary">🔍 Test Connection</button>
|
||||
<button type="button" onclick="migrateToPostgres()" class="btn btn-accent">🚀 Migrate SQLite Data to PostgreSQL</button>
|
||||
<button type="button" onclick="testPgConnection()" class="btn btn-secondary" {pg_disabled_attr}>🔍 Test Connection</button>
|
||||
<button type="button" onclick="migrateToPostgres()" class="btn btn-accent" {pg_disabled_attr}>🚀 Migrate SQLite Data to PostgreSQL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zero-Knowledge OpenPGP Data Encryption at Rest -->
|
||||
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 8px; display: flex; align-items: center; gap: 8px;">
|
||||
<span>🔐</span> Zero-Knowledge Data Encryption at Rest
|
||||
</div>
|
||||
<label style="display: inline-flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="checkbox" id="encryptAtRestCheck" {'checked' if config.get('encrypt_at_rest', False) else ''} style="width: 18px; height: 18px; accent-color: var(--accent);">
|
||||
<span style="font-weight: 600; font-size: 14px;">Encrypt Sensitive Telemetry & Log Messages with Server OpenPGP Key</span>
|
||||
</label>
|
||||
<p style="font-size: 12px; color: var(--text-muted); line-height: 1.5; margin-left: 28px;">
|
||||
When enabled, anomaly message payloads stored in the external database are encrypted with the server's OpenPGP public key before writing. Only this hub instance can decrypt records for authorized WebUI and Hermes AI usage, ensuring zero-knowledge privacy on remote database servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry Ingestion Cache Mode & Buffer Tuning -->
|
||||
<div style="background: #0f172a; padding: 20px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 20px;">
|
||||
<div style="font-size: 14px; font-weight: 700; margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<span style="display: flex; align-items: center; gap: 8px;">⚡ Ingestion Cache & Buffer Mode</span>
|
||||
<button type="button" onclick="flushIngestBuffer()" class="btn btn-accent" style="padding: 4px 10px; font-size: 11px;">🌊 Flush Buffer Now</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="cacheModeRadio" id="cacheModeDirect" value="direct" {'checked' if config.get('cache_mode', 'direct') == 'direct' else ''} onchange="toggleCacheModeParams()">
|
||||
<span>⚡ <strong>Immediate Direct Write</strong> <span style="color: var(--text-muted); font-size: 12px;">(Low Latency / Synchronous Verification)</span></span>
|
||||
</label>
|
||||
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 14px; background: #151c2e; border: 1px solid var(--border); border-radius: 8px;">
|
||||
<input type="radio" name="cacheModeRadio" id="cacheModeBuffered" value="buffered" {'checked' if config.get('cache_mode', 'direct') == 'buffered' else ''} onchange="toggleCacheModeParams()">
|
||||
<span>🚀 <strong>In-Memory Queue Buffer</strong> <span style="color: var(--text-muted); font-size: 12px;">(High-Throughput Batch Flushing)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bufferParamsSection" style="display: {'grid' if config.get('cache_mode', 'direct') == 'buffered' else 'none'}; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; padding-top: 10px; border-top: 1px solid var(--border);">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Flush Interval (seconds)</label>
|
||||
<input type="number" id="bufferFlushInterval" step="0.5" class="form-control" value="{config.get('flush_interval', queue_stats.get('flush_interval_seconds', 2.0))}" placeholder="2.0">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Batch Size Threshold</label>
|
||||
<input type="number" id="bufferBatchSize" class="form-control" value="{config.get('batch_size', queue_stats.get('batch_size_threshold', 100))}" placeholder="100">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">Max Buffer Capacity</label>
|
||||
<input type="number" id="bufferMaxQueue" class="form-control" value="{config.get('max_queue', 50000)}" placeholder="50000">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Save Action Bar -->
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px;">
|
||||
<button type="button" onclick="saveDbConfig()" class="btn btn-primary" style="padding: 10px 24px; font-size: 14px;">
|
||||
💾 Save Database & Cache Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
db_tab_pane_html = f"""
|
||||
<!-- Tab 5: Database Engine (Locked State) -->
|
||||
<div class="tab-pane" id="tabPane-db">
|
||||
<div class="card" style="text-align: center; padding: 48px 24px; border-color: rgba(239, 68, 68, 0.3);">
|
||||
<div style="font-size: 52px; margin-bottom: 16px;">🔒</div>
|
||||
<h2 style="font-size: 22px; font-weight: 800; margin-bottom: 8px;">Enterprise Database & High-Throughput Engine is Locked</h2>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<span class="badge" style="background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.4); padding: 6px 14px; font-size: 12px;">
|
||||
REQUIRES 'enterprise_db' LICENSE FEATURE
|
||||
</span>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: var(--text-muted); max-width: 640px; margin: 0 auto 24px;">
|
||||
Direct PostgreSQL storage backend clustering, automatic schema synchronization, and customizable high-throughput batch-flush controls are gated behind the <strong>enterprise_db</strong> capability.
|
||||
</p>
|
||||
<div style="display: flex; justify-content: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button onclick="switchTab('pki')" class="btn btn-primary">📜 Go to Sub-CA & Licensing</button>
|
||||
<button onclick="openImportLicModal()" class="btn btn-accent">📥 Import Enterprise .lic Package</button>
|
||||
</div>
|
||||
|
||||
<!-- Global Save Action Bar -->
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px;">
|
||||
<button type="button" onclick="saveDbConfig()" class="btn btn-primary" style="padding: 10px 24px; font-size: 14px;">
|
||||
💾 Save Database & Cache Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
</div>
|
||||
"""
|
||||
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -3766,11 +3869,11 @@ def serve_webui(request: Request = None):
|
||||
|
||||
<!-- 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 active" id="tabBtn-fleet" onclick="switchTab('fleet')">💻 Fleet Forwarders</button>
|
||||
<button class="tab-btn" id="tabBtn-hermes" onclick="switchTab('hermes')">🤖 Hermes AI & Tokens</button>
|
||||
<button class="tab-btn" id="tabBtn-anomalies" onclick="switchTab('anomalies')">📊 Verified Telemetry</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>
|
||||
<button class="tab-btn" id="tabBtn-db" onclick="switchTab('db')">💾 DB Engine</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Summary Cards -->
|
||||
@@ -3957,6 +4060,21 @@ def serve_webui(request: Request = None):
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Licensed Capabilities & Feature Modules -->
|
||||
<div class="card" style="margin-top: 20px;">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">✨ Licensed Capabilities & Feature Modules</span>
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">Features authorized by your subordinate CA license package and upstream authority</div>
|
||||
</div>
|
||||
<span class="badge {'badge-active' if active_features else 'badge-os'}">{len(active_features)} ACTIVE FEATURES</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; margin-top: 6px;">
|
||||
{features_block_html}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{db_tab_pane_html}
|
||||
@@ -4855,11 +4973,11 @@ def main():
|
||||
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" LOGAR Server Hub: {config.get('server_name', 'LOGAR Enterprise Hub')}")
|
||||
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" Server Encryption Fingerprint: {config.get('server_fingerprint', 'N/A')}")
|
||||
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)
|
||||
|
||||
+152
-13
@@ -710,7 +710,53 @@ class TestServerComponent(unittest.TestCase):
|
||||
decrypted_msg = Server.decrypt_field_from_storage(encrypted_msg)
|
||||
self.assertEqual(decrypted_msg, raw_msg)
|
||||
|
||||
# 2. Test updating config via api_db_update_config (direct -> buffered, encrypt_at_rest = True)
|
||||
# 2. Test that without enterprise_db license, buffering, encryption at rest, and postgres are blocked
|
||||
cfg_unlicensed_buf = Server.DbFullConfigRequest(
|
||||
storage_engine="sqlite",
|
||||
cache_mode="buffered",
|
||||
encrypt_at_rest=False
|
||||
)
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_update_config(cfg_unlicensed_buf)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
cfg_unlicensed_enc = Server.DbFullConfigRequest(
|
||||
storage_engine="sqlite",
|
||||
cache_mode="direct",
|
||||
encrypt_at_rest=True
|
||||
)
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_update_config(cfg_unlicensed_enc)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
pg_req = Server.DbFullConfigRequest(
|
||||
storage_engine="postgres",
|
||||
cache_mode="direct",
|
||||
pg_config=Server.DbConnectionTestRequest(host="db.corp.internal", port=5432, database="logar", user="postgres", password="")
|
||||
)
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_update_config(pg_req)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
# 3. Standard local SQLite configuration succeeds when unlicensed
|
||||
cfg_standard = Server.DbFullConfigRequest(
|
||||
storage_engine="sqlite",
|
||||
cache_mode="direct",
|
||||
encrypt_at_rest=False
|
||||
)
|
||||
res_std = Server.api_db_update_config(cfg_standard)
|
||||
self.assertEqual(res_std["status"], "success")
|
||||
|
||||
# 4. Unlock enterprise_db feature in license_config
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
conn.execute("UPDATE license_config SET features = ? WHERE id = 1", (json.dumps(["enterprise_db"]),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 5. Test updating config with enterprise_db unlocked (direct -> buffered, encrypt_at_rest = True)
|
||||
cfg_req = Server.DbFullConfigRequest(
|
||||
storage_engine="sqlite",
|
||||
cache_mode="buffered",
|
||||
@@ -726,23 +772,116 @@ class TestServerComponent(unittest.TestCase):
|
||||
self.assertEqual(Server.INGEST_QUEUE.flush_interval, 4.0)
|
||||
self.assertEqual(Server.INGEST_QUEUE.batch_size, 200)
|
||||
|
||||
# 3. Test that switching to external PostgreSQL requires enterprise_db license
|
||||
pg_req = Server.DbFullConfigRequest(
|
||||
storage_engine="postgres",
|
||||
cache_mode="direct",
|
||||
pg_config=Server.DbConnectionTestRequest(host="db.corp.internal", port=5432, database="logar", user="postgres", password="")
|
||||
)
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_update_config(pg_req)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
# 4. Verify storage engine info output
|
||||
# 6. Verify storage engine info output
|
||||
info = Server.get_storage_engine_info(db_path=self.test_db)
|
||||
self.assertEqual(info["cache_mode"], "buffered")
|
||||
self.assertTrue(info["encrypt_at_rest"])
|
||||
self.assertEqual(info["flush_interval"], 4.0)
|
||||
|
||||
def test_active_licensed_features_helper(self):
|
||||
"""Tests helper function to extract active licensed capabilities from DB and certificates."""
|
||||
Server.init_db(self.test_db)
|
||||
Server.SERVER_STATE["ca_cert_pem"] = ""
|
||||
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": "non_existent_test_certs"}
|
||||
|
||||
# 1. Unlicensed state
|
||||
features_unlicensed = Server.get_active_licensed_features(db_path=self.test_db)
|
||||
self.assertEqual(features_unlicensed, [])
|
||||
self.assertFalse(Server.is_feature_licensed("enterprise_db", db_path=self.test_db))
|
||||
|
||||
# 2. Add features via JSON string 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()
|
||||
|
||||
features_licensed = Server.get_active_licensed_features(db_path=self.test_db)
|
||||
self.assertEqual(features_licensed, ["ai_heuristics", "enterprise_db"])
|
||||
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("non_existent_feature", db_path=self.test_db))
|
||||
|
||||
# 3. Verify get_license_summary includes features list
|
||||
summary = Server.get_license_summary()
|
||||
self.assertIn("features", summary)
|
||||
self.assertIn("enterprise_db", summary["features"])
|
||||
|
||||
def test_webui_tab_rendering_and_feature_cards(self):
|
||||
"""Tests WebUI HTML generation for clean tab buttons and feature cards in PKI tab."""
|
||||
Server.init_db(self.test_db)
|
||||
Server.SERVER_STATE["config"] = {
|
||||
"db_path": self.test_db,
|
||||
"cert_dir": "certs",
|
||||
"tcp_port": 9443,
|
||||
"hermes_port": 8443,
|
||||
"server_name": "Test-Hub"
|
||||
}
|
||||
|
||||
# 1. Render WebUI in standard / unlicensed mode
|
||||
resp = Server.serve_webui(request=None)
|
||||
html_content = resp.body.decode("utf-8")
|
||||
|
||||
# Verify clean tab buttons without number suffixes
|
||||
self.assertIn('>💻 Fleet Forwarders</button>', html_content)
|
||||
self.assertIn('>🤖 Hermes AI & Tokens</button>', html_content)
|
||||
self.assertIn('>📊 Verified Telemetry</button>', html_content)
|
||||
self.assertIn('>📜 Sub-CA & Licensing</button>', html_content)
|
||||
self.assertIn('>💾 DB Engine</button>', html_content)
|
||||
self.assertNotIn('🟢 UNLOCKED', html_content)
|
||||
self.assertNotIn('Fleet Forwarders (', html_content)
|
||||
|
||||
# Verify Licensed Capabilities card is present
|
||||
self.assertIn('Licensed Capabilities & Feature Modules', html_content)
|
||||
self.assertIn('Standard Community Edition is running', html_content)
|
||||
|
||||
# 2. Render WebUI with enterprise_db unlocked
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
conn.execute("UPDATE license_config SET features = ? WHERE id = 1", (json.dumps(["enterprise_db"]),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp_ent = Server.serve_webui(request=None)
|
||||
html_ent = resp_ent.body.decode("utf-8")
|
||||
self.assertIn('enterprise_db', html_ent)
|
||||
self.assertIn('Enterprise Database & High-Throughput Engine', html_ent)
|
||||
self.assertIn('>💾 DB Engine</button>', html_ent)
|
||||
self.assertNotIn('🟢 UNLOCKED', html_ent)
|
||||
|
||||
def test_explicit_opt_in_guarantee(self):
|
||||
"""Tests that licensing does NOT automatically turn on encryption or buffering without explicit admin setting."""
|
||||
Server.init_db(self.test_db)
|
||||
priv_armored, pub_armored, fp = Server.generate_server_keypair("test-opt-in-node")
|
||||
priv_key, _ = pgpy.PGPKey.from_blob(priv_armored)
|
||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_armored)
|
||||
|
||||
Server.SERVER_STATE["config"] = {
|
||||
"db_path": self.test_db,
|
||||
"cert_dir": "certs",
|
||||
"storage_engine": "sqlite",
|
||||
"cache_mode": "direct",
|
||||
"encrypt_at_rest": False,
|
||||
"private_key": priv_armored,
|
||||
"public_key": pub_armored,
|
||||
"server_fingerprint": fp
|
||||
}
|
||||
Server.SERVER_STATE["private_key_obj"] = priv_key
|
||||
Server.SERVER_STATE["public_key_obj"] = pub_key
|
||||
|
||||
# Unlock enterprise license
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
conn.execute("UPDATE license_config SET features = ? WHERE id = 1", (json.dumps(["enterprise_db"]),))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# License is active, but config has encrypt_at_rest=False -> plaintext storage
|
||||
raw_msg = "Sample operational log event"
|
||||
result_msg = Server.encrypt_field_for_storage(raw_msg)
|
||||
self.assertEqual(result_msg, raw_msg)
|
||||
self.assertFalse(result_msg.startswith("-----BEGIN PGP MESSAGE-----"))
|
||||
|
||||
# Ingestion cache mode is direct
|
||||
self.assertEqual(Server.SERVER_STATE["config"]["cache_mode"], "direct")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user