Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edae4d797 | ||
|
|
cc25c31884 | ||
|
|
421eafb72d | ||
|
|
ca8b0153fa | ||
|
|
50f76ca3c3 | ||
|
|
ea6410d391 | ||
|
|
41d7d66a56 | ||
|
|
959f2977f0 | ||
|
|
40cdbf349c | ||
|
|
ca1b5b5d25 | ||
|
|
c1d4675321 | ||
|
|
2d33b14d60 | ||
|
|
2b0b680188 | ||
|
|
f0bf90e033 | ||
|
|
22072a0b88 | ||
|
|
730f0ba7ca | ||
|
|
b7c27ee3a1 | ||
|
|
fa662da476 | ||
|
|
6cb9631958 | ||
|
|
c03bb348b5 | ||
|
|
2d39f3c007 | ||
|
|
78e62b31d2 | ||
|
|
860e39b360 | ||
|
|
2a04244db3 | ||
|
|
9f999fc6c3 | ||
|
|
3399c6d57b | ||
|
|
c6d04f8896 | ||
|
|
0a67b43619 | ||
|
|
3234acf435 | ||
|
|
702ca985d2 | ||
|
|
97d92d1bee | ||
|
|
a8cd2cd83c | ||
|
|
6843b22ac1 | ||
|
|
bfd576bfdd | ||
|
|
9810220cb7 | ||
|
|
13ca1cacda | ||
|
|
4d607d0a9e | ||
|
|
ad80620e41 | ||
|
|
6fb631f908 | ||
|
|
65ec1b3b1b | ||
|
|
7b3b822974 | ||
|
|
5b97ccc1f2 | ||
|
|
d6e5ef7107 | ||
|
|
d689713234 | ||
|
|
61825571b1 | ||
|
|
06db6672ce | ||
|
|
a6eddc207c | ||
|
|
a70ce9162d | ||
|
|
900d03b7f7 | ||
|
|
a8d59b2f14 | ||
|
|
b79022bc41 | ||
|
|
fcba813edd | ||
|
|
72e3527c79 | ||
|
|
4a40b4d395 | ||
|
|
399cf469c5 | ||
|
|
ce7d2b97d2 | ||
|
|
2e256baf4a | ||
|
|
1a984caaf7 | ||
|
|
96756ca5db | ||
|
|
157229a84e | ||
|
|
8287af3129 | ||
|
|
a133050cb5 |
@@ -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: |
|
||||
|
||||
@@ -9,6 +9,7 @@ __pycache__/
|
||||
*$py.class
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
*.spec
|
||||
*.exe
|
||||
!compilation/nssm.exe
|
||||
@@ -16,19 +17,27 @@ dist/
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.nsh
|
||||
*.iss.tmp
|
||||
version.nsh
|
||||
version.iss
|
||||
|
||||
# Database files & state
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
client_state.json
|
||||
*.tmp
|
||||
|
||||
# Live configuration with generated private keys & tokens (samples are tracked)
|
||||
server_config.json
|
||||
client_config.json
|
||||
client_state.json
|
||||
local_collector_state.json
|
||||
certs/
|
||||
*.bak
|
||||
test_*.json
|
||||
test_*.db
|
||||
test_pipeline_master_ca/
|
||||
test_pipeline_certs/
|
||||
master_ca_certs/
|
||||
.env
|
||||
*.env
|
||||
runner/.env
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<p align="center">
|
||||
<img src="https://gitea.eibl.tech/me0nline/LOGAR/raw/branch/main/assets/logo_banner.svg" alt="LOGAR Banner" width="750"/>
|
||||
</p>
|
||||
|
||||
# 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, 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)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,8 +115,15 @@ Once installed or executed, access the web console:
|
||||
|
||||
```
|
||||
LOGAR/
|
||||
├── assets/ # Official vector SVGs, PNGs, ICOs, and branding bitmaps
|
||||
│ ├── logo.svg # Master vector emblem
|
||||
│ ├── logo_transparent.svg # Transparent WebUI emblem
|
||||
│ ├── logo_banner.svg # Full brand banner
|
||||
│ ├── logo.ico / favicon.ico # Application & web icons
|
||||
│ └── wizard_*.bmp # Installer dialog bitmaps
|
||||
├── .gitea/workflows/ # CI/CD pipelines (ci.yml, release.yml)
|
||||
├── compilation/ # Build scripts, Linux installers & Inno Setup scripts
|
||||
│ ├── generate_branding_assets.py # Asset & bitmap generator
|
||||
│ ├── install_linux_client.sh # Linux client systemd auto-installer
|
||||
│ ├── install_linux_server.sh # Linux server systemd auto-installer
|
||||
│ ├── package_dist.py # Standalone binary cross-compiler
|
||||
@@ -118,7 +133,7 @@ LOGAR/
|
||||
│ ├── server_enrollment.py # Sub-CA PKI and certificate generator
|
||||
│ ├── Win_Client.py # Windows edge forwarder
|
||||
│ └── Linux_Client.py # Linux edge forwarder
|
||||
├── tests/ # Automated test suites (35 unit tests + pipeline test)
|
||||
├── tests/ # Automated test suites (42 unit & integration tests)
|
||||
├── LOGAR-LICENSING/ # Standalone Master License Authority (Git submodule)
|
||||
├── RELEASE_NOTES.md # Version history and security changelog
|
||||
└── README.md # Repository overview
|
||||
@@ -146,7 +161,7 @@ LOGAR/
|
||||
## Testing & Cross-Compilation
|
||||
|
||||
```bash
|
||||
# Run full unit test suite (35 tests)
|
||||
# Run full unit test suite (51 tests)
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# Run end-to-end mTLS pipeline integration test
|
||||
@@ -162,9 +177,11 @@ 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)
|
||||
* [Complete REST API Specification](https://gitea.eibl.tech/me0nline/LOGAR/wiki/API-Reference)
|
||||
* [Production Deployment & Service Management](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Deployment-and-Installation)
|
||||
* [CI/CD Pipeline & Cross-Compilation Workflow](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Testing-and-Releases)
|
||||
* [Branding & Design System Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Branding-Guide)
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
# LOGAR Release v0.3.1
|
||||
# LOGAR Release Notes
|
||||
|
||||
### 🛡️ Security Fixes & Defensive Hardening
|
||||
- **Mandatory Admin Authentication**: Enforced strict administrator authentication across all fleet management endpoints (`/api/clients`, `/api/clients/approve`, `/api/clients/revoke`, `/api/clients/delete`, `/api/client/export-profile`) and license management APIs.
|
||||
- **Brute-Force Mitigation & Rate Limiting**: Implemented thread-safe sliding-window IP rate limiting on `/login` (10 attempts/min) and `/api/client/enroll` (15 attempts/min) with progressive delays and `HTTP 429` throttling.
|
||||
- **Cookie Security Flags**: Session cookies now enforce `HttpOnly=True`, `SameSite="Lax"`, and dynamic `Secure=True` for HTTPS connections and reverse-proxy deployments (`X-Forwarded-Proto`).
|
||||
- **Cryptographic Random Secrets**: Server Hub and License Authority session secrets are now generated with 256-bit CSPRNG (`secrets.token_hex(32)`) on initialization.
|
||||
- **License Renewal Validation**: Hardened `/api/license/renew` with explicit PEM structure and database validation, preventing unhandled exceptions.
|
||||
## [v0.3.5.1] - 2026-09-30
|
||||
|
||||
### ⚡ Operational Improvements & Modernization
|
||||
- **FastAPI Lifespan Handlers**: Migrated legacy startup events to modern ASGI lifespan context managers.
|
||||
- **OpenPGP Compression Preferences**: Aligned Server public key compression algorithms (`ZIP`, `ZLIB`, `BZ2`, `Uncompressed`) with client preferences.
|
||||
- **Cross-Platform Compilation**: Unified automated packaging for standalone Windows and Linux binaries.
|
||||
### 🗄️ 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.
|
||||
|
||||
### 🔐 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.
|
||||
|
||||
### 📜 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.
|
||||
|
||||
### 📖 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.
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="100%" stop-color="#070C18"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#00FFA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="bannerGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Card -->
|
||||
<rect width="960" height="240" rx="16" fill="url(#bannerBg)" stroke="#162A45" stroke-width="2"/>
|
||||
|
||||
<!-- Left Emblem Group (scaled & positioned) -->
|
||||
<g transform="translate(40, 20) scale(0.39)">
|
||||
<!-- Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="#0E1A2E" stroke="url(#cyanGrad)" stroke-width="12" filter="url(#bannerGlow)"/>
|
||||
<!-- Inner Telemetry Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="4" stroke-dasharray="10 8"/>
|
||||
<!-- Stylized L & Telemetry Bars -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
<!-- Connection Points -->
|
||||
<circle cx="238" cy="190" r="9" fill="#00FFA3"/>
|
||||
<circle cx="304" cy="130" r="9" fill="#00E5FF"/>
|
||||
<circle cx="380" cy="349" r="10" fill="#00E5FF"/>
|
||||
</g>
|
||||
|
||||
<!-- Typography & Tagline -->
|
||||
<text x="270" y="98" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="4" fill="url(#textCyan)" filter="url(#bannerGlow)">LOGAR</text>
|
||||
<text x="272" y="142" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="20" font-weight="600" fill="#E2E8F0" letter-spacing="0.5">Fleet Log Aggregation & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0072FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00E5FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00FFA3" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#070C18" stroke="#1e293b" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0E1A2E" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#94A3B8" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#EF4444" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#F59E0B" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#FFFFFF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="100%" stop-color="#070C18"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#00FFA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="bannerGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Card -->
|
||||
<rect width="960" height="240" rx="16" fill="url(#bannerBg)" stroke="#162A45" stroke-width="2"/>
|
||||
|
||||
<!-- Left Emblem Group (scaled & positioned) -->
|
||||
<g transform="translate(40, 20) scale(0.39)">
|
||||
<!-- Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="#0E1A2E" stroke="url(#cyanGrad)" stroke-width="12" filter="url(#bannerGlow)"/>
|
||||
<!-- Inner Telemetry Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="4" stroke-dasharray="10 8"/>
|
||||
<!-- Stylized L & Telemetry Bars -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
<!-- Connection Points -->
|
||||
<circle cx="238" cy="190" r="9" fill="#00FFA3"/>
|
||||
<circle cx="304" cy="130" r="9" fill="#00E5FF"/>
|
||||
<circle cx="380" cy="349" r="10" fill="#00E5FF"/>
|
||||
</g>
|
||||
|
||||
<!-- Typography & Tagline -->
|
||||
<text x="270" y="98" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="4" fill="url(#textCyan)" filter="url(#bannerGlow)">LOGAR</text>
|
||||
<text x="272" y="142" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="20" font-weight="600" fill="#E2E8F0" letter-spacing="0.5">Fleet Log Aggregation & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0072FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00E5FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00FFA3" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#070C18" stroke="#1e293b" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0E1A2E" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#94A3B8" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#EF4444" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#F59E0B" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#FFFFFF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
LOGAR Branding & Installer Graphics Generator
|
||||
Generates clean, professional SVG, PNG, ICO, and BMP visual assets for
|
||||
LOGAR installers (Inno Setup & NSIS), application icons, root /assets library, and WebUI branding.
|
||||
Zero external dependencies required (uses standard library zlib and struct).
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import zlib
|
||||
import struct
|
||||
import shutil
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ROOT_ASSETS_DIR = os.path.join(ROOT_DIR, "assets")
|
||||
COMPILATION_ASSETS_DIR = os.path.join(ROOT_DIR, "compilation", "assets")
|
||||
LICENSING_ASSETS_DIR = os.path.join(ROOT_DIR, "LOGAR-LICENSING", "assets")
|
||||
|
||||
for d in (ROOT_ASSETS_DIR, COMPILATION_ASSETS_DIR, LICENSING_ASSETS_DIR):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
def create_bmp_24bit(width: int, height: int, pixel_func) -> bytes:
|
||||
"""Generates an uncompressed 24-bit RGB BMP image."""
|
||||
row_bytes = width * 3
|
||||
padding = (4 - (row_bytes % 4)) % 4
|
||||
image_size = (row_bytes + padding) * height
|
||||
file_size = 14 + 40 + image_size
|
||||
|
||||
# BITMAPFILEHEADER (14 bytes)
|
||||
header = struct.pack(
|
||||
"<2sIHHI",
|
||||
b"BM",
|
||||
file_size,
|
||||
0, 0,
|
||||
54 # Offset to pixel data
|
||||
)
|
||||
|
||||
# BITMAPINFOHEADER (40 bytes)
|
||||
info = struct.pack(
|
||||
"<IIIHHIIIIII",
|
||||
40, # biSize
|
||||
width, # biWidth
|
||||
height, # biHeight (positive = bottom-to-top)
|
||||
1, # biPlanes
|
||||
24, # biBitCount (24-bit BGR)
|
||||
0, # biCompression (BI_RGB)
|
||||
image_size, # biSizeImage
|
||||
2835, # biXPelsPerMeter (72 DPI)
|
||||
2835, # biYPelsPerMeter
|
||||
0, # biClrUsed
|
||||
0 # biClrImportant
|
||||
)
|
||||
|
||||
data = bytearray()
|
||||
pad = b"\x00" * padding
|
||||
# BMP is stored bottom-to-top
|
||||
for y in range(height - 1, -1, -1):
|
||||
for x in range(width):
|
||||
r, g, b = pixel_func(x, y, width, height)
|
||||
data.extend(bytes([int(b) & 0xFF, int(g) & 0xFF, int(r) & 0xFF]))
|
||||
data.extend(pad)
|
||||
|
||||
return header + info + bytes(data)
|
||||
|
||||
|
||||
def create_png_rgba(width: int, height: int, pixel_func) -> bytes:
|
||||
"""Generates a standard 32-bit RGBA PNG image."""
|
||||
raw_scanlines = bytearray()
|
||||
for y in range(height):
|
||||
raw_scanlines.append(0) # Filter type 0 (None)
|
||||
for x in range(width):
|
||||
r, g, b, a = pixel_func(x, y, width, height)
|
||||
raw_scanlines.extend(bytes([int(r) & 0xFF, int(g) & 0xFF, int(b) & 0xFF, int(a) & 0xFF]))
|
||||
|
||||
compressed = zlib.compress(bytes(raw_scanlines), level=9)
|
||||
|
||||
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||
chk = tag + data
|
||||
crc = zlib.crc32(chk) & 0xFFFFFFFF
|
||||
return struct.pack(">I", len(data)) + chk + struct.pack(">I", crc)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")
|
||||
return png_bytes
|
||||
|
||||
|
||||
def draw_logo_rgba(x: int, y: int, w: int, h: int) -> tuple:
|
||||
"""Renders a sleek modern hexagon shield and pulse log streams."""
|
||||
nx = (x - w / 2) / (w / 2)
|
||||
ny = (y - h / 2) / (h / 2)
|
||||
dist = math.sqrt(nx * nx + ny * ny)
|
||||
|
||||
if dist > 0.98:
|
||||
return 0, 0, 0, 0
|
||||
|
||||
# Hexagon radius formula
|
||||
angle = math.atan2(ny, nx)
|
||||
hex_radius = 0.88 / (math.cos((angle % (math.pi / 3)) - (math.pi / 6)) + 0.001)
|
||||
|
||||
if dist > hex_radius:
|
||||
if dist < hex_radius + 0.04:
|
||||
alpha = max(0.0, min(1.0, (hex_radius + 0.04 - dist) / 0.04))
|
||||
return 11, 25, 44, int(alpha * 255)
|
||||
return 0, 0, 0, 0
|
||||
|
||||
# Gradient background: Dark Slate Blue to Navy
|
||||
grad_t = (ny + 1) / 2
|
||||
bg_r = int(12 * (1 - grad_t) + 6 * grad_t)
|
||||
bg_g = int(24 * (1 - grad_t) + 14 * grad_t)
|
||||
bg_b = int(48 * (1 - grad_t) + 32 * grad_t)
|
||||
|
||||
# Hexagon outer border (Cyber Cyan / Emerald highlight)
|
||||
if dist > hex_radius - 0.07:
|
||||
glow_t = (nx + 1) / 2
|
||||
br = int(0 * (1 - glow_t) + 0 * glow_t)
|
||||
bg = int(229 * (1 - glow_t) + 160 * glow_t)
|
||||
bb = int(255 * (1 - glow_t) + 255 * glow_t)
|
||||
return br, bg, bb, 255
|
||||
|
||||
# Center stylized 'L' log symbol and dynamic telemetry bars
|
||||
# Bar 1 (Left Log Stream): x in [-0.42, -0.22], y in [-0.45, 0.45]
|
||||
if -0.42 <= nx <= -0.22 and -0.45 <= ny <= 0.45:
|
||||
v = (ny + 0.45) / 0.9
|
||||
return int(0), int(220 * (1 - v) + 140 * v), int(255), 255
|
||||
|
||||
# Bar 2 (Bottom Base of 'L'): x in [-0.42, 0.35], y in [0.25, 0.45]
|
||||
if -0.42 <= nx <= 0.35 and 0.25 <= ny <= 0.45:
|
||||
v = (nx + 0.42) / 0.77
|
||||
return int(0), int(220 * (1 - v) + 180 * v), int(255), 255
|
||||
|
||||
# Bar 3 (Central Stream Wave 1): x in [-0.10, 0.08], y in [-0.30, 0.12]
|
||||
if -0.10 <= nx <= 0.08 and -0.30 <= ny <= 0.12:
|
||||
return 0, 240, 255, 255
|
||||
|
||||
# Bar 4 (Central Stream Wave 2): x in [0.18, 0.36], y in [-0.50, 0.12]
|
||||
if 0.18 <= nx <= 0.36 and -0.50 <= ny <= 0.12:
|
||||
return 0, 180, 255, 255
|
||||
|
||||
# Subtle inner grid dots
|
||||
grid_x = (nx * 10) % 1
|
||||
grid_y = (ny * 10) % 1
|
||||
if 0.45 <= grid_x <= 0.55 and 0.45 <= grid_y <= 0.55:
|
||||
return bg_r + 20, bg_g + 30, bg_b + 50, 255
|
||||
|
||||
return bg_r, bg_g, bg_b, 255
|
||||
|
||||
|
||||
def draw_wizard_header_white_bgr(x: int, y: int, w: int, h: int) -> tuple:
|
||||
"""
|
||||
Renders header BMP graphic with a pure white background (255, 255, 255)
|
||||
matching the installer wizard dialog background seamlessly.
|
||||
The logo is positioned on the right (icon_center_x = w - 32, center y = h / 2)
|
||||
allowing the header title and subtext to align cleanly to the far left.
|
||||
"""
|
||||
bg_r, bg_g, bg_b = 255, 255, 255
|
||||
|
||||
# Position emblem cleanly on the right
|
||||
icon_center_x = w - 32
|
||||
icon_center_y = h / 2
|
||||
icon_radius = 22
|
||||
dx = (x - icon_center_x) / icon_radius
|
||||
dy = (y - icon_center_y) / icon_radius
|
||||
d = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if d < 1.0:
|
||||
er, eg, eb, ea = draw_logo_rgba(int((dx + 1) * 32), int((dy + 1) * 32), 64, 64)
|
||||
alpha = ea / 255.0
|
||||
r = int(er * alpha + bg_r * (1.0 - alpha))
|
||||
g = int(eg * alpha + bg_g * (1.0 - alpha))
|
||||
b = int(eb * alpha + bg_b * (1.0 - alpha))
|
||||
return r, g, b
|
||||
|
||||
return bg_r, bg_g, bg_b
|
||||
|
||||
|
||||
def draw_wizard_small_bgr(x: int, y: int, w: int, h: int) -> tuple:
|
||||
"""Renders 55x55 square header BMP with centered logo on pure white background."""
|
||||
bg_r, bg_g, bg_b = 255, 255, 255
|
||||
cx = w / 2
|
||||
cy = h / 2
|
||||
rad = (min(w, h) / 2) - 4
|
||||
dx = (x - cx) / rad
|
||||
dy = (y - cy) / rad
|
||||
d = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if d < 1.0:
|
||||
er, eg, eb, ea = draw_logo_rgba(int((dx + 1) * 32), int((dy + 1) * 32), 64, 64)
|
||||
alpha = ea / 255.0
|
||||
r = int(er * alpha + bg_r * (1.0 - alpha))
|
||||
g = int(eg * alpha + bg_g * (1.0 - alpha))
|
||||
b = int(eb * alpha + bg_b * (1.0 - alpha))
|
||||
return r, g, b
|
||||
|
||||
return bg_r, bg_g, bg_b
|
||||
|
||||
|
||||
def draw_wizard_sidebar_bgr(x: int, y: int, w: int, h: int) -> tuple:
|
||||
"""Renders 164x314 BMP sidebar graphic for NSIS / Inno Setup."""
|
||||
t_y = y / h
|
||||
|
||||
# Deep navy to midnight blue vertical gradient
|
||||
r = int(10 * (1 - t_y) + 4 * t_y)
|
||||
g = int(22 * (1 - t_y) + 12 * t_y)
|
||||
b = int(48 * (1 - t_y) + 26 * t_y)
|
||||
|
||||
# Subtle telemetry grid / stream lines
|
||||
if int(x) % 18 == 0 or int(y) % 24 == 0:
|
||||
r = min(255, r + 8)
|
||||
g = min(255, g + 14)
|
||||
b = min(255, b + 25)
|
||||
|
||||
# Decorative node glow in top region
|
||||
emblem_cx = w / 2
|
||||
emblem_cy = 90
|
||||
edx = (x - emblem_cx) / 44
|
||||
edy = (y - emblem_cy) / 44
|
||||
ed = math.sqrt(edx * edx + edy * edy)
|
||||
|
||||
if ed < 0.98:
|
||||
er, eg, eb, ea = draw_logo_rgba(int((edx + 1) * 64), int((edy + 1) * 64), 128, 128)
|
||||
if ea > 128:
|
||||
return er, eg, eb
|
||||
|
||||
# Glowing pulse lines streaming downwards from the emblem
|
||||
if 55 <= x <= 110 and y > 150:
|
||||
line_idx = int((x - 55) / 14)
|
||||
line_offset = (line_idx * 37 + int(y * 0.4)) % 100
|
||||
if line_offset < 30:
|
||||
glow = math.sin((line_offset / 30) * math.pi)
|
||||
g = min(255, int(g + 160 * glow))
|
||||
b = min(255, int(b + 220 * glow))
|
||||
|
||||
# Bottom accent band
|
||||
if y >= h - 4:
|
||||
accent_t = x / w
|
||||
return 0, int(220 * accent_t), 255
|
||||
|
||||
return r, g, b
|
||||
|
||||
|
||||
def generate_ico_file(png_images: list) -> bytes:
|
||||
"""Packs multiple PNG images into a valid Windows .ico container."""
|
||||
count = len(png_images)
|
||||
header = struct.pack("<HHH", 0, 1, count) # Reserved=0, Type=1 (ICO), Count
|
||||
entries = bytearray()
|
||||
offset = 6 + (16 * count)
|
||||
|
||||
image_data_blocks = []
|
||||
for w, h, png_bytes in png_images:
|
||||
size = len(png_bytes)
|
||||
b_w = w if w < 256 else 0
|
||||
b_h = h if h < 256 else 0
|
||||
entry = struct.pack(
|
||||
"<BBBBHHII",
|
||||
b_w,
|
||||
b_h,
|
||||
0, # Color count
|
||||
0, # Reserved
|
||||
1, # Color planes
|
||||
32, # Bits per pixel
|
||||
size,
|
||||
offset
|
||||
)
|
||||
entries.extend(entry)
|
||||
image_data_blocks.append(png_bytes)
|
||||
offset += size
|
||||
|
||||
return header + bytes(entries) + b"".join(image_data_blocks)
|
||||
|
||||
|
||||
def generate_svg_logo(transparent_bg: bool = False) -> str:
|
||||
"""Generates a high-quality SVG vector graphic for LOGAR."""
|
||||
bg_polygon = "" if transparent_bg else """ <!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>"""
|
||||
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{bg_polygon}
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>"""
|
||||
|
||||
|
||||
def generate_svg_banner() -> str:
|
||||
"""Generates a wide SVG banner with emblem and modern typography."""
|
||||
return """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="100%" stop-color="#070C18"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#00FFA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="bannerGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Card -->
|
||||
<rect width="960" height="240" rx="16" fill="url(#bannerBg)" stroke="#162A45" stroke-width="2"/>
|
||||
|
||||
<!-- Left Emblem Group (scaled & positioned) -->
|
||||
<g transform="translate(40, 20) scale(0.39)">
|
||||
<!-- Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="#0E1A2E" stroke="url(#cyanGrad)" stroke-width="12" filter="url(#bannerGlow)"/>
|
||||
<!-- Inner Telemetry Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="4" stroke-dasharray="10 8"/>
|
||||
<!-- Stylized L & Telemetry Bars -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
<!-- Connection Points -->
|
||||
<circle cx="238" cy="190" r="9" fill="#00FFA3"/>
|
||||
<circle cx="304" cy="130" r="9" fill="#00E5FF"/>
|
||||
<circle cx="380" cy="349" r="10" fill="#00E5FF"/>
|
||||
</g>
|
||||
|
||||
<!-- Typography & Tagline -->
|
||||
<text x="270" y="98" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="4" fill="url(#textCyan)" filter="url(#bannerGlow)">LOGAR</text>
|
||||
<text x="272" y="142" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="20" font-weight="600" fill="#E2E8F0" letter-spacing="0.5">Fleet Log Aggregation & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>"""
|
||||
|
||||
|
||||
def main():
|
||||
print("[*] Generating comprehensive branding assets for LOGAR...")
|
||||
|
||||
# 1. Generate SVG Logos
|
||||
svg_logo_dark = generate_svg_logo(transparent_bg=False)
|
||||
svg_logo_trans = generate_svg_logo(transparent_bg=True)
|
||||
svg_banner = generate_svg_banner()
|
||||
|
||||
# 2. Generate PNG images across multiple dimensions
|
||||
png_sizes = [16, 32, 48, 64, 128, 256, 512]
|
||||
png_buffers = []
|
||||
png_dict = {}
|
||||
for s in png_sizes:
|
||||
png_data = create_png_rgba(s, s, draw_logo_rgba)
|
||||
png_buffers.append((s, s, png_data))
|
||||
png_dict[s] = png_data
|
||||
|
||||
# 3. Generate Windows ICO File (multi-res 16..256)
|
||||
ico_data = generate_ico_file(png_buffers[:-1])
|
||||
|
||||
# 4. Generate BMP files for installers
|
||||
header_bmp_data = create_bmp_24bit(150, 57, draw_wizard_header_white_bgr)
|
||||
small_bmp_data = create_bmp_24bit(55, 55, draw_wizard_small_bgr)
|
||||
sidebar_bmp_data = create_bmp_24bit(164, 314, draw_wizard_sidebar_bgr)
|
||||
|
||||
# 5. Generate Palette Swatch SVGs
|
||||
swatches = {
|
||||
"00e5ff": "#00E5FF",
|
||||
"00ffa3": "#00FFA3",
|
||||
"0072ff": "#0072FF",
|
||||
"0e1a2e": "#0E1A2E",
|
||||
"070c18": "#070C18",
|
||||
"ef4444": "#EF4444",
|
||||
"f59e0b": "#F59E0B",
|
||||
"94a3b8": "#94A3B8",
|
||||
"ffffff": "#FFFFFF",
|
||||
}
|
||||
swatch_svgs = {}
|
||||
for name, hex_code in swatches.items():
|
||||
border = "#334155" if hex_code.upper() != "#070C18" else "#1e293b"
|
||||
swatch_svgs[name] = f'<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="{hex_code}" stroke="{border}" stroke-width="1.5"/></svg>'
|
||||
|
||||
# 6. Write to target directories
|
||||
for target_dir in (ROOT_ASSETS_DIR, COMPILATION_ASSETS_DIR, LICENSING_ASSETS_DIR):
|
||||
swatch_dir = os.path.join(target_dir, "swatches")
|
||||
os.makedirs(swatch_dir, exist_ok=True)
|
||||
|
||||
# Swatches
|
||||
for name, svg_content in swatch_svgs.items():
|
||||
with open(os.path.join(swatch_dir, f"swatch_{name}.svg"), "w", encoding="utf-8") as f:
|
||||
f.write(svg_content)
|
||||
|
||||
# SVGs
|
||||
with open(os.path.join(target_dir, "logo.svg"), "w", encoding="utf-8") as f:
|
||||
f.write(svg_logo_dark)
|
||||
with open(os.path.join(target_dir, "logo_dark.svg"), "w", encoding="utf-8") as f:
|
||||
f.write(svg_logo_dark)
|
||||
with open(os.path.join(target_dir, "logo_transparent.svg"), "w", encoding="utf-8") as f:
|
||||
f.write(svg_logo_trans)
|
||||
with open(os.path.join(target_dir, "logo_banner.svg"), "w", encoding="utf-8") as f:
|
||||
f.write(svg_banner)
|
||||
|
||||
# PNGs
|
||||
with open(os.path.join(target_dir, "logo.png"), "wb") as f:
|
||||
f.write(png_dict[512])
|
||||
for s in png_sizes:
|
||||
with open(os.path.join(target_dir, f"logo_{s}x{s}.png"), "wb") as f:
|
||||
f.write(png_dict[s])
|
||||
|
||||
# ICOs
|
||||
with open(os.path.join(target_dir, "logo.ico"), "wb") as f:
|
||||
f.write(ico_data)
|
||||
with open(os.path.join(target_dir, "favicon.ico"), "wb") as f:
|
||||
f.write(ico_data)
|
||||
|
||||
# BMPs
|
||||
with open(os.path.join(target_dir, "wizard_header.bmp"), "wb") as f:
|
||||
f.write(header_bmp_data)
|
||||
with open(os.path.join(target_dir, "wizard_small.bmp"), "wb") as f:
|
||||
f.write(small_bmp_data)
|
||||
with open(os.path.join(target_dir, "wizard_sidebar.bmp"), "wb") as f:
|
||||
f.write(sidebar_bmp_data)
|
||||
|
||||
print(f"[+] Populated asset repository: {target_dir}")
|
||||
|
||||
print("[+] All LOGAR branding assets, icons, and banners generated successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,26 @@ ENROLL_SECRET="${2:-}"
|
||||
INSTALL_DIR="/opt/logar-client"
|
||||
CONFIG_DIR="/etc/logar"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Server is present
|
||||
if [ -f "/opt/logar-server/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||
echo "[-] Error: LOGAR Server is already installed on this machine." >&2
|
||||
echo "[-] LOGAR Client cannot be installed on a host where LOGAR Server is present (LOGAR Server includes built-in log collection)." >&2
|
||||
echo "[-] Please uninstall LOGAR Server first before installing LOGAR Client." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
IS_UPDATE=0
|
||||
if [ -f "${INSTALL_DIR}/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||
IS_UPDATE=1
|
||||
echo "[*] Existing LOGAR Client installation detected. Performing in-place upgrade..."
|
||||
fi
|
||||
|
||||
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[*] Stopping LOGAR Client service for upgrade..."
|
||||
systemctl stop logar-client.service || true
|
||||
fi
|
||||
|
||||
echo "[+] Installing LOGAR Client..."
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||
chmod 700 "${CONFIG_DIR}/certs"
|
||||
|
||||
@@ -21,14 +21,34 @@ echo "============================================================"
|
||||
echo " LOGAR Server Enterprise Linux Installer"
|
||||
echo "============================================================"
|
||||
|
||||
# Interactive configuration if running in a terminal without CLI arguments
|
||||
if [ -t 0 ]; then
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Client is present
|
||||
if [ -f "/opt/logar-client/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||
echo "[-] Error: LOGAR Client is already installed on this machine." >&2
|
||||
echo "[-] LOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server includes built-in log collection)." >&2
|
||||
echo "[-] Please uninstall LOGAR Client first before installing LOGAR Server." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
IS_UPDATE=0
|
||||
if [ -f "${INSTALL_DIR}/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||
IS_UPDATE=1
|
||||
echo "[*] Existing LOGAR Server installation detected. Performing in-place upgrade..."
|
||||
fi
|
||||
|
||||
# Interactive configuration if running in a terminal without CLI arguments (only for fresh install)
|
||||
if [ "${IS_UPDATE}" -eq 0 ] && [ -t 0 ]; then
|
||||
read -r -p "Enter Data and Configuration Directory [${CONFIG_DIR}]: " USER_CFG
|
||||
if [ -n "${USER_CFG}" ]; then
|
||||
CONFIG_DIR="${USER_CFG}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[*] Stopping LOGAR Server service for upgrade..."
|
||||
systemctl stop logar-server.service || true
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" "/var/log/logar"
|
||||
|
||||
if [ -f "dist/Server.bin" ]; then
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
#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.1
|
||||
AppPublisher=LOGAR Security Systems
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
AppUpdatesURL=https://logar.eibl.tech
|
||||
DefaultDirName={autopf}\LOGAR
|
||||
UsePreviousAppDir=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=LOGAR-Client-Setup
|
||||
PrivilegesRequired=admin
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
VersionInfoCompany=LOGAR Security Systems
|
||||
SetupIconFile=assets\logo.ico
|
||||
WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||
VersionInfoVersion=0.3.1.0
|
||||
VersionInfoVersion={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Client
|
||||
VersionInfoProductVersion=0.3.1
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
@@ -31,7 +44,7 @@ Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{code:GetDataDir}\client.log"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{code:GetDataDir}\client_err.log"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppParameters ""--config """"{code:GetDataDir}\client_config.json"""""""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppParameters ""--config """"{code:GetDataDir}\client_config.json"""" --interval 30"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -50,6 +63,59 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR"
|
||||
[Code]
|
||||
var
|
||||
DataDirPage: TInputDirWizardPage;
|
||||
ConfigFilePage: TInputFileWizardPage;
|
||||
IsUpdateMode: Boolean;
|
||||
ExistingDataDir: String;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ServerFound: Boolean;
|
||||
Val: String;
|
||||
begin
|
||||
Result := True;
|
||||
IsUpdateMode := False;
|
||||
ExistingDataDir := '';
|
||||
ServerFound := False;
|
||||
|
||||
// 1. Mutual Exclusion Check: Deny installation if LOGAR Server is already present
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server') or
|
||||
RegValueExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR-Server\Server.exe')) then
|
||||
begin
|
||||
ServerFound := True;
|
||||
end;
|
||||
|
||||
if ServerFound then
|
||||
begin
|
||||
MsgBox('LOGAR Server is already installed on this machine.'#13#10#13#10 +
|
||||
'LOGAR Client cannot be installed on a LOGAR Server host (LOGAR Server already provides built-in log collection).'#13#10#13#10 +
|
||||
'Please uninstall LOGAR Server first if you wish to run a standalone forwarder client.',
|
||||
mbCriticalError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR\Win_Client.exe')) then
|
||||
begin
|
||||
IsUpdateMode := True;
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', Val) then
|
||||
ExistingDataDir := Val;
|
||||
|
||||
MsgBox('An existing installation of LOGAR Client was detected.'#13#10#13#10 +
|
||||
'Setup will perform an in-place update: core binaries will be updated, while your existing configuration (client_config.json), state tracking, and certificates will be preserved.',
|
||||
mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard;
|
||||
begin
|
||||
@@ -63,35 +129,134 @@ begin
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR');
|
||||
if (ExistingDataDir <> '') then
|
||||
DataDirPage.Values[0] := ExistingDataDir
|
||||
else
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR');
|
||||
|
||||
ConfigFilePage := CreateInputFilePage(
|
||||
DataDirPage.ID,
|
||||
'Import Client Configuration (Optional)',
|
||||
'Do you have a pre-exported client_config.json profile to import?',
|
||||
'Select your client_config.json profile exported from the LOGAR Server Hub.'#13#10#13#10 +
|
||||
'If selected, this configuration will be copied into the data directory and used for automated server connection and enrollment.'#13#10#13#10 +
|
||||
'You can also leave this blank to place client_config.json in the data directory manually later.'
|
||||
);
|
||||
ConfigFilePage.Add('Client Configuration (client_config.json):', 'JSON Files (*.json)|*.json|All Files (*.*)|*.*', '.json');
|
||||
end;
|
||||
|
||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
if IsUpdateMode then
|
||||
begin
|
||||
if (PageID = wpSelectDir) or (PageID = DataDirPage.ID) or (PageID = ConfigFilePage.ID) then
|
||||
Result := True;
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetDataDir(Param: String): String;
|
||||
begin
|
||||
if IsUpdateMode and (ExistingDataDir <> '') then
|
||||
begin
|
||||
Result := ExistingDataDir;
|
||||
Exit;
|
||||
end;
|
||||
Result := DataDirPage.Values[0];
|
||||
if Result = '' then
|
||||
Result := ExpandConstant('{commonappdata}\LOGAR');
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
function GetConfigFile(Param: String): String;
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
Result := ConfigFilePage.Values[0];
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
CfgPath, DestCfg: String;
|
||||
begin
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
Exec('net.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('sc.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Sleep(500);
|
||||
end
|
||||
else if CurStep = ssPostInstall then
|
||||
begin
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'InstallDir', ExpandConstant('{app}'));
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir(''));
|
||||
|
||||
if not IsUpdateMode then
|
||||
begin
|
||||
CfgPath := GetConfigFile('');
|
||||
if (CfgPath <> '') and FileExists(CfgPath) then
|
||||
begin
|
||||
DestCfg := GetDataDir('') + '\client_config.json';
|
||||
FileCopy(CfgPath, DestCfg, False);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
var
|
||||
CustomDataDir: String;
|
||||
CustomDataDir, AppDir, PowerShellCmd: String;
|
||||
I, ResultCode: Integer;
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
if CurUninstallStep = usUninstall then
|
||||
begin
|
||||
AppDir := ExpandConstant('{app}');
|
||||
Exec('net.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('sc.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileExists(AppDir + '\nssm.exe') then
|
||||
begin
|
||||
Exec(AppDir + '\nssm.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec(AppDir + '\nssm.exe', 'remove LOGAR_Client confirm', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
Exec('sc.exe', 'delete LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
PowerShellCmd := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "' +
|
||||
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like ''' + AppDir + '\*'' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"';
|
||||
Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Sleep(1200);
|
||||
end
|
||||
else if CurUninstallStep = usPostUninstall then
|
||||
begin
|
||||
AppDir := ExpandConstant('{app}');
|
||||
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', CustomDataDir) then
|
||||
begin
|
||||
if (CustomDataDir <> '') and DirExists(CustomDataDir) then
|
||||
DelTree(CustomDataDir, True, True, True);
|
||||
begin
|
||||
for I := 1 to 5 do
|
||||
begin
|
||||
if DelTree(CustomDataDir, True, True, True) or not DirExists(CustomDataDir) then
|
||||
Break;
|
||||
Sleep(300);
|
||||
end;
|
||||
end;
|
||||
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client');
|
||||
end;
|
||||
|
||||
if (AppDir <> '') and DirExists(AppDir) then
|
||||
begin
|
||||
for I := 1 to 10 do
|
||||
begin
|
||||
DeleteFile(AppDir + '\nssm.exe');
|
||||
DeleteFile(AppDir + '\Win_Client.exe');
|
||||
DelTree(AppDir + '\runtime', True, True, True);
|
||||
DelTree(AppDir + '\certs', True, True, True);
|
||||
if DelTree(AppDir, True, True, True) or not DirExists(AppDir) then
|
||||
Break;
|
||||
Sleep(300);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Name "LOGAR Client"
|
||||
OutFile "../dist/LOGAR-Client-Setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\LOGAR"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Enterprise SIEM & Edge Forwarder"
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Forwarder"
|
||||
|
||||
VIProductVersion "0.3.1.0"
|
||||
!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 Security Systems"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.1.0"
|
||||
VIAddVersionKey "ProductVersion" "0.3.1.0"
|
||||
VIAddVersionKey "FileVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${DISPLAY_VERSION}"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
!define MUI_UNICON "assets/logo.ico"
|
||||
!define MUI_HEADERIMAGE
|
||||
!define MUI_HEADERIMAGE_RIGHT
|
||||
!define MUI_HEADERIMAGE_BITMAP "assets/wizard_header.bmp"
|
||||
!define MUI_WELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
|
||||
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
|
||||
|
||||
Var DATADIR
|
||||
Var CFGFILE
|
||||
Var IS_UPDATE
|
||||
Var CfgDialog
|
||||
Var CfgFileInput
|
||||
Var CfgBrowseBtn
|
||||
|
||||
# --- UI Pages ---
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
# Welcome Page
|
||||
!define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Client Setup"
|
||||
!define MUI_WELCOMEPAGE_TEXT "LOGAR Client provides secure, encrypted Windows Event Log forwarding with automatic certificate enrollment and state tracking.\r\n\r\nThis wizard will guide you through installing the forwarder service."
|
||||
!define MUI_WELCOMEPAGE_TEXT "LOGAR Client provides secure, encrypted Windows Event Log forwarding with automatic certificate enrollment and state tracking.$\r$\n$\r$\nThis wizard will guide you through installing the forwarder service."
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
|
||||
# Directory Page 1: Installation Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Installation Location"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose the folder in which to install program binaries."
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $INSTDIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -37,9 +64,13 @@ Var DATADIR
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose where state tracking, certificates, and configuration files should be stored."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder where LOGAR Client should store state tracking (client_state.json), configuration (client_config.json), and mTLS certificates.$\r$\n$\r$\nDefault is recommended for automatic background service access."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "Data & Storage Folder"
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
# Custom Page: Config File Selection
|
||||
Page custom ConfigPageCreate ConfigPageLeave
|
||||
|
||||
# Instfiles Page
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
@@ -54,13 +85,95 @@ Var DATADIR
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function SkipIfUpdate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function .onInit
|
||||
SetShellVarContext all
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||
StrCpy $CFGFILE ""
|
||||
StrCpy $IS_UPDATE "0"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Server is present
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Server" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Server" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES64\LOGAR-Server\Server.exe"
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES\LOGAR-Server\Server.exe"
|
||||
MessageBox MB_ICONSTOP|MB_OK "LOGAR Server is already installed on this machine.$\r$\n$\r$\nLOGAR Client cannot be co-installed on a LOGAR Server host (LOGAR Server already provides built-in log collection).$\r$\n$\r$\nPlease uninstall LOGAR Server first if you wish to run a standalone forwarder client."
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
# 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Client" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$INSTDIR\Win_Client.exe"
|
||||
StrCpy $IS_UPDATE "1"
|
||||
${If} $0 != ""
|
||||
StrCpy $DATADIR $0
|
||||
${EndIf}
|
||||
${If} $1 != ""
|
||||
StrCpy $INSTDIR $1
|
||||
${EndIf}
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "An existing installation of LOGAR Client was detected.$\r$\n$\r$\nSetup will perform an in-place update: program files will be updated while your existing configuration (client_config.json), state tracking, and certificates are preserved."
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function ConfigPageCreate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $CfgDialog
|
||||
${If} $CfgDialog == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
!insertmacro MUI_HEADER_TEXT "Import Client Configuration (Optional)" "Choose a client_config.json profile exported from LOGAR Server."
|
||||
|
||||
${NSD_CreateLabel} 0 0 100% 48u "If you have exported a client_config.json profile bundle from your LOGAR Server dashboard, select it below to configure the forwarder immediately.$\r$\n$\r$\nLeave this field blank to configure later."
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateText} 0 54u 75% 14u "$CFGFILE"
|
||||
Pop $CfgFileInput
|
||||
|
||||
${NSD_CreateButton} 78% 53u 22% 16u "Browse..."
|
||||
Pop $CfgBrowseBtn
|
||||
${NSD_OnClick} $CfgBrowseBtn OnBrowseCfg
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function OnBrowseCfg
|
||||
nsDialogs::SelectFileDialog open "" "LOGAR Client Profiles (*.json)|*.json|All Files (*.*)|*.*"
|
||||
Pop $0
|
||||
${If} $0 != ""
|
||||
StrCpy $CFGFILE $0
|
||||
${NSD_SetText} $CfgFileInput $CFGFILE
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function ConfigPageLeave
|
||||
${NSD_GetText} $CfgFileInput $CFGFILE
|
||||
FunctionEnd
|
||||
|
||||
Section "Install"
|
||||
SetShellVarContext all
|
||||
|
||||
# Terminate any existing service or running processes before replacing files
|
||||
ExecWait 'net stop LOGAR_Client'
|
||||
ExecWait 'sc stop LOGAR_Client'
|
||||
ExecWait 'taskkill /F /T /IM Win_Client.exe'
|
||||
ExecWait 'taskkill /F /T /IM nssm.exe'
|
||||
Sleep 500
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
File "../dist/Win_Client.exe"
|
||||
File "../compilation/nssm.exe"
|
||||
@@ -69,15 +182,23 @@ Section "Install"
|
||||
CreateDirectory "$DATADIR"
|
||||
ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q'
|
||||
|
||||
# If client config file was selected and not updating existing config, copy to data directory
|
||||
${If} $IS_UPDATE != "1"
|
||||
${AndIf} $CFGFILE != ""
|
||||
${AndIf} ${FileExists} "$CFGFILE"
|
||||
CopyFiles /SILENT "$CFGFILE" "$DATADIR\client_config.json"
|
||||
${EndIf}
|
||||
|
||||
# Install and configure service via NSSM
|
||||
ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStdout "$DATADIR\client.log"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStderr "$DATADIR\client_err.log"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppParameters "--config \"$DATADIR\client_config.json\""'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppParameters "--config $\"$DATADIR\client_config.json$\" --interval 30"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Client'
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Client" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Client" "DataDir" "$DATADIR"
|
||||
SectionEnd
|
||||
|
||||
@@ -88,8 +209,22 @@ Section "Uninstall"
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||
${EndIf}
|
||||
|
||||
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client'
|
||||
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm'
|
||||
# 1. Stop and remove service
|
||||
ExecWait 'net stop LOGAR_Client'
|
||||
ExecWait 'sc stop LOGAR_Client'
|
||||
${If} ${FileExists} "$INSTDIR\nssm.exe"
|
||||
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client'
|
||||
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm'
|
||||
${EndIf}
|
||||
ExecWait 'sc delete LOGAR_Client'
|
||||
|
||||
# 2. Force terminate any lingering processes
|
||||
ExecWait 'taskkill /F /T /IM Win_Client.exe'
|
||||
ExecWait 'taskkill /F /T /IM nssm.exe'
|
||||
ExecWait `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$INSTDIR\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
|
||||
Sleep 1200
|
||||
|
||||
# 3. Clean up all installation and data files
|
||||
RMDir /r "$INSTDIR\runtime"
|
||||
RMDir /r "$INSTDIR\certs"
|
||||
Delete "$INSTDIR\client_config.json"
|
||||
@@ -101,4 +236,5 @@ Section "Uninstall"
|
||||
RMDir /r "$INSTDIR"
|
||||
RMDir /r "$DATADIR"
|
||||
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client"
|
||||
DeleteRegKey /ifempty HKLM "Software\LOGAR"
|
||||
SectionEnd
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
#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.1
|
||||
AppPublisher=LOGAR Security Systems
|
||||
AppVersion={#AppVersion}
|
||||
AppPublisher=LOGAR
|
||||
AppPublisherURL=https://logar.eibl.tech
|
||||
AppSupportURL=https://logar.eibl.tech
|
||||
AppUpdatesURL=https://logar.eibl.tech
|
||||
DefaultDirName={autopf}\LOGAR-Server
|
||||
UsePreviousAppDir=yes
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=LOGAR-Server-Setup
|
||||
PrivilegesRequired=admin
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
VersionInfoCompany=LOGAR Security Systems
|
||||
SetupIconFile=assets\logo.ico
|
||||
WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
|
||||
VersionInfoVersion=0.3.1.0
|
||||
VersionInfoVersion={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Server
|
||||
VersionInfoProductVersion=0.3.1
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
@@ -51,6 +64,58 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR-Server"
|
||||
[Code]
|
||||
var
|
||||
DataDirPage: TInputDirWizardPage;
|
||||
IsUpdateMode: Boolean;
|
||||
ExistingDataDir: String;
|
||||
|
||||
function InitializeSetup(): Boolean;
|
||||
var
|
||||
ClientFound: Boolean;
|
||||
Val: String;
|
||||
begin
|
||||
Result := True;
|
||||
IsUpdateMode := False;
|
||||
ExistingDataDir := '';
|
||||
ClientFound := False;
|
||||
|
||||
// 1. Mutual Exclusion Check: Deny installation if LOGAR Client is already present
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client') or
|
||||
RegValueExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{9C4A6F1D-8975-476E-8512-4217DE029A88}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Client_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR\Win_Client.exe')) then
|
||||
begin
|
||||
ClientFound := True;
|
||||
end;
|
||||
|
||||
if ClientFound then
|
||||
begin
|
||||
MsgBox('LOGAR Client is already installed on this machine.'#13#10#13#10 +
|
||||
'LOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server already includes built-in local log collection).'#13#10#13#10 +
|
||||
'Please uninstall LOGAR Client before installing LOGAR Server.',
|
||||
mbCriticalError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
|
||||
// 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
if RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{{4F3D675E-1E4E-43B6-8C84-B47DE290BE4A}}_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
RegKeyExists(HKEY_LOCAL_MACHINE, 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\LOGAR Server_is1') or
|
||||
FileExists(ExpandConstant('{autopf}\LOGAR-Server\Server.exe')) then
|
||||
begin
|
||||
IsUpdateMode := True;
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', Val) then
|
||||
ExistingDataDir := Val;
|
||||
|
||||
MsgBox('An existing installation of LOGAR Server was detected.'#13#10#13#10 +
|
||||
'Setup will perform an in-place update: core binaries will be updated, while your existing configuration (server_config.json), database (logar_state.db), and certificates will be preserved.',
|
||||
mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard;
|
||||
begin
|
||||
@@ -64,35 +129,108 @@ begin
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||
if (ExistingDataDir <> '') then
|
||||
DataDirPage.Values[0] := ExistingDataDir
|
||||
else
|
||||
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||
end;
|
||||
|
||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||
begin
|
||||
Result := False;
|
||||
if IsUpdateMode then
|
||||
begin
|
||||
if (PageID = wpSelectDir) or (PageID = DataDirPage.ID) then
|
||||
Result := True;
|
||||
end;
|
||||
end;
|
||||
|
||||
function GetDataDir(Param: String): String;
|
||||
begin
|
||||
if IsUpdateMode and (ExistingDataDir <> '') then
|
||||
begin
|
||||
Result := ExistingDataDir;
|
||||
Exit;
|
||||
end;
|
||||
Result := DataDirPage.Values[0];
|
||||
if Result = '' then
|
||||
Result := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
if CurStep = ssInstall then
|
||||
begin
|
||||
Exec('net.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('sc.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Sleep(500);
|
||||
end
|
||||
else if CurStep = ssPostInstall then
|
||||
begin
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'InstallDir', ExpandConstant('{app}'));
|
||||
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', GetDataDir(''));
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
var
|
||||
CustomDataDir: String;
|
||||
CustomDataDir, AppDir, PowerShellCmd: String;
|
||||
I, ResultCode: Integer;
|
||||
begin
|
||||
if CurUninstallStep = usPostUninstall then
|
||||
if CurUninstallStep = usUninstall then
|
||||
begin
|
||||
AppDir := ExpandConstant('{app}');
|
||||
Exec('net.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('sc.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileExists(AppDir + '\nssm.exe') then
|
||||
begin
|
||||
Exec(AppDir + '\nssm.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec(AppDir + '\nssm.exe', 'remove LOGAR_Server confirm', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
Exec('sc.exe', 'delete LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
PowerShellCmd := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "' +
|
||||
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like ''' + AppDir + '\*'' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"';
|
||||
Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Sleep(1200);
|
||||
end
|
||||
else if CurUninstallStep = usPostUninstall then
|
||||
begin
|
||||
AppDir := ExpandConstant('{app}');
|
||||
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
|
||||
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', CustomDataDir) then
|
||||
begin
|
||||
if (CustomDataDir <> '') and DirExists(CustomDataDir) then
|
||||
DelTree(CustomDataDir, True, True, True);
|
||||
begin
|
||||
for I := 1 to 5 do
|
||||
begin
|
||||
if DelTree(CustomDataDir, True, True, True) or not DirExists(CustomDataDir) then
|
||||
Break;
|
||||
Sleep(300);
|
||||
end;
|
||||
end;
|
||||
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server');
|
||||
end;
|
||||
|
||||
if (AppDir <> '') and DirExists(AppDir) then
|
||||
begin
|
||||
for I := 1 to 10 do
|
||||
begin
|
||||
DeleteFile(AppDir + '\nssm.exe');
|
||||
DeleteFile(AppDir + '\Server.exe');
|
||||
DelTree(AppDir + '\runtime', True, True, True);
|
||||
DelTree(AppDir + '\certs', True, True, True);
|
||||
if DelTree(AppDir, True, True, True) or not DirExists(AppDir) then
|
||||
Break;
|
||||
Sleep(300);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
@@ -6,29 +6,51 @@ OutFile "../dist/LOGAR-Server-Setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\LOGAR-Server"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Enterprise SIEM & Log Ingestion"
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Ingestion Hub"
|
||||
|
||||
VIProductVersion "0.3.1.0"
|
||||
!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 Security Systems"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
|
||||
VIAddVersionKey "CompanyName" "LOGAR"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
|
||||
VIAddVersionKey "FileVersion" "0.3.1.0"
|
||||
VIAddVersionKey "ProductVersion" "0.3.1.0"
|
||||
VIAddVersionKey "FileVersion" "${APP_VERSION}"
|
||||
VIAddVersionKey "ProductVersion" "${DISPLAY_VERSION}"
|
||||
|
||||
# --- Visual Graphics & Branding ---
|
||||
!define MUI_ICON "assets/logo.ico"
|
||||
!define MUI_UNICON "assets/logo.ico"
|
||||
!define MUI_HEADERIMAGE
|
||||
!define MUI_HEADERIMAGE_RIGHT
|
||||
!define MUI_HEADERIMAGE_BITMAP "assets/wizard_header.bmp"
|
||||
!define MUI_WELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
|
||||
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
|
||||
|
||||
Var DATADIR
|
||||
Var IS_UPDATE
|
||||
|
||||
# --- UI Pages ---
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
# Welcome Page
|
||||
!define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Server Setup"
|
||||
!define MUI_WELCOMEPAGE_TEXT "LOGAR provides high-performance encrypted log ingestion, temporal analysis, and mTLS client enrollment.\r\n\r\nThis wizard will guide you through installing LOGAR Server and configuring it as an automated Windows service."
|
||||
!define MUI_WELCOMEPAGE_TEXT "LOGAR provides high-performance encrypted log ingestion, temporal analysis, and mTLS client enrollment.$\r$\n$\r$\nThis wizard will guide you through installing LOGAR Server and configuring it as an automated Windows service."
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
|
||||
# Directory Page 1: Installation Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Installation Location"
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose the folder in which to install program binaries."
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $INSTDIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -37,6 +59,7 @@ Var DATADIR
|
||||
!define MUI_PAGE_HEADER_SUBTEXT "Choose where database, certificates, and configuration files should be stored."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_TOP "Choose the folder where LOGAR Server should store its database (logar_state.db), configuration (server_config.json), and mTLS certificates.$\r$\n$\r$\nDefault is recommended for automatic background service access."
|
||||
!define MUI_DIRECTORYPAGE_TEXT_DESTINATION "Data & Storage Folder"
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfUpdate
|
||||
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
|
||||
@@ -45,7 +68,7 @@ Var DATADIR
|
||||
|
||||
# Finish Page
|
||||
!define MUI_FINISHPAGE_TITLE "Installation Complete"
|
||||
!define MUI_FINISHPAGE_TEXT "LOGAR Server has been successfully installed and started as a background Windows service.\r\n\r\nWeb Hub Dashboard: http://localhost:8443\r\nTCP Ingestion Port: 9443"
|
||||
!define MUI_FINISHPAGE_TEXT "LOGAR Server has been successfully installed and started as a background Windows service.$\r$\n$\r$\nWeb Hub Dashboard: http://localhost:8443$\r$\nTCP Ingestion Port: 9443"
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
# Uninstaller Pages
|
||||
@@ -54,13 +77,55 @@ Var DATADIR
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
Function SkipIfUpdate
|
||||
${If} $IS_UPDATE == "1"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function .onInit
|
||||
SetShellVarContext all
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR-Server"
|
||||
StrCpy $IS_UPDATE "0"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Client is present
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Client" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES64\LOGAR\Win_Client.exe"
|
||||
${OrIf} ${FileExists} "$PROGRAMFILES\LOGAR\Win_Client.exe"
|
||||
MessageBox MB_ICONSTOP|MB_OK "LOGAR Client is already installed on this machine.$\r$\n$\r$\nLOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server already includes built-in local log collection).$\r$\n$\r$\nPlease uninstall LOGAR Client before installing LOGAR Server."
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
# 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
ReadRegStr $0 HKLM "Software\LOGAR\LOGAR-Server" "DataDir"
|
||||
ReadRegStr $1 HKLM "Software\LOGAR\LOGAR-Server" "InstallDir"
|
||||
${If} $0 != ""
|
||||
${OrIf} $1 != ""
|
||||
${OrIf} ${FileExists} "$INSTDIR\Server.exe"
|
||||
StrCpy $IS_UPDATE "1"
|
||||
${If} $0 != ""
|
||||
StrCpy $DATADIR $0
|
||||
${EndIf}
|
||||
${If} $1 != ""
|
||||
StrCpy $INSTDIR $1
|
||||
${EndIf}
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "An existing installation of LOGAR Server was detected.$\r$\n$\r$\nSetup will perform an in-place update: program files will be updated while your existing database, configuration (server_config.json), and certificates are preserved."
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Section "Install"
|
||||
SetShellVarContext all
|
||||
|
||||
# Terminate any existing service or running processes before replacing files
|
||||
ExecWait 'net stop LOGAR_Server'
|
||||
ExecWait 'sc stop LOGAR_Server'
|
||||
ExecWait 'taskkill /F /T /IM Server.exe'
|
||||
ExecWait 'taskkill /F /T /IM nssm.exe'
|
||||
Sleep 500
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
File "../dist/Server.exe"
|
||||
File "../compilation/nssm.exe"
|
||||
@@ -74,10 +139,11 @@ Section "Install"
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppDirectory "$INSTDIR"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStdout "$DATADIR\server.log"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStderr "$DATADIR\server_err.log"'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppParameters "--config \"$DATADIR\server_config.json\""'
|
||||
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppParameters "--config $\"$DATADIR\server_config.json$\""'
|
||||
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Server'
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "DataDir" "$DATADIR"
|
||||
SectionEnd
|
||||
|
||||
@@ -88,8 +154,22 @@ Section "Uninstall"
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR-Server"
|
||||
${EndIf}
|
||||
|
||||
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Server'
|
||||
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Server confirm'
|
||||
# 1. Stop and remove service
|
||||
ExecWait 'net stop LOGAR_Server'
|
||||
ExecWait 'sc stop LOGAR_Server'
|
||||
${If} ${FileExists} "$INSTDIR\nssm.exe"
|
||||
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Server'
|
||||
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Server confirm'
|
||||
${EndIf}
|
||||
ExecWait 'sc delete LOGAR_Server'
|
||||
|
||||
# 2. Force terminate any lingering processes
|
||||
ExecWait 'taskkill /F /T /IM Server.exe'
|
||||
ExecWait 'taskkill /F /T /IM nssm.exe'
|
||||
ExecWait `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$INSTDIR\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
|
||||
Sleep 1200
|
||||
|
||||
# 3. Clean up all installation and data files
|
||||
RMDir /r "$INSTDIR\runtime"
|
||||
RMDir /r "$INSTDIR\certs"
|
||||
Delete "$INSTDIR\server_config.json"
|
||||
@@ -102,4 +182,5 @@ Section "Uninstall"
|
||||
RMDir /r "$INSTDIR"
|
||||
RMDir /r "$DATADIR"
|
||||
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Server"
|
||||
DeleteRegKey /ifempty HKLM "Software\LOGAR"
|
||||
SectionEnd
|
||||
|
||||
@@ -15,12 +15,34 @@ BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
|
||||
|
||||
def clean_and_prep():
|
||||
if os.path.exists(DIST_DIR):
|
||||
shutil.rmtree(DIST_DIR)
|
||||
os.makedirs(DIST_DIR, exist_ok=True)
|
||||
for item in os.listdir(DIST_DIR):
|
||||
if item == "runtime":
|
||||
continue
|
||||
item_path = os.path.join(DIST_DIR, item)
|
||||
try:
|
||||
if os.path.isdir(item_path):
|
||||
shutil.rmtree(item_path)
|
||||
else:
|
||||
os.remove(item_path)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
os.makedirs(DIST_DIR, exist_ok=True)
|
||||
if os.path.exists(BUILD_TEMP):
|
||||
shutil.rmtree(BUILD_TEMP)
|
||||
try:
|
||||
shutil.rmtree(BUILD_TEMP)
|
||||
except Exception:
|
||||
pass
|
||||
os.makedirs(BUILD_TEMP, exist_ok=True)
|
||||
|
||||
# Generate or verify branding images and icons
|
||||
branding_script = os.path.join(ROOT_DIR, "compilation", "generate_branding_assets.py")
|
||||
if os.path.exists(branding_script):
|
||||
try:
|
||||
subprocess.run([sys.executable, branding_script], cwd=os.path.join(ROOT_DIR, "compilation"), check=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def calculate_sha256(filepath):
|
||||
h = hashlib.sha256()
|
||||
@@ -268,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()
|
||||
@@ -275,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}")
|
||||
@@ -316,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"
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# LOGAR Linux Edge Forwarder
|
||||
|
||||
Standalone compiled binary and automated systemd service distribution for Linux edge servers.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, auto-enrolls with the central LOGAR hub, and streams candidate events over mutual TLS 1.3 (**mTLS**) to the central hub.
|
||||
|
||||
### Key Capabilities
|
||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
||||
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with machine-bound client certificates.
|
||||
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `/etc/logar/certs/`, and establishes secure mTLS streaming.
|
||||
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
|
||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
||||
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
||||
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Script (Recommended)
|
||||
|
||||
Run the automated installer script:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||
```
|
||||
This script:
|
||||
1. Installs the binary to `/opt/logar-client/Linux_Client`.
|
||||
2. Creates `/etc/logar/certs` with strict permissions.
|
||||
3. Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||
4. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-client.service`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generating & Deploying the Configuration File
|
||||
|
||||
### Step 1: Generate `client_config.json` on the Server
|
||||
Run the following command on your central LOGAR server:
|
||||
```bash
|
||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||
|
||||
### Step 2: Configuration Structure
|
||||
The generated `client_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"hermes_host": "192.168.1.100",
|
||||
"hermes_port": 8443,
|
||||
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"auth_token": "a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||
|
||||
### Step 3: Copy to Edge Node
|
||||
Place `Linux_Client.bin` and `client_config.json` into the target directory (e.g. `/opt/logar/`):
|
||||
```bash
|
||||
sudo mkdir -p /opt/logar
|
||||
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
||||
sudo chmod +x /opt/logar/Linux_Client.bin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running Manually
|
||||
|
||||
Test the forwarder interactively:
|
||||
```bash
|
||||
cd /opt/logar
|
||||
./Linux_Client.bin --hours 24
|
||||
```
|
||||
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||
|
||||
### Command-Line Arguments
|
||||
| Argument | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `--config` | `client_config.json` | Path to client configuration file |
|
||||
| `--hours` | `24` | Lookback window in hours for journal logs |
|
||||
| `--state-file` | `client_state.json` | Path to persistent state file |
|
||||
| `--no-state` | `False` | Disable state tracking and send all events matching lookback window |
|
||||
|
||||
---
|
||||
|
||||
## 4. Manual Systemd Service & Timer Setup
|
||||
|
||||
### Step 1: Create the Systemd Service Unit
|
||||
Create `/etc/systemd/system/logar-client.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LOGAR Edge Log Forwarder
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/logar
|
||||
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Step 2: Enable and Start the Service
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now logar-client.service
|
||||
```
|
||||
|
||||
### Step 3: Check Logs
|
||||
```bash
|
||||
sudo journalctl -u logar-client.service -n 50 -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Uninstallation & Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now logar-client.service
|
||||
sudo rm -f /etc/systemd/system/logar-client.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/logar-client /opt/logar /etc/logar
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"server_fingerprint": "PASTE_SERVER_FINGERPRINT_HERE",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"auth_token": "PASTE_AUTH_TOKEN_HERE"
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
# LOGAR Linux Server Hub
|
||||
|
||||
Standalone compiled binary and automated systemd service distribution for Linux server environments (`Server.bin`).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`Server.bin` is a self-contained, pre-compiled Linux ELF executable that operates as the central coordination, log analysis, dynamic PKI, and reporting hub of the LOGAR telemetry architecture.
|
||||
|
||||
### Key Architecture & Capabilities
|
||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
|
||||
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Script (Recommended)
|
||||
|
||||
Deploy using the automated installer:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_server.sh
|
||||
```
|
||||
This script:
|
||||
1. Installs the server binary to `/opt/logar-server/Server`.
|
||||
2. Creates `/etc/logar` and `/var/log/logar`.
|
||||
3. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-server.service` with `LimitNOFILE=65536`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Initializing & Generating Server Configuration
|
||||
|
||||
### Step 1: Automatic First-Run Generation
|
||||
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
|
||||
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||
5. Default network socket coordinates (mTLS 9443, Hermes API 8443).
|
||||
|
||||
Run `Server.bin` once to initialize:
|
||||
```bash
|
||||
./Server.bin
|
||||
```
|
||||
Output:
|
||||
```
|
||||
[!] Config 'server_config.json' not found. Initializing first-run configuration...
|
||||
[+] Successfully generated new server config and OpenPGP keypair.
|
||||
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||
[+] Saved to: server_config.json
|
||||
============================================================
|
||||
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||
Transport Security: mTLS (TLS 1.3)
|
||||
License Quota: 10 Active Seats
|
||||
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||
============================================================
|
||||
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||
```
|
||||
|
||||
### Step 2: Configuration Fields Reference
|
||||
The generated `server_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_name": "LOGAR-Linux-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
||||
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||
"max_seats": 10,
|
||||
"cert_dir": "certs",
|
||||
"tls_enabled": true,
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 1,
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Exporting Client Configurations
|
||||
|
||||
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||
```bash
|
||||
./Server.bin --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||
|
||||
### Accessing the WebUI
|
||||
Open your browser and navigate to:
|
||||
```
|
||||
http://<SERVER_IP>:8443/webui
|
||||
```
|
||||
The WebUI provides:
|
||||
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||
|
||||
### CLI Licensing Utilities
|
||||
Export a `.lic` request file via CLI:
|
||||
```bash
|
||||
./Server.bin --export-lic server.lic
|
||||
```
|
||||
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||
```bash
|
||||
./Server.bin --import-lic activated.lic
|
||||
# Or alternatively with --import-ca:
|
||||
./Server.bin --import-ca ca.crt
|
||||
```
|
||||
Synchronize license and check for seat quota increases against the master authority:
|
||||
```bash
|
||||
./Server.bin --sync-license
|
||||
```
|
||||
|
||||
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`), multi-tenant fleet isolation, user management, and authenticated WebUI dashboard access.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual Systemd Service Management
|
||||
|
||||
Check service status:
|
||||
```bash
|
||||
sudo systemctl status logar-server.service
|
||||
```
|
||||
|
||||
Inspect live service logs:
|
||||
```bash
|
||||
sudo journalctl -u logar-server.service -f -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Uninstallation & Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now logar-server.service
|
||||
sudo rm -f /etc/systemd/system/logar-server.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"server_name": "LOGAR-Linux-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "replace_with_secure_random_hex_token",
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 4,
|
||||
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
# LOGAR Windows Edge Forwarder
|
||||
|
||||
Standalone compiled executable and installer distribution for Windows Server and workstation environments.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
`Win_Client.exe` is a self-contained executable that queries the Windows Application Event Log, filters candidate events at the source, auto-enrolls with the central LOGAR hub to receive signed mTLS certificates, and streams records over mutual TLS 1.3 (**mTLS**) socket connection.
|
||||
|
||||
### Key Capabilities
|
||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`) or full installer (`LOGAR-Client-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
||||
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with hardware/machine-bound client certificates.
|
||||
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `certs/`, and establishes secure mTLS streaming.
|
||||
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
|
||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
||||
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
||||
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Inno Setup (Recommended)
|
||||
|
||||
Run the self-contained installer built from `compilation/installer_client.iss`:
|
||||
```powershell
|
||||
.\LOGAR-Client-Setup.exe
|
||||
```
|
||||
This installer:
|
||||
1. Installs `Win_Client.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR\`.
|
||||
2. Sets up directory permissions in `C:\ProgramData\LOGAR\`.
|
||||
3. Registers and starts the `LOGAR_Client` Windows service automatically via NSSM.
|
||||
4. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR\client.log` and `client_err.log`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generating & Deploying the Configuration File
|
||||
|
||||
### Step 1: Generate `client_config.json` on the Server
|
||||
Run the following command on your central LOGAR server:
|
||||
```bash
|
||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||
|
||||
### Step 2: Configuration Structure
|
||||
The generated `client_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"hermes_host": "192.168.1.100",
|
||||
"hermes_port": 8443,
|
||||
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"auth_token": "a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||
|
||||
### Step 3: Copy to Edge Node
|
||||
Place `client_config.json` next to `Win_Client.exe` (e.g. `C:\Program Files\LOGAR\` or `C:\LOGAR\`):
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
||||
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running Manually
|
||||
|
||||
Test the forwarder interactively from PowerShell or Command Prompt:
|
||||
```powershell
|
||||
cd C:\LOGAR
|
||||
.\Win_Client.exe --hours 24
|
||||
```
|
||||
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||
|
||||
### Command-Line Arguments
|
||||
| Argument | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `--config` | `client_config.json` | Path to client configuration file |
|
||||
| `--hours` | `24` | Lookback window in hours for event logs |
|
||||
| `--state-file` | `client_state.json` | Path to persistent state tracking file |
|
||||
| `--no-state` | `False` | Disable state tracking and send all events matching lookback window |
|
||||
|
||||
---
|
||||
|
||||
## 4. Manual Service Installation (NSSM or Scheduled Task)
|
||||
|
||||
### Method A: Windows Service via Bundled NSSM
|
||||
```powershell
|
||||
# From the compilation directory or with bundled nssm.exe:
|
||||
.\nssm.exe install LOGAR_Client "C:\LOGAR\Win_Client.exe" "--hours 24"
|
||||
.\nssm.exe set LOGAR_Client AppDirectory "C:\LOGAR"
|
||||
.\nssm.exe set LOGAR_Client AppStdout "C:\ProgramData\LOGAR\client.log"
|
||||
.\nssm.exe set LOGAR_Client AppStderr "C:\ProgramData\LOGAR\client_err.log"
|
||||
.\nssm.exe start LOGAR_Client
|
||||
```
|
||||
|
||||
### Method B: Windows Scheduled Task via PowerShell
|
||||
```powershell
|
||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
||||
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
||||
|
||||
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
||||
-Action $Action `
|
||||
-Trigger $Trigger `
|
||||
-Settings $Settings `
|
||||
-User "NT AUTHORITY\SYSTEM" `
|
||||
-RunLevel Highest `
|
||||
-Description "LOGAR Windows Edge Log Forwarder Service"
|
||||
|
||||
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Uninstallation
|
||||
|
||||
If installed via the Inno Setup installer, use **Windows Add/Remove Programs** or run `unins000.exe` in `C:\Program Files\LOGAR\`.
|
||||
|
||||
If installed manually via NSSM:
|
||||
```powershell
|
||||
.\nssm.exe stop LOGAR_Client
|
||||
.\nssm.exe remove LOGAR_Client confirm
|
||||
Remove-Item -Recurse -Force "C:\LOGAR"
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"server_fingerprint": "PASTE_SERVER_FINGERPRINT_HERE",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"auth_token": "PASTE_AUTH_TOKEN_HERE"
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
# LOGAR Windows Server Hub
|
||||
|
||||
Standalone compiled executable and installer distribution for Windows Server environments (`Server.exe`).
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`Server.exe` is a self-contained, pre-compiled native Windows PE executable that serves as the central log aggregation, temporal persistence analyzer, dynamic PKI certificate authority, and reporting hub of the LOGAR infrastructure.
|
||||
|
||||
### Key Architecture & Capabilities
|
||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
||||
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Inno Setup (Recommended)
|
||||
|
||||
Run the self-contained installer built from `compilation/installer_server.iss`:
|
||||
```powershell
|
||||
.\LOGAR-Server-Setup.exe
|
||||
```
|
||||
This installer:
|
||||
1. Installs `Server.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR-Server\`.
|
||||
2. Registers and starts the `LOGAR_Server` Windows service automatically via NSSM.
|
||||
3. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR-Server\server.log` and `server_err.log`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Initializing & Generating Server Configuration
|
||||
|
||||
### Step 1: Automatic First-Run Generation
|
||||
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
|
||||
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||
5. Default network socket coordinates (mTLS 9443, Hermes API 8443).
|
||||
|
||||
Open PowerShell and run:
|
||||
```powershell
|
||||
.\Server.exe
|
||||
```
|
||||
Output:
|
||||
```
|
||||
[!] Config 'server_config.json' not found. Initializing first-run configuration...
|
||||
[+] Successfully generated new server config and OpenPGP keypair.
|
||||
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||
[+] Saved to: server_config.json
|
||||
============================================================
|
||||
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||
Transport Security: mTLS (TLS 1.3)
|
||||
License Quota: 10 Active Seats
|
||||
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||
============================================================
|
||||
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||
```
|
||||
|
||||
### Step 2: Configuration Fields Reference
|
||||
The generated `server_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_name": "LOGAR-Windows-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
||||
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||
"max_seats": 10,
|
||||
"cert_dir": "certs",
|
||||
"tls_enabled": true,
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 1,
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Exporting Client Configurations
|
||||
|
||||
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||
```powershell
|
||||
.\Server.exe --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||
|
||||
### Accessing the WebUI
|
||||
Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8443/webui
|
||||
```
|
||||
The WebUI provides:
|
||||
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||
|
||||
### CLI Licensing Utilities
|
||||
Export a `.lic` request file via CLI:
|
||||
```powershell
|
||||
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
|
||||
```
|
||||
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||
```powershell
|
||||
.\Server.exe --import-lic C:\LOGAR-Server\activated.lic
|
||||
# Or alternatively with --import-ca:
|
||||
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
||||
```
|
||||
Synchronize license and check for seat quota increases against the master authority:
|
||||
```powershell
|
||||
.\Server.exe --sync-license
|
||||
```
|
||||
|
||||
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`), multi-tenant fleet isolation, user management, and authenticated WebUI dashboard access.
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual Windows Service Setup (via NSSM)
|
||||
|
||||
```powershell
|
||||
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
||||
.\nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
||||
.\nssm.exe set LOGAR_Server AppStdout "C:\ProgramData\LOGAR-Server\server.log"
|
||||
.\nssm.exe set LOGAR_Server AppStderr "C:\ProgramData\LOGAR-Server\server_err.log"
|
||||
.\nssm.exe start LOGAR_Server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Uninstallation
|
||||
|
||||
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
||||
|
||||
If installed manually via NSSM:
|
||||
```powershell
|
||||
.\nssm.exe stop LOGAR_Server
|
||||
.\nssm.exe remove LOGAR_Server confirm
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"server_name": "LOGAR-Windows-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "replace_with_secure_random_hex_token",
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 4,
|
||||
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,447 +1,72 @@
|
||||
"""
|
||||
LOGAR Linux Edge Log Forwarder
|
||||
Collects Linux systemd journalctl / syslog entries and securely streams them
|
||||
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
import argparse
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# Ensure script directory and repo root are in sys.path
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
|
||||
if REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import pgpy
|
||||
|
||||
from log_collector import (
|
||||
get_machine_identifier,
|
||||
is_path_writable,
|
||||
get_default_data_dir,
|
||||
resolve_writable_path,
|
||||
get_state_path,
|
||||
load_state,
|
||||
save_state,
|
||||
commit_state,
|
||||
LinuxLogCollector,
|
||||
BaseLogCollector
|
||||
)
|
||||
from client_transport import (
|
||||
is_cert_expiring_soon,
|
||||
enroll_client_if_needed,
|
||||
get_tls_socket,
|
||||
load_config,
|
||||
ensure_enrolled as transport_ensure_enrolled,
|
||||
send_encrypted_logs_over_socket as transport_send_encrypted_logs
|
||||
)
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||
if not os.path.exists(cert_path):
|
||||
return True
|
||||
try:
|
||||
from cryptography import x509
|
||||
with open(cert_path, "r", encoding="utf-8") as f:
|
||||
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||
if expiry is None:
|
||||
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
return expiry <= (now + timedelta(days=threshold_days))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def enroll_client_if_needed(
|
||||
hub_url: str,
|
||||
enrollment_secret: str,
|
||||
cert_dir: str,
|
||||
client_id: str,
|
||||
hostname: str,
|
||||
os_type: str = "linux",
|
||||
force_renew: bool = False,
|
||||
threshold_days: int = 30
|
||||
):
|
||||
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||
return True
|
||||
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||
|
||||
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": os_type,
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
enroll_endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
with open(ca_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["ca_cert"])
|
||||
with open(cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_cert"])
|
||||
with open(key_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_key"])
|
||||
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||
return True
|
||||
|
||||
|
||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
ctx.check_hostname = False
|
||||
|
||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||
|
||||
|
||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||
if custom_state_path:
|
||||
return custom_state_path
|
||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||
|
||||
|
||||
def load_state(state_path: str) -> dict:
|
||||
if os.path.exists(state_path):
|
||||
try:
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state_path: str, state: dict):
|
||||
try:
|
||||
temp_path = f"{state_path}.tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
os.replace(temp_path, state_path)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
||||
|
||||
|
||||
def commit_state(state: dict, state_path: str):
|
||||
if "new_last_cursor" in state:
|
||||
val = state.pop("new_last_cursor")
|
||||
if val:
|
||||
state["last_cursor"] = val
|
||||
if "new_last_timestamp_us" in state:
|
||||
val = state.pop("new_last_timestamp_us")
|
||||
if val:
|
||||
state["last_timestamp_us"] = val
|
||||
if "new_sent_cursors" in state:
|
||||
state["sent_cursors"] = state.pop("new_sent_cursors")
|
||||
save_state(state_path, state)
|
||||
|
||||
|
||||
def load_config(config_path: str = CONFIG_FILE_NAME):
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Client configuration file not found at: {config_path}\n"
|
||||
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
||||
)
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_machine_identifier() -> str:
|
||||
"""
|
||||
Returns the hostname of the machine sending the logs,
|
||||
and appends the network/DNS domain if available.
|
||||
"""
|
||||
# 1. Try fully-qualified domain name (FQDN)
|
||||
fqdn = socket.getfqdn()
|
||||
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
||||
return fqdn
|
||||
|
||||
hostname = socket.gethostname()
|
||||
|
||||
# 2. Check /etc/resolv.conf domain or search directive
|
||||
try:
|
||||
if os.path.exists("/etc/resolv.conf"):
|
||||
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
||||
domain = parts[1]
|
||||
if domain and not domain.startswith("."):
|
||||
return f"{hostname}.{domain}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Try reverse DNS lookup
|
||||
try:
|
||||
host_ip = socket.gethostbyname(hostname)
|
||||
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
||||
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
||||
return canonical_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return hostname
|
||||
|
||||
|
||||
def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
||||
"""
|
||||
Collects info, warnings, and errors from systemd journalctl over the lookback window.
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG (priority 7) and skips events older than lookback window (default: 24h).
|
||||
State Tracking: Skips events older than lookback window (default 24h) and events
|
||||
that have already been sent in previous runs.
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG noise.
|
||||
State Tracking: Skips events older than lookback window and already sent cursors.
|
||||
"""
|
||||
last_cursor = None
|
||||
last_timestamp_us = 0.0
|
||||
sent_cursors = set()
|
||||
if state:
|
||||
last_cursor = state.get("last_cursor")
|
||||
try:
|
||||
last_timestamp_us = float(state.get("last_timestamp_us", 0))
|
||||
except (ValueError, TypeError):
|
||||
last_timestamp_us = 0.0
|
||||
sent_cursors = set(state.get("sent_cursors", []))
|
||||
|
||||
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||
result = None
|
||||
|
||||
if last_cursor:
|
||||
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
|
||||
try:
|
||||
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
|
||||
if res.returncode == 0:
|
||||
result = res
|
||||
except FileNotFoundError:
|
||||
print("[!] journalctl command not found. Ensure this script runs on a systemd-enabled Linux system.")
|
||||
return []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result is None:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError:
|
||||
print("[!] journalctl command not found. Ensure this script runs on a systemd-enabled Linux system.")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"[!] Error running journalctl: {e}")
|
||||
return []
|
||||
|
||||
logs = []
|
||||
machine_id = get_machine_identifier()
|
||||
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
|
||||
|
||||
newest_cursor = None
|
||||
newest_timestamp_us = last_timestamp_us
|
||||
collected_cursors = []
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line_str)
|
||||
entry_cursor = entry.get("__CURSOR")
|
||||
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
|
||||
|
||||
entry_ts_us = 0.0
|
||||
if entry_ts_us_raw:
|
||||
try:
|
||||
entry_ts_us = float(entry_ts_us_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 1. Skip entries older than lookback window (default: 24h)
|
||||
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
|
||||
continue
|
||||
|
||||
# 2. Skip already sent events
|
||||
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
|
||||
continue
|
||||
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
|
||||
continue
|
||||
|
||||
# Advance newest tracking for new entries
|
||||
if entry_cursor:
|
||||
newest_cursor = entry_cursor
|
||||
collected_cursors.append(entry_cursor)
|
||||
if entry_ts_us > newest_timestamp_us:
|
||||
newest_timestamp_us = entry_ts_us
|
||||
|
||||
priority = int(entry.get("PRIORITY", "6"))
|
||||
# Priority 0: Emerg, 1: Alert, 2: Crit, 3: Err (-> ERROR)
|
||||
# Priority 4: Warning, 5: Notice (-> WARNING)
|
||||
# Priority 6: Info (-> INFO)
|
||||
# Priority 7: Debug (skip)
|
||||
if priority > 6:
|
||||
continue
|
||||
|
||||
if priority <= 3:
|
||||
sev = "ERROR"
|
||||
elif priority in (4, 5):
|
||||
sev = "WARNING"
|
||||
else:
|
||||
sev = "INFO"
|
||||
|
||||
logs.append({
|
||||
"server": machine_id,
|
||||
"os_type": "linux",
|
||||
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
||||
"severity": sev,
|
||||
"message": entry.get("MESSAGE", "")[:2048]
|
||||
})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if state is not None:
|
||||
state["new_last_cursor"] = newest_cursor or last_cursor
|
||||
state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
|
||||
state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
|
||||
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return logs
|
||||
collector = LinuxLogCollector(state=state)
|
||||
return collector.collect_logs(hours=hours)
|
||||
|
||||
|
||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
"""
|
||||
Streams logs to the LOGAR hub.
|
||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||
or falls back to OpenPGP encrypted envelope over TCP.
|
||||
"""
|
||||
server_host = config["server_host"]
|
||||
server_port = int(config["server_port"])
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
machine_id = get_machine_identifier()
|
||||
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||
return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux")
|
||||
|
||||
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||
hub_url = None
|
||||
if enrollment_secret:
|
||||
hermes_host = config.get("hermes_host", server_host)
|
||||
if hermes_host in ("0.0.0.0", "::", None):
|
||||
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||
hermes_port = config.get("hermes_port", 8443)
|
||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||
|
||||
if has_mtls_certs:
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||
sock = None
|
||||
try:
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||
if enrollment_secret and hub_url:
|
||||
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux", force_renew=True)
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except Exception as retry_err:
|
||||
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
with sock:
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed mTLS connection without response.")
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
|
||||
# Fallback to OpenPGP envelope over plain TCP socket
|
||||
auth_token = config.get("auth_token", "")
|
||||
pub_key_armored = config.get("server_public_key")
|
||||
if not pub_key_armored:
|
||||
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||
|
||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||
if expected_fp and actual_fp != expected_fp:
|
||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_json = json.dumps(payload)
|
||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||
encrypted_armored = str(encrypted_msg)
|
||||
|
||||
envelope = {
|
||||
"auth_token": auth_token,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"encrypted_payload": encrypted_armored
|
||||
}
|
||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(15.0)
|
||||
sock.connect((server_host, server_port))
|
||||
|
||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed connection without response.")
|
||||
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="linux")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -450,6 +75,7 @@ def main():
|
||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)")
|
||||
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
||||
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
||||
parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
@@ -461,31 +87,52 @@ def main():
|
||||
state_path = get_state_path(args.config, args.state_file)
|
||||
state = None if args.no_state else load_state(state_path)
|
||||
|
||||
machine_id = get_machine_identifier()
|
||||
machine_id = config.get("client_id") or get_machine_identifier()
|
||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||
if state and ("last_cursor" in state or "last_timestamp_us" in state):
|
||||
print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})")
|
||||
elif not args.no_state:
|
||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||
|
||||
print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...")
|
||||
candidate_logs = get_recent_linux_logs(hours=args.hours, state=state)
|
||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
|
||||
ensure_enrolled(config, machine_id)
|
||||
|
||||
if not candidate_logs:
|
||||
print("[*] No new unsent events to transmit.")
|
||||
if state is not None:
|
||||
commit_state(state, state_path)
|
||||
return
|
||||
collector = LinuxLogCollector(state=state, machine_id=machine_id)
|
||||
|
||||
try:
|
||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
||||
if state is not None and resp and resp.get("status") == "success":
|
||||
commit_state(state, state_path)
|
||||
print(f"[+] State successfully committed to {state_path}")
|
||||
except Exception as e:
|
||||
print(f"[!] Failed to stream logs to server: {e}")
|
||||
sys.exit(1)
|
||||
def run_forwarder_cycle():
|
||||
# Continuously verify/renew PKI enrollment if server is licensed & online
|
||||
ensure_enrolled(config, machine_id)
|
||||
|
||||
print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...")
|
||||
candidate_logs = collector.collect_logs(hours=args.hours)
|
||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||
|
||||
if not candidate_logs:
|
||||
print("[*] No new unsent events to transmit.")
|
||||
if state is not None:
|
||||
commit_state(state, state_path)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
||||
if state is not None and resp and resp.get("status") == "success":
|
||||
commit_state(state, state_path)
|
||||
print(f"[+] State successfully committed to {state_path}")
|
||||
except Exception as e:
|
||||
print(f"[!] Failed to stream logs to server: {e}")
|
||||
if not args.interval:
|
||||
sys.exit(1)
|
||||
|
||||
if args.interval and args.interval > 0:
|
||||
print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
|
||||
while True:
|
||||
try:
|
||||
run_forwarder_cycle()
|
||||
except Exception as e:
|
||||
print(f"[!] Error in forwarder cycle: {e}")
|
||||
time.sleep(args.interval)
|
||||
else:
|
||||
run_forwarder_cycle()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,540 +1,144 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import argparse
|
||||
import urllib.request
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import pgpy
|
||||
|
||||
try:
|
||||
import win32evtlog
|
||||
except ImportError:
|
||||
win32evtlog = None
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||
if not os.path.exists(cert_path):
|
||||
return True
|
||||
try:
|
||||
from cryptography import x509
|
||||
with open(cert_path, "r", encoding="utf-8") as f:
|
||||
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||
if expiry is None:
|
||||
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
return expiry <= (now + timedelta(days=threshold_days))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def enroll_client_if_needed(
|
||||
hub_url: str,
|
||||
enrollment_secret: str,
|
||||
cert_dir: str,
|
||||
client_id: str,
|
||||
hostname: str,
|
||||
os_type: str = "windows",
|
||||
force_renew: bool = False,
|
||||
threshold_days: int = 30
|
||||
):
|
||||
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||
return True
|
||||
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||
|
||||
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": os_type,
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
enroll_endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
with open(ca_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["ca_cert"])
|
||||
with open(cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_cert"])
|
||||
with open(key_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_key"])
|
||||
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||
return True
|
||||
|
||||
|
||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
ctx.check_hostname = False
|
||||
|
||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||
|
||||
|
||||
def is_path_writable(filepath: str) -> bool:
|
||||
"""Checks if a file or directory path is actually writable by the current process."""
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
if os.path.isdir(filepath):
|
||||
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
||||
with open(test_f, "w") as f:
|
||||
f.write("1")
|
||||
os.remove(test_f)
|
||||
return True
|
||||
else:
|
||||
with open(filepath, "a") as f:
|
||||
pass
|
||||
return True
|
||||
else:
|
||||
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
||||
with open(test_f, "w") as f:
|
||||
f.write("1")
|
||||
os.remove(test_f)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_default_data_dir(app_name: str = "LOGAR") -> str:
|
||||
"""Returns a suitable writable application data directory."""
|
||||
if sys.platform == "win32":
|
||||
# 1. ProgramData (C:\ProgramData\LOGAR)
|
||||
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
||||
target_prog = os.path.join(prog_data, app_name)
|
||||
if is_path_writable(target_prog):
|
||||
return target_prog
|
||||
|
||||
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
|
||||
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
||||
target_local = os.path.join(local_app, app_name)
|
||||
if is_path_writable(target_local):
|
||||
return target_local
|
||||
|
||||
# 3. Temp directory (%TEMP%\LOGAR)
|
||||
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
||||
if is_path_writable(temp_dir):
|
||||
return temp_dir
|
||||
|
||||
cwd = os.getcwd()
|
||||
if is_path_writable(cwd):
|
||||
return cwd
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
|
||||
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
||||
if not path:
|
||||
path = default_filename
|
||||
|
||||
if os.path.isabs(path) and is_path_writable(path):
|
||||
return path
|
||||
|
||||
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
||||
return os.path.abspath(path)
|
||||
|
||||
data_dir = get_default_data_dir(app_name)
|
||||
fname = os.path.basename(path)
|
||||
return os.path.join(data_dir, fname)
|
||||
|
||||
|
||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||
if custom_state_path:
|
||||
return custom_state_path
|
||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
|
||||
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
|
||||
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||
|
||||
|
||||
def load_state(state_path: str) -> dict:
|
||||
if os.path.exists(state_path):
|
||||
try:
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state_path: str, state: dict):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
|
||||
temp_path = f"{state_path}.tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
os.replace(temp_path, state_path)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
||||
|
||||
|
||||
def commit_state(state: dict, state_path: str):
|
||||
if "new_last_record_number" in state:
|
||||
val = state.pop("new_last_record_number")
|
||||
if val:
|
||||
state["last_record_number"] = val
|
||||
if "new_sent_record_ids" in state:
|
||||
state["sent_record_ids"] = state.pop("new_sent_record_ids")
|
||||
save_state(state_path, state)
|
||||
|
||||
|
||||
def load_config(config_path: str = CONFIG_FILE_NAME):
|
||||
actual_config_path = config_path
|
||||
if not os.path.exists(actual_config_path):
|
||||
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
|
||||
if os.path.exists(data_dir_cfg):
|
||||
actual_config_path = data_dir_cfg
|
||||
|
||||
if not os.path.exists(actual_config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Client configuration file not found at: {config_path}\n"
|
||||
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
||||
)
|
||||
with open(actual_config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
if "cert_dir" in config:
|
||||
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR", "certs")
|
||||
return config
|
||||
|
||||
|
||||
def get_machine_identifier() -> str:
|
||||
"""
|
||||
Returns the hostname of the machine sending the logs,
|
||||
and appends the network/DNS domain if available.
|
||||
"""
|
||||
fqdn = socket.getfqdn()
|
||||
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
||||
return fqdn
|
||||
|
||||
hostname = socket.gethostname()
|
||||
user_dns_domain = os.environ.get("USERDNSDOMAIN")
|
||||
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
|
||||
return f"{hostname}.{user_dns_domain.lower()}"
|
||||
|
||||
try:
|
||||
host_ip = socket.gethostbyname(hostname)
|
||||
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
||||
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
||||
return canonical_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return hostname
|
||||
|
||||
|
||||
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
||||
"""
|
||||
Scans the Windows Application Event Log backwards for events within the window.
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||
State Tracking: Skips events older than lookback window (default 24h) and events
|
||||
that have already been sent in previous runs.
|
||||
"""
|
||||
if win32evtlog is None:
|
||||
print("[!] pywin32 is not installed or not running on Windows. Returning mock/empty candidate list.")
|
||||
return []
|
||||
|
||||
server = "localhost"
|
||||
log_type = "Application"
|
||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||
|
||||
try:
|
||||
hand = win32evtlog.OpenEventLog(server, log_type)
|
||||
except Exception as e:
|
||||
print(f"[!] Error opening Windows event log: {e}")
|
||||
return []
|
||||
|
||||
logs = []
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||
machine_id = get_machine_identifier()
|
||||
|
||||
last_record_number = 0
|
||||
sent_record_ids = set()
|
||||
if state:
|
||||
last_record_number = int(state.get("last_record_number", 0))
|
||||
sent_record_ids = set(state.get("sent_record_ids", []))
|
||||
|
||||
# Windows Event Log EventTypes:
|
||||
# 1: EVENTLOG_ERROR_TYPE -> ERROR
|
||||
# 2: EVENTLOG_WARNING_TYPE -> WARNING
|
||||
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
|
||||
# Excludes: 8 (Audit Success), 16 (Audit Failure), and other verbose noise
|
||||
sev_map = {
|
||||
1: "ERROR",
|
||||
2: "WARNING",
|
||||
4: "INFO"
|
||||
}
|
||||
|
||||
newest_record_number = 0
|
||||
collected_record_ids = []
|
||||
|
||||
while True:
|
||||
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||
if not events:
|
||||
break
|
||||
|
||||
for event in events:
|
||||
rec_num = int(event.RecordNumber)
|
||||
if newest_record_number == 0:
|
||||
newest_record_number = rec_num
|
||||
|
||||
# 1. Skip entries older than lookback window (default: 24h)
|
||||
if event.TimeGenerated < cutoff_time:
|
||||
break
|
||||
|
||||
# 2. Skip already sent events if we've reached records <= last_record_number
|
||||
# (unless the log was cleared and numbers wrapped, i.e. newest_record_number < last_record_number)
|
||||
if last_record_number > 0 and newest_record_number >= last_record_number:
|
||||
if rec_num <= last_record_number:
|
||||
break
|
||||
|
||||
rec_id = f"{rec_num}:{event.TimeGenerated.isoformat()}"
|
||||
if rec_id in sent_record_ids:
|
||||
continue
|
||||
|
||||
# Filter: upload everything from INFO to ERROR only
|
||||
if event.EventType in sev_map:
|
||||
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
|
||||
logs.append({
|
||||
"server": machine_id,
|
||||
"os_type": "windows",
|
||||
"signature": event.SourceName or "Windows-Event",
|
||||
"severity": sev_map[event.EventType],
|
||||
"message": msg[:2048] # Limit message length
|
||||
})
|
||||
collected_record_ids.append(rec_id)
|
||||
|
||||
if events[-1].TimeGenerated < cutoff_time:
|
||||
break
|
||||
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
|
||||
break
|
||||
|
||||
win32evtlog.CloseEventLog(hand)
|
||||
|
||||
if state is not None:
|
||||
target_rec = max(newest_record_number, last_record_number)
|
||||
state["new_last_record_number"] = target_rec
|
||||
state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
|
||||
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
"""
|
||||
Streams logs to the LOGAR hub.
|
||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||
or falls back to OpenPGP encrypted envelope over TCP.
|
||||
"""
|
||||
server_host = config["server_host"]
|
||||
server_port = int(config["server_port"])
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
machine_id = get_machine_identifier()
|
||||
|
||||
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||
hub_url = None
|
||||
if enrollment_secret:
|
||||
hermes_host = config.get("hermes_host", server_host)
|
||||
if hermes_host in ("0.0.0.0", "::", None):
|
||||
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||
hermes_port = config.get("hermes_port", 8443)
|
||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||
|
||||
if has_mtls_certs:
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||
sock = None
|
||||
try:
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||
if enrollment_secret and hub_url:
|
||||
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows", force_renew=True)
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except Exception as retry_err:
|
||||
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
with sock:
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed mTLS connection without response.")
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
|
||||
# Fallback to OpenPGP envelope over plain TCP socket
|
||||
auth_token = config.get("auth_token", "")
|
||||
pub_key_armored = config.get("server_public_key")
|
||||
if not pub_key_armored:
|
||||
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||
|
||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||
if expected_fp and actual_fp != expected_fp:
|
||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_json = json.dumps(payload)
|
||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||
encrypted_armored = str(encrypted_msg)
|
||||
|
||||
envelope = {
|
||||
"auth_token": auth_token,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"encrypted_payload": encrypted_armored
|
||||
}
|
||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(15.0)
|
||||
sock.connect((server_host, server_port))
|
||||
|
||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed connection without response.")
|
||||
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
||||
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
||||
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
||||
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as e:
|
||||
print(f"[!] Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
state_path = get_state_path(args.config, args.state_file)
|
||||
state = None if args.no_state else load_state(state_path)
|
||||
|
||||
machine_id = get_machine_identifier()
|
||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||
if state and "last_record_number" in state:
|
||||
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
|
||||
elif not args.no_state:
|
||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||
|
||||
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
||||
candidate_logs = get_recent_windows_logs(hours=args.hours, state=state)
|
||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||
|
||||
if not candidate_logs:
|
||||
print("[*] No new unsent events to transmit.")
|
||||
if state is not None:
|
||||
commit_state(state, state_path)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
||||
if state is not None and resp and resp.get("status") == "success":
|
||||
commit_state(state, state_path)
|
||||
print(f"[+] State successfully committed to {state_path}")
|
||||
except Exception as e:
|
||||
print(f"[!] Failed to stream logs to server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
LOGAR Windows Edge Log Forwarder
|
||||
Collects Windows Event Logs (Application/System) and securely streams them
|
||||
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
import warnings
|
||||
from typing import Optional
|
||||
|
||||
# Ensure script directory and repo root are in sys.path
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
|
||||
if REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import pgpy
|
||||
|
||||
try:
|
||||
import win32evtlog
|
||||
except ImportError:
|
||||
win32evtlog = None
|
||||
|
||||
from log_collector import (
|
||||
get_machine_identifier,
|
||||
is_path_writable,
|
||||
get_default_data_dir,
|
||||
resolve_writable_path,
|
||||
get_state_path,
|
||||
load_state,
|
||||
save_state,
|
||||
commit_state,
|
||||
WindowsLogCollector,
|
||||
BaseLogCollector
|
||||
)
|
||||
from client_transport import (
|
||||
is_cert_expiring_soon,
|
||||
enroll_client_if_needed,
|
||||
get_tls_socket,
|
||||
load_config,
|
||||
ensure_enrolled as transport_ensure_enrolled,
|
||||
send_encrypted_logs_over_socket as transport_send_encrypted_logs
|
||||
)
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
||||
"""
|
||||
Scans the Windows Application Event Log backwards for events within the window.
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||
State Tracking: Skips events older than lookback window and already sent records.
|
||||
"""
|
||||
collector = WindowsLogCollector(state=state, channels=["Application"])
|
||||
return collector.collect_logs(hours=hours)
|
||||
|
||||
|
||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||
return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows")
|
||||
|
||||
|
||||
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
||||
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
||||
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
||||
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
||||
parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as e:
|
||||
print(f"[!] Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
state_path = get_state_path(args.config, args.state_file)
|
||||
state = None if args.no_state else load_state(state_path)
|
||||
|
||||
machine_id = config.get("client_id") or get_machine_identifier()
|
||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||
if state and "last_record_number" in state:
|
||||
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
|
||||
elif not args.no_state:
|
||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||
|
||||
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
|
||||
ensure_enrolled(config, machine_id)
|
||||
|
||||
collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"])
|
||||
|
||||
def run_forwarder_cycle():
|
||||
# Continuously verify/renew PKI enrollment if server is licensed & online
|
||||
ensure_enrolled(config, machine_id)
|
||||
|
||||
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
||||
candidate_logs = collector.collect_logs(hours=args.hours)
|
||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||
|
||||
if not candidate_logs:
|
||||
print("[*] No new unsent events to transmit.")
|
||||
if state is not None:
|
||||
commit_state(state, state_path)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
||||
if state is not None and resp and resp.get("status") == "success":
|
||||
commit_state(state, state_path)
|
||||
print(f"[+] State successfully committed to {state_path}")
|
||||
except Exception as e:
|
||||
print(f"[!] Failed to stream logs to server: {e}")
|
||||
if not args.interval:
|
||||
sys.exit(1)
|
||||
|
||||
if args.interval and args.interval > 0:
|
||||
print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
|
||||
while True:
|
||||
try:
|
||||
run_forwarder_cycle()
|
||||
except Exception as e:
|
||||
print(f"[!] Error in forwarder cycle: {e}")
|
||||
time.sleep(args.interval)
|
||||
else:
|
||||
run_forwarder_cycle()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
LOGAR - Unified Client Transport & PKI Enrollment Module
|
||||
Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing,
|
||||
and dynamic certificate enrollment with the LOGAR Server Hub.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import urllib.request
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
import pgpy
|
||||
from log_collector import (
|
||||
get_machine_identifier,
|
||||
is_path_writable,
|
||||
get_default_data_dir,
|
||||
resolve_writable_path
|
||||
)
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
|
||||
|
||||
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||
if not os.path.exists(cert_path):
|
||||
return True
|
||||
try:
|
||||
from cryptography import x509
|
||||
with open(cert_path, "r", encoding="utf-8") as f:
|
||||
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||
if expiry is None:
|
||||
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
return expiry <= (now + timedelta(days=threshold_days))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def enroll_client_if_needed(
|
||||
hub_url: str,
|
||||
enrollment_secret: str,
|
||||
cert_dir: str,
|
||||
client_id: str,
|
||||
hostname: str,
|
||||
os_type: str = "windows",
|
||||
force_renew: bool = False,
|
||||
threshold_days: int = 30
|
||||
) -> bool:
|
||||
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||
return True
|
||||
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||
|
||||
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": os_type,
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
enroll_endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
with open(ca_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["ca_cert"])
|
||||
with open(cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_cert"])
|
||||
with open(key_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_key"])
|
||||
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||
return True
|
||||
|
||||
|
||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
ctx.check_hostname = False
|
||||
|
||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||
|
||||
|
||||
def load_config(config_path: str = CONFIG_FILE_NAME) -> dict:
|
||||
"""Loads and normalizes client configuration JSON."""
|
||||
actual_config_path = config_path
|
||||
if not os.path.exists(actual_config_path):
|
||||
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
|
||||
if os.path.exists(data_dir_cfg):
|
||||
actual_config_path = data_dir_cfg
|
||||
|
||||
if not os.path.exists(actual_config_path):
|
||||
raise FileNotFoundError(
|
||||
f"Client configuration file not found at: {config_path}\n"
|
||||
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
||||
)
|
||||
with open(actual_config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Resolve cert_dir to a writable directory
|
||||
cfg_dir = os.path.dirname(os.path.abspath(actual_config_path))
|
||||
raw_cert_dir = config.get("cert_dir", "certs")
|
||||
if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir):
|
||||
config["cert_dir"] = raw_cert_dir
|
||||
else:
|
||||
candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir))
|
||||
if is_path_writable(candidate) or is_path_writable(cfg_dir):
|
||||
config["cert_dir"] = candidate
|
||||
else:
|
||||
config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs")
|
||||
|
||||
# If ca_cert is present in config and ca.crt doesn't exist yet, save it
|
||||
ca_cert_pem = config.get("ca_cert")
|
||||
if ca_cert_pem and isinstance(ca_cert_pem, str):
|
||||
ca_file = os.path.join(config["cert_dir"], "ca.crt")
|
||||
if not os.path.exists(ca_file):
|
||||
try:
|
||||
os.makedirs(config["cert_dir"], exist_ok=True)
|
||||
with open(ca_file, "w", encoding="utf-8") as f:
|
||||
f.write(ca_cert_pem)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool:
|
||||
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
if not enrollment_secret:
|
||||
return False
|
||||
|
||||
server_host = config.get("server_host", "127.0.0.1")
|
||||
hermes_host = config.get("hermes_host", server_host)
|
||||
if hermes_host in ("0.0.0.0", "::", None, ""):
|
||||
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1"
|
||||
hermes_port = config.get("hermes_port", 8443)
|
||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||
try:
|
||||
return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_encrypted_logs_over_socket(
|
||||
config: dict,
|
||||
logs: list,
|
||||
os_type: Optional[str] = None,
|
||||
machine_id: Optional[str] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Streams logs to the LOGAR hub.
|
||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||
or falls back to OpenPGP encrypted envelope over TCP socket.
|
||||
"""
|
||||
node_os = os_type or ("windows" if sys.platform == "win32" else "linux")
|
||||
server_host = config["server_host"]
|
||||
server_port = int(config["server_port"])
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier()
|
||||
|
||||
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||
hub_url = None
|
||||
if enrollment_secret:
|
||||
hermes_host = config.get("hermes_host", server_host)
|
||||
if hermes_host in ("0.0.0.0", "::", None):
|
||||
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||
hermes_port = config.get("hermes_port", 8443)
|
||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||
|
||||
if not has_mtls_certs and enrollment_secret and hub_url:
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Enrollment before TLS connection failed: {e}")
|
||||
|
||||
if has_mtls_certs:
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||
sock = None
|
||||
try:
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||
if enrollment_secret and hub_url:
|
||||
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||
try:
|
||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True)
|
||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||
except Exception as retry_err:
|
||||
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
with sock:
|
||||
payload = {
|
||||
"server": actual_machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed mTLS connection without response.")
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
|
||||
# Fallback to OpenPGP envelope over plain TCP socket
|
||||
auth_token = config.get("auth_token", "")
|
||||
pub_key_armored = config.get("server_public_key")
|
||||
if not pub_key_armored:
|
||||
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||
|
||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||
if expected_fp and actual_fp != expected_fp:
|
||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||
|
||||
payload = {
|
||||
"server": actual_machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_json = json.dumps(payload)
|
||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||
encrypted_armored = str(encrypted_msg)
|
||||
|
||||
envelope = {
|
||||
"auth_token": auth_token,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"encrypted_payload": encrypted_armored
|
||||
}
|
||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(15.0)
|
||||
sock.connect((server_host, server_port))
|
||||
|
||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed connection without response.")
|
||||
|
||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||
resp_bytes = bytearray()
|
||||
while len(resp_bytes) < resp_len:
|
||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
resp_bytes.extend(chunk)
|
||||
|
||||
response = json.loads(resp_bytes.decode("utf-8"))
|
||||
print(f"[+] Server response: {response}")
|
||||
return response
|
||||
@@ -0,0 +1,542 @@
|
||||
"""
|
||||
LOGAR - Unified Cross-Platform Log Collector & State Management
|
||||
Provides modular log collection classes and state management for Windows and Linux,
|
||||
reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List, Dict, Any, Set
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
try:
|
||||
import win32evtlog
|
||||
except ImportError:
|
||||
win32evtlog = None
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def get_machine_identifier() -> str:
|
||||
"""
|
||||
Returns the fully qualified hostname of the machine sending/collecting logs.
|
||||
Cross-platform resolution:
|
||||
1. FQDN (if non-local)
|
||||
2. Windows USERDNSDOMAIN (on Windows)
|
||||
3. Linux /etc/resolv.conf domain/search directive (on Linux)
|
||||
4. Reverse DNS lookup
|
||||
5. socket.gethostname() fallback
|
||||
"""
|
||||
fqdn = socket.getfqdn()
|
||||
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
||||
return fqdn
|
||||
|
||||
hostname = socket.gethostname()
|
||||
|
||||
# Windows DNS domain
|
||||
if sys.platform == "win32":
|
||||
user_dns_domain = os.environ.get("USERDNSDOMAIN")
|
||||
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
|
||||
return f"{hostname}.{user_dns_domain.lower()}"
|
||||
|
||||
# Linux resolv.conf search / domain
|
||||
if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"):
|
||||
try:
|
||||
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
||||
domain = parts[1]
|
||||
if domain and not domain.startswith("."):
|
||||
return f"{hostname}.{domain}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reverse DNS lookup
|
||||
try:
|
||||
host_ip = socket.gethostbyname(hostname)
|
||||
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
||||
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
||||
return canonical_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return hostname
|
||||
|
||||
|
||||
def is_path_writable(filepath: str) -> bool:
|
||||
"""Checks if a file or directory path is actually writable by the current process."""
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
if os.path.isdir(filepath):
|
||||
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
||||
with open(test_f, "w") as f:
|
||||
f.write("1")
|
||||
os.remove(test_f)
|
||||
return True
|
||||
else:
|
||||
with open(filepath, "a") as f:
|
||||
pass
|
||||
return True
|
||||
else:
|
||||
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
||||
with open(test_f, "w") as f:
|
||||
f.write("1")
|
||||
os.remove(test_f)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_default_data_dir(app_name: str = "LOGAR") -> str:
|
||||
"""Returns a suitable writable application data directory."""
|
||||
if sys.platform == "win32":
|
||||
# 1. ProgramData (C:\ProgramData\LOGAR)
|
||||
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
||||
target_prog = os.path.join(prog_data, app_name)
|
||||
if is_path_writable(target_prog):
|
||||
return target_prog
|
||||
|
||||
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
|
||||
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
||||
target_local = os.path.join(local_app, app_name)
|
||||
if is_path_writable(target_local):
|
||||
return target_local
|
||||
|
||||
# 3. Temp directory (%TEMP%\LOGAR)
|
||||
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
||||
if is_path_writable(temp_dir):
|
||||
return temp_dir
|
||||
else:
|
||||
# Linux / Unix candidates: /var/lib/<app_name>, /tmp/<app_name>, ~/.local/share/<app_name>
|
||||
var_lib = f"/var/lib/{app_name.lower()}"
|
||||
if is_path_writable(var_lib):
|
||||
return var_lib
|
||||
user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}")
|
||||
if is_path_writable(user_share):
|
||||
return user_share
|
||||
|
||||
cwd = os.getcwd()
|
||||
if is_path_writable(cwd):
|
||||
return cwd
|
||||
return os.path.expanduser("~")
|
||||
|
||||
|
||||
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
|
||||
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
||||
if not path:
|
||||
path = default_filename
|
||||
|
||||
if os.path.isabs(path) and is_path_writable(path):
|
||||
return path
|
||||
|
||||
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
||||
return os.path.abspath(path)
|
||||
|
||||
data_dir = get_default_data_dir(app_name)
|
||||
fname = os.path.basename(path)
|
||||
return os.path.join(data_dir, fname)
|
||||
|
||||
|
||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||
if custom_state_path:
|
||||
return custom_state_path
|
||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
|
||||
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
|
||||
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||
|
||||
|
||||
def load_state(state_path: str) -> dict:
|
||||
"""Loads state tracking dictionary from disk."""
|
||||
if os.path.exists(state_path):
|
||||
try:
|
||||
with open(state_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state_path: str, state: dict):
|
||||
"""Atomically saves state dictionary to disk."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
|
||||
temp_path = f"{state_path}.tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
os.replace(temp_path, state_path)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
||||
|
||||
|
||||
def commit_state(state: dict, state_path: str):
|
||||
"""
|
||||
Commits staged cursor/record state keys to persistent fields and writes to state_path.
|
||||
Supports both Windows record numbers and Linux cursors/timestamps.
|
||||
"""
|
||||
if "new_last_record_number" in state:
|
||||
val = state.pop("new_last_record_number")
|
||||
if val:
|
||||
state["last_record_number"] = val
|
||||
if "new_sent_record_ids" in state:
|
||||
state["sent_record_ids"] = state.pop("new_sent_record_ids")
|
||||
if "new_last_cursor" in state:
|
||||
val = state.pop("new_last_cursor")
|
||||
if val:
|
||||
state["last_cursor"] = val
|
||||
if "new_last_timestamp_us" in state:
|
||||
val = state.pop("new_last_timestamp_us")
|
||||
if val:
|
||||
state["last_timestamp_us"] = val
|
||||
if "new_sent_cursors" in state:
|
||||
state["sent_cursors"] = state.pop("new_sent_cursors")
|
||||
save_state(state_path, state)
|
||||
|
||||
|
||||
class BaseLogCollector(ABC):
|
||||
"""
|
||||
Abstract base class for platform-specific edge and hub log collectors.
|
||||
Manages state persistence, machine identifiers, lookback windows, and normalization.
|
||||
"""
|
||||
|
||||
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||
self.state = state if state is not None else {}
|
||||
self.machine_id = machine_id or get_machine_identifier()
|
||||
|
||||
@abstractmethod
|
||||
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Collects normalized logs within the given lookback window.
|
||||
Returns a list of dicts:
|
||||
[
|
||||
{
|
||||
"server": str,
|
||||
"os_type": "windows"|"linux",
|
||||
"signature": str,
|
||||
"severity": "ERROR"|"WARNING"|"INFO",
|
||||
"message": str
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
pass
|
||||
|
||||
def commit(self, state_path: Optional[str] = None):
|
||||
"""Commits staged state keys and optionally writes to disk."""
|
||||
if state_path:
|
||||
commit_state(self.state, state_path)
|
||||
else:
|
||||
if "new_last_record_number" in self.state:
|
||||
val = self.state.pop("new_last_record_number")
|
||||
if val:
|
||||
self.state["last_record_number"] = val
|
||||
if "new_sent_record_ids" in self.state:
|
||||
self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids")
|
||||
if "new_last_cursor" in self.state:
|
||||
val = self.state.pop("new_last_cursor")
|
||||
if val:
|
||||
self.state["last_cursor"] = val
|
||||
if "new_last_timestamp_us" in self.state:
|
||||
val = self.state.pop("new_last_timestamp_us")
|
||||
if val:
|
||||
self.state["last_timestamp_us"] = val
|
||||
if "new_sent_cursors" in self.state:
|
||||
self.state["sent_cursors"] = self.state.pop("new_sent_cursors")
|
||||
|
||||
|
||||
class WindowsLogCollector(BaseLogCollector):
|
||||
"""
|
||||
Windows Event Log Collector.
|
||||
Reads Application and System (or custom channels) backwards.
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||
State Tracking: Skips events older than lookback window and already sent records.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state: Optional[dict] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
channels: Optional[List[str]] = None
|
||||
):
|
||||
super().__init__(state=state, machine_id=machine_id)
|
||||
self.channels = channels or ["Application"]
|
||||
|
||||
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
if win32evtlog is None:
|
||||
return []
|
||||
|
||||
logs = []
|
||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||
|
||||
last_record_number = 0
|
||||
sent_record_ids: Set[str] = set()
|
||||
if self.state:
|
||||
last_record_number = int(self.state.get("last_record_number", 0))
|
||||
sent_record_ids = set(self.state.get("sent_record_ids", []))
|
||||
|
||||
# Windows Event Log EventTypes:
|
||||
# 1: EVENTLOG_ERROR_TYPE -> ERROR
|
||||
# 2: EVENTLOG_WARNING_TYPE -> WARNING
|
||||
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
|
||||
sev_map = {
|
||||
1: "ERROR",
|
||||
2: "WARNING",
|
||||
4: "INFO"
|
||||
}
|
||||
|
||||
newest_record_number = 0
|
||||
collected_record_ids = []
|
||||
|
||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||
|
||||
for log_type in self.channels:
|
||||
try:
|
||||
hand = win32evtlog.OpenEventLog("localhost", log_type)
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
while True:
|
||||
try:
|
||||
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||
except Exception:
|
||||
break
|
||||
if not events:
|
||||
break
|
||||
|
||||
for event in events:
|
||||
rec_num = int(event.RecordNumber)
|
||||
if newest_record_number == 0:
|
||||
newest_record_number = rec_num
|
||||
|
||||
# 1. Skip entries older than lookback window
|
||||
if event.TimeGenerated < cutoff_time:
|
||||
break
|
||||
|
||||
# 2. Skip already sent events
|
||||
if last_record_number > 0 and newest_record_number >= last_record_number:
|
||||
if rec_num <= last_record_number:
|
||||
break
|
||||
|
||||
rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}"
|
||||
if rec_id in sent_record_ids:
|
||||
continue
|
||||
|
||||
# Filter: upload everything from INFO to ERROR
|
||||
if event.EventType in sev_map:
|
||||
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
|
||||
sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event")
|
||||
logs.append({
|
||||
"server": self.machine_id,
|
||||
"os_type": "windows",
|
||||
"signature": sig,
|
||||
"severity": sev_map[event.EventType],
|
||||
"message": msg[:2048]
|
||||
})
|
||||
collected_record_ids.append(rec_id)
|
||||
|
||||
if events[-1].TimeGenerated < cutoff_time:
|
||||
break
|
||||
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
|
||||
break
|
||||
|
||||
try:
|
||||
win32evtlog.CloseEventLog(hand)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.state is not None:
|
||||
target_rec = max(newest_record_number, last_record_number)
|
||||
self.state["new_last_record_number"] = target_rec
|
||||
self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
|
||||
# Also update direct keys for immediate server-side ingestion
|
||||
self.state["last_record_number"] = target_rec
|
||||
self.state["sent_record_ids"] = self.state["new_sent_record_ids"]
|
||||
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
class LinuxLogCollector(BaseLogCollector):
|
||||
"""
|
||||
Linux Systemd Journal / Syslog Log Collector.
|
||||
Collects info, warnings, and errors from journalctl (or syslog fallback).
|
||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise.
|
||||
State Tracking: Skips events older than lookback window and already sent cursors.
|
||||
"""
|
||||
|
||||
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||
super().__init__(state=state, machine_id=machine_id)
|
||||
|
||||
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
last_cursor = None
|
||||
last_timestamp_us = 0.0
|
||||
sent_cursors: Set[str] = set()
|
||||
if self.state:
|
||||
last_cursor = self.state.get("last_cursor")
|
||||
try:
|
||||
last_timestamp_us = float(self.state.get("last_timestamp_us", 0))
|
||||
except (ValueError, TypeError):
|
||||
last_timestamp_us = 0.0
|
||||
sent_cursors = set(self.state.get("sent_cursors", []))
|
||||
|
||||
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||
result = None
|
||||
|
||||
if last_cursor:
|
||||
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
|
||||
try:
|
||||
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
|
||||
if res.returncode == 0:
|
||||
result = res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result is None:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError:
|
||||
# journalctl not present, attempt fallback to /var/log/syslog or messages
|
||||
return self._collect_syslog_fallback(hours=hours)
|
||||
except Exception as e:
|
||||
print(f"[!] Error running journalctl: {e}")
|
||||
return []
|
||||
|
||||
if not result or result.returncode != 0:
|
||||
return []
|
||||
|
||||
logs = []
|
||||
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
|
||||
|
||||
newest_cursor = None
|
||||
newest_timestamp_us = last_timestamp_us
|
||||
collected_cursors = []
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line_str)
|
||||
entry_cursor = entry.get("__CURSOR")
|
||||
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
|
||||
|
||||
entry_ts_us = 0.0
|
||||
if entry_ts_us_raw:
|
||||
try:
|
||||
entry_ts_us = float(entry_ts_us_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 1. Skip entries older than lookback window
|
||||
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
|
||||
continue
|
||||
|
||||
# 2. Skip already sent events
|
||||
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
|
||||
continue
|
||||
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
|
||||
continue
|
||||
|
||||
# Advance tracking
|
||||
if entry_cursor:
|
||||
newest_cursor = entry_cursor
|
||||
collected_cursors.append(entry_cursor)
|
||||
if entry_ts_us > newest_timestamp_us:
|
||||
newest_timestamp_us = entry_ts_us
|
||||
|
||||
priority = int(entry.get("PRIORITY", "6"))
|
||||
if priority > 6:
|
||||
continue
|
||||
|
||||
if priority <= 3:
|
||||
sev = "ERROR"
|
||||
elif priority in (4, 5):
|
||||
sev = "WARNING"
|
||||
else:
|
||||
sev = "INFO"
|
||||
|
||||
logs.append({
|
||||
"server": self.machine_id,
|
||||
"os_type": "linux",
|
||||
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
||||
"severity": sev,
|
||||
"message": entry.get("MESSAGE", "")[:2048]
|
||||
})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if self.state is not None:
|
||||
self.state["new_last_cursor"] = newest_cursor or last_cursor
|
||||
self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
|
||||
self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
|
||||
# Also update direct keys for immediate server-side ingestion
|
||||
self.state["last_cursor"] = self.state["new_last_cursor"]
|
||||
self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"]
|
||||
self.state["sent_cursors"] = self.state["new_sent_cursors"]
|
||||
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return logs
|
||||
|
||||
def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
"""Fallback log reader for non-systemd Linux environments."""
|
||||
candidates = ["/var/log/syslog", "/var/log/messages"]
|
||||
target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None)
|
||||
if not target:
|
||||
return []
|
||||
|
||||
logs = []
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()[-500:] # Read recent 500 lines
|
||||
for line in lines:
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
sev = "INFO"
|
||||
lower_l = line_str.lower()
|
||||
if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]):
|
||||
sev = "ERROR"
|
||||
elif any(w in lower_l for w in ["warn", "notice"]):
|
||||
sev = "WARNING"
|
||||
|
||||
logs.append({
|
||||
"server": self.machine_id,
|
||||
"os_type": "linux",
|
||||
"signature": "syslog",
|
||||
"severity": sev,
|
||||
"message": line_str[:2048]
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return logs
|
||||
|
||||
|
||||
def get_log_collector(
|
||||
os_type: Optional[str] = None,
|
||||
state: Optional[dict] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
channels: Optional[List[str]] = None
|
||||
) -> BaseLogCollector:
|
||||
"""
|
||||
Factory helper that returns the appropriate BaseLogCollector instance.
|
||||
If os_type is omitted, auto-detects based on sys.platform.
|
||||
"""
|
||||
target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux")
|
||||
if target_os == "windows":
|
||||
chans = channels or (["Application", "System"] if channels is None else ["Application"])
|
||||
return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans)
|
||||
else:
|
||||
return LinuxLogCollector(state=state, machine_id=machine_id)
|
||||
@@ -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_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_FEATURES = x509.ObjectIdentifier("1.3.6.1.4.1.59999.1.4")
|
||||
|
||||
|
||||
|
||||
@@ -251,8 +252,24 @@ def generate_server_cert_if_needed(
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode("utf-8")
|
||||
|
||||
# Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain
|
||||
full_server_cert_pem = server_cert_pem
|
||||
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||
if os.path.exists(ca_cert_path):
|
||||
with open(ca_cert_path, "r", encoding="utf-8") as f:
|
||||
ca_pem = f.read().strip()
|
||||
if ca_pem and ca_pem not in full_server_cert_pem:
|
||||
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem
|
||||
|
||||
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||
if os.path.exists(upstream_ca_path):
|
||||
with open(upstream_ca_path, "r", encoding="utf-8") as f:
|
||||
upstream_pem = f.read().strip()
|
||||
if upstream_pem and upstream_pem not in full_server_cert_pem:
|
||||
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem
|
||||
|
||||
with open(server_cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(server_cert_pem)
|
||||
f.write(full_server_cert_pem)
|
||||
with open(server_key_path, "w", encoding="utf-8") as f:
|
||||
f.write(server_key_pem)
|
||||
|
||||
@@ -432,6 +449,19 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
||||
except (x509.ExtensionNotFound, Exception):
|
||||
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_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)
|
||||
@@ -466,6 +496,7 @@ def extract_license_info_from_cert(cert_pem: str) -> Dict[str, Any]:
|
||||
"seats": seats,
|
||||
"license_id": license_id,
|
||||
"issuer_url": issuer_url,
|
||||
"features": features,
|
||||
"subject_cn": subject_cn,
|
||||
"issuer_cn": issuer_cn,
|
||||
"is_ca": is_ca,
|
||||
@@ -587,11 +618,12 @@ def sign_ca_csr_with_seats(
|
||||
seats: int,
|
||||
days_valid: int = 365,
|
||||
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:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
||||
@@ -650,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())
|
||||
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||
|
||||
@@ -887,6 +926,7 @@ def import_license_package(
|
||||
"master_root_ca_pem": master_root_ca_pem,
|
||||
"license_info": info,
|
||||
"seats": info.get("seats"),
|
||||
"features": info.get("features", []),
|
||||
"license_id": info.get("license_id"),
|
||||
"issuer_url": info.get("issuer_url"),
|
||||
"fingerprint": info.get("fingerprint"),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Unit tests for LOGAR log_collector and client_transport modules.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import unittest
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
warnings.simplefilter("ignore")
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||
|
||||
from log_collector import (
|
||||
get_machine_identifier,
|
||||
is_path_writable,
|
||||
get_default_data_dir,
|
||||
resolve_writable_path,
|
||||
get_state_path,
|
||||
load_state,
|
||||
save_state,
|
||||
commit_state,
|
||||
BaseLogCollector,
|
||||
WindowsLogCollector,
|
||||
LinuxLogCollector,
|
||||
get_log_collector
|
||||
)
|
||||
from client_transport import (
|
||||
is_cert_expiring_soon,
|
||||
load_config
|
||||
)
|
||||
|
||||
|
||||
class TestLogCollectorModule(unittest.TestCase):
|
||||
def test_machine_identifier(self):
|
||||
mid = get_machine_identifier()
|
||||
self.assertIsInstance(mid, str)
|
||||
self.assertTrue(len(mid) > 0)
|
||||
self.assertNotEqual(mid, "localhost")
|
||||
|
||||
def test_path_writable_and_data_dir(self):
|
||||
data_dir = get_default_data_dir("LOGAR_TEST")
|
||||
self.assertIsInstance(data_dir, str)
|
||||
self.assertTrue(is_path_writable(data_dir))
|
||||
|
||||
def test_state_lifecycle_windows_and_linux(self):
|
||||
state_file = "test_shared_state.json"
|
||||
try:
|
||||
state = load_state(state_file)
|
||||
self.assertEqual(state, {})
|
||||
|
||||
# Windows staged state
|
||||
state["new_last_record_number"] = 100
|
||||
state["new_sent_record_ids"] = ["100:2026-09-27T00:00:00"]
|
||||
commit_state(state, state_file)
|
||||
self.assertNotIn("new_last_record_number", state)
|
||||
self.assertEqual(state["last_record_number"], 100)
|
||||
|
||||
# Linux staged state
|
||||
state["new_last_cursor"] = "c_xyz"
|
||||
state["new_last_timestamp_us"] = 123456789.0
|
||||
state["new_sent_cursors"] = ["c_xyz"]
|
||||
commit_state(state, state_file)
|
||||
self.assertNotIn("new_last_cursor", state)
|
||||
self.assertEqual(state["last_cursor"], "c_xyz")
|
||||
self.assertEqual(state["last_timestamp_us"], 123456789.0)
|
||||
|
||||
# Reload and verify
|
||||
reloaded = load_state(state_file)
|
||||
self.assertEqual(reloaded["last_record_number"], 100)
|
||||
self.assertEqual(reloaded["last_cursor"], "c_xyz")
|
||||
finally:
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
os.remove(state_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_windows_log_collector_severity_mapping(self):
|
||||
state = {}
|
||||
collector = WindowsLogCollector(state=state, machine_id="test-win-node", channels=["Application"])
|
||||
self.assertEqual(collector.machine_id, "test-win-node")
|
||||
self.assertEqual(collector.channels, ["Application"])
|
||||
|
||||
def test_linux_log_collector_priority_normalization(self):
|
||||
state = {}
|
||||
collector = LinuxLogCollector(state=state, machine_id="test-linux-node")
|
||||
self.assertEqual(collector.machine_id, "test-linux-node")
|
||||
|
||||
def test_get_log_collector_factory(self):
|
||||
win_col = get_log_collector(os_type="windows", machine_id="win-1")
|
||||
self.assertIsInstance(win_col, WindowsLogCollector)
|
||||
self.assertEqual(win_col.machine_id, "win-1")
|
||||
|
||||
lin_col = get_log_collector(os_type="linux", machine_id="lin-1")
|
||||
self.assertIsInstance(lin_col, LinuxLogCollector)
|
||||
self.assertEqual(lin_col.machine_id, "lin-1")
|
||||
|
||||
auto_col = get_log_collector()
|
||||
self.assertIsInstance(auto_col, BaseLogCollector)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import struct
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import shutil
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
@@ -30,8 +31,14 @@ HERMES_PORT = 8443
|
||||
|
||||
|
||||
def run_tests():
|
||||
print("=== [1] Verifying server_config.json & client_config.json ===")
|
||||
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json")
|
||||
server_cfg_path = "server_config.json"
|
||||
if not os.path.exists(server_cfg_path):
|
||||
server_cfg_path = os.path.join(ROOT_DIR, "server_config.json")
|
||||
if not os.path.exists(server_cfg_path):
|
||||
progdata_path = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"), "LOGAR-Server", "server_config.json")
|
||||
if os.path.exists(progdata_path):
|
||||
server_cfg_path = progdata_path
|
||||
|
||||
client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json")
|
||||
|
||||
assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"
|
||||
@@ -51,9 +58,84 @@ def run_tests():
|
||||
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
||||
|
||||
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
client_id = "test-edge-node.corp.internal"
|
||||
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
|
||||
auth_token = server_conf.get("auth_token", "")
|
||||
|
||||
print("\n=== [1.5] Testing Server License Activation via Web API (/api/license/import) ===")
|
||||
import server_enrollment
|
||||
|
||||
# 1. Check current license status
|
||||
req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
|
||||
with urllib.request.urlopen(req_status_init, timeout=5) as resp:
|
||||
init_lic_stat = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if init_lic_stat.get("status") == "UNLICENSED":
|
||||
unlicensed_enroll_payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": client_id,
|
||||
"os": "linux",
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req_unlicensed = urllib.request.Request(
|
||||
f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll",
|
||||
data=json.dumps(unlicensed_enroll_payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req_unlicensed, timeout=5):
|
||||
assert False, "Expected HTTP 403 on unlicensed server enrollment"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}"
|
||||
print("[OK] Unlicensed server correctly rejected edge client enrollment.")
|
||||
|
||||
# 2. Fetch CA CSR from Server
|
||||
req_csr = urllib.request.Request(
|
||||
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr",
|
||||
headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req_csr, timeout=5) as resp:
|
||||
assert resp.status == 200
|
||||
csr_data = json.loads(resp.read().decode("utf-8"))
|
||||
csr_pem = csr_data["csr"]
|
||||
print("[OK] Fetched subordinate CA CSR from server.")
|
||||
|
||||
# 3. Generate test master CA and sign subordinate CA with 10 seats
|
||||
master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca")
|
||||
master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA")
|
||||
sub_pem = server_enrollment.sign_ca_csr_with_seats(
|
||||
csr_pem=csr_pem,
|
||||
master_ca_cert_pem=master_pem,
|
||||
master_ca_key_pem=master_key_pem,
|
||||
license_id="LIC-TEST-PIPELINE-10",
|
||||
seats=10,
|
||||
days_valid=365
|
||||
)
|
||||
|
||||
# 4. Import signed license package to server
|
||||
import_payload = {"ca_cert": sub_pem, "master_root_ca": master_pem}
|
||||
req_import = urllib.request.Request(
|
||||
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/import",
|
||||
data=json.dumps(import_payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
with urllib.request.urlopen(req_import, timeout=5) as resp:
|
||||
assert resp.status == 200
|
||||
import_res = json.loads(resp.read().decode("utf-8"))
|
||||
assert import_res["status"] == "success"
|
||||
assert import_res["seats"] == 10
|
||||
print("[OK] Successfully imported signed license (.lic). Server activated with 10 seats.")
|
||||
|
||||
# 5. Verify license status
|
||||
req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
|
||||
with urllib.request.urlopen(req_status, timeout=5) as resp:
|
||||
assert resp.status == 200
|
||||
lic_stat = json.loads(resp.read().decode("utf-8"))
|
||||
assert lic_stat["status"] == "ACTIVE"
|
||||
assert lic_stat["max_seats"] == 10
|
||||
print("[OK] Verified server license status is ACTIVE.")
|
||||
|
||||
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
|
||||
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
|
||||
@@ -244,7 +326,6 @@ def run_tests():
|
||||
linux_logs = get_recent_linux_logs(hours=24)
|
||||
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
|
||||
|
||||
import shutil
|
||||
if os.path.exists(cert_dir):
|
||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import struct
|
||||
import sqlite3
|
||||
import unittest
|
||||
import urllib.request
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
@@ -21,12 +22,8 @@ import pgpy
|
||||
|
||||
class TestServerComponent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.test_db = "test_server_state.db"
|
||||
self.test_config = "test_server_config.json"
|
||||
if os.path.exists(self.test_db):
|
||||
os.remove(self.test_db)
|
||||
if os.path.exists(self.test_config):
|
||||
os.remove(self.test_config)
|
||||
self.test_db = f"test_server_state_{uuid.uuid4().hex[:8]}.db"
|
||||
self.test_config = f"test_server_config_{uuid.uuid4().hex[:8]}.json"
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.test_db):
|
||||
@@ -223,7 +220,7 @@ class TestServerComponent(unittest.TestCase):
|
||||
test_cert_dir = "test_certs_enroll"
|
||||
try:
|
||||
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||
Server.SERVER_STATE["config"] = {"db_path": self.test_db}
|
||||
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir}
|
||||
Server.SERVER_STATE["ca_cert"] = ca_cert
|
||||
Server.SERVER_STATE["ca_key"] = ca_key
|
||||
Server.SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||
@@ -249,7 +246,7 @@ class TestServerComponent(unittest.TestCase):
|
||||
resp1 = Server.enroll_client(req1)
|
||||
self.assertIn("client_cert", resp1)
|
||||
self.assertIn("client_key", resp1)
|
||||
self.assertEqual(resp1["ca_cert"], ca_pem)
|
||||
self.assertTrue(resp1["ca_cert"].strip().startswith(ca_pem.strip()))
|
||||
|
||||
# 3. Valid enrollment for client 2
|
||||
req2 = Server.ClientEnrollRequest(
|
||||
@@ -438,10 +435,456 @@ class TestServerComponent(unittest.TestCase):
|
||||
self.assertEqual(Server.format_relative_time(None), "Never")
|
||||
self.assertEqual(Server.format_relative_time(""), "Never")
|
||||
self.assertEqual(Server.format_relative_time("invalid-date-string"), "invalid-date-str")
|
||||
just_now = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.assertIn(Server.format_relative_time(just_now), ("0s ago", "1s ago", "Just now"))
|
||||
now_fresh = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
rel = Server.format_relative_time(now_fresh)
|
||||
self.assertTrue(rel.endswith("s ago") or rel == "Just now", f"Unexpected relative time: {rel}")
|
||||
|
||||
def test_integrated_local_log_collector_zero_seats(self):
|
||||
"""Tests that local hub log collection runs without license, registering a 0-seat built-in client."""
|
||||
Server.init_db(self.test_db, enrollment_secret="test-sec-456", max_seats=0)
|
||||
Server.SERVER_STATE["config"] = {
|
||||
"db_path": self.test_db,
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 1
|
||||
}
|
||||
|
||||
# Run cycle
|
||||
state = {}
|
||||
processed = Server.run_local_log_collection_cycle(self.test_db, window_hours=12, min_runs=1, state=state)
|
||||
|
||||
# Verify local hub client entry exists in clients table
|
||||
machine_id = Server.get_machine_identifier()
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT client_id, cert_fingerprint, status FROM clients WHERE client_id = ?", (machine_id,))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row[0], machine_id)
|
||||
self.assertEqual(row[1], "LOCAL-HOST-BUILTIN")
|
||||
self.assertEqual(row[2], "active")
|
||||
|
||||
# Verify active seat count is STILL 0 (exempt from seat accounting)
|
||||
client_data = Server.list_clients()
|
||||
self.assertEqual(client_data["active_seats"], 0)
|
||||
self.assertEqual(len(client_data["clients"]), 1)
|
||||
|
||||
def test_two_factor_auth_and_totp_flow(self):
|
||||
"""Tests TOTP secret generation, token verification, URI generation, and enable/disable lifecycle."""
|
||||
Server.init_db(self.test_db)
|
||||
|
||||
# 1. Test TOTP helpers
|
||||
secret = Server.generate_totp_secret()
|
||||
self.assertTrue(len(secret) >= 16)
|
||||
token = Server.get_totp_token(secret)
|
||||
self.assertEqual(len(token), 6)
|
||||
self.assertTrue(token.isdigit())
|
||||
self.assertTrue(Server.verify_totp_code(secret, token))
|
||||
self.assertFalse(Server.verify_totp_code(secret, "000000" if token != "000000" else "999999"))
|
||||
self.assertFalse(Server.verify_totp_code(secret, "invalid"))
|
||||
|
||||
# 2. Test OTPAuth URI
|
||||
uri = Server.get_totp_uri("admin", secret, issuer="LOGAR Fleet Hub")
|
||||
self.assertTrue(uri.startswith("otpauth://totp/"))
|
||||
self.assertIn("secret=" + secret, uri)
|
||||
self.assertIn("LOGAR", uri)
|
||||
|
||||
# 3. Test initial user 2FA status
|
||||
info = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertFalse(info["enabled"])
|
||||
self.assertEqual(info["secret"], "")
|
||||
|
||||
# 4. Test enabling 2FA
|
||||
enabled = Server.enable_user_totp("admin", secret, db_path=self.test_db)
|
||||
self.assertTrue(enabled)
|
||||
|
||||
info_active = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertTrue(info_active["enabled"])
|
||||
self.assertEqual(info_active["secret"], secret)
|
||||
|
||||
# 5. Test disabling 2FA
|
||||
disabled = Server.disable_user_totp("admin", db_path=self.test_db)
|
||||
self.assertTrue(disabled)
|
||||
|
||||
info_disabled = Server.get_user_totp_info("admin", db_path=self.test_db)
|
||||
self.assertFalse(info_disabled["enabled"])
|
||||
|
||||
def test_persistent_session_secret_and_rate_limiting(self):
|
||||
"""Tests DB persistence of session secret and sliding window rate limiting."""
|
||||
Server.init_db(self.test_db)
|
||||
|
||||
# 1. Session secret persistence
|
||||
sec1 = Server.get_session_secret(self.test_db)
|
||||
self.assertTrue(len(sec1) >= 32)
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'")
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(row[0], sec1)
|
||||
|
||||
# Token generated with test_db
|
||||
token = Server.generate_session_token("admin", db_path=self.test_db)
|
||||
self.assertEqual(Server.verify_session_token(token, db_path=self.test_db), "admin")
|
||||
|
||||
# 2. Rate limiting
|
||||
test_ip = "10.0.0.99"
|
||||
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))
|
||||
Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db)
|
||||
Server.record_failed_ip_attempt(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))
|
||||
|
||||
Server.record_failed_ip_attempt(test_ip, scope="login", db_path=self.test_db)
|
||||
self.assertFalse(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="login", db_path=self.test_db))
|
||||
|
||||
# Different scope ('enroll') should still be allowed
|
||||
self.assertTrue(Server.check_ip_rate_limit(test_ip, max_attempts=3, window_seconds=60, scope="enroll", 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))
|
||||
|
||||
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."""
|
||||
Server.init_db(self.test_db)
|
||||
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "storage_engine": "sqlite"}
|
||||
|
||||
# 1. Test direct authorization & endpoint logic
|
||||
stats = Server.api_db_stats()
|
||||
self.assertIn("queue", stats)
|
||||
self.assertIn("storage", stats)
|
||||
self.assertFalse(stats["enterprise_db_licensed"])
|
||||
|
||||
# 2. Test Connection without enterprise_db -> HTTPException 403
|
||||
pg_req = Server.DbConnectionTestRequest(host="localhost", port=5432, database="logar", user="postgres", password="")
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_test_connection(pg_req)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
# 3. Switch backend without enterprise_db -> HTTPException 403
|
||||
switch_req = Server.DbSwitchBackendRequest(db_type="postgres", host="localhost", port=5432, database="logar", user="postgres", password="")
|
||||
with self.assertRaises(Server.HTTPException) as cm:
|
||||
Server.api_db_switch_backend(switch_req)
|
||||
self.assertEqual(cm.exception.status_code, 403)
|
||||
self.assertIn("enterprise_db", cm.exception.detail)
|
||||
|
||||
# 4. Flush buffer endpoint executes successfully
|
||||
flush_res = Server.api_db_flush_buffer()
|
||||
self.assertEqual(flush_res["status"], "success")
|
||||
|
||||
# 5. Optional HTTP client verification if starlette testclient dependencies are present
|
||||
try:
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(Server.app)
|
||||
|
||||
# Unauthenticated request to /api/db/stats -> 401
|
||||
res = client.get("/api/db/stats")
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
# 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"])
|
||||
|
||||
# 403 checks via HTTP client
|
||||
res_test = client.post("/api/db/test-connection", json={"host": "localhost", "port": 5432, "database": "logar", "user": "postgres", "password": ""})
|
||||
self.assertEqual(res_test.status_code, 403)
|
||||
|
||||
res_switch = client.post("/api/db/switch-backend", json={"db_type": "postgres", "host": "localhost", "port": 5432, "database": "logar", "user": "postgres", "password": ""})
|
||||
self.assertEqual(res_switch.status_code, 403)
|
||||
|
||||
res_flush = client.post("/api/db/flush-buffer")
|
||||
self.assertEqual(res_flush.status_code, 200)
|
||||
except (ImportError, RuntimeError, Exception):
|
||||
# Starlette TestClient may raise RuntimeError when httpx/httpx2 is missing in lightweight runner environments
|
||||
pass
|
||||
|
||||
def test_db_encryption_at_rest_and_full_config_api(self):
|
||||
"""Tests OpenPGP data-at-rest encryption/decryption and full DB/Cache configuration API."""
|
||||
Server.init_db(self.test_db)
|
||||
priv_armored, pub_armored, fp = Server.generate_server_keypair("test-db-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,
|
||||
"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
|
||||
|
||||
# 1. Test OpenPGP encryption & decryption roundtrip
|
||||
raw_msg = "Critical database corruption: segment 0x89FA damaged"
|
||||
encrypted_msg = Server.encrypt_field_for_storage(raw_msg, force=True)
|
||||
self.assertTrue(encrypted_msg.startswith("-----BEGIN PGP MESSAGE-----"))
|
||||
self.assertNotIn("Critical database corruption", encrypted_msg)
|
||||
|
||||
decrypted_msg = Server.decrypt_field_from_storage(encrypted_msg)
|
||||
self.assertEqual(decrypted_msg, raw_msg)
|
||||
|
||||
# 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",
|
||||
flush_interval=4.0,
|
||||
batch_size=200,
|
||||
max_queue=25000,
|
||||
encrypt_at_rest=True
|
||||
)
|
||||
res = Server.api_db_update_config(cfg_req)
|
||||
self.assertEqual(res["status"], "success")
|
||||
self.assertEqual(Server.SERVER_STATE["config"]["cache_mode"], "buffered")
|
||||
self.assertTrue(Server.SERVER_STATE["config"]["encrypt_at_rest"])
|
||||
self.assertEqual(Server.INGEST_QUEUE.flush_interval, 4.0)
|
||||
self.assertEqual(Server.INGEST_QUEUE.batch_size, 200)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
|
||||
|
||||