84 Commits
Author SHA1 Message Date
me0nline 8f8a605c98 fix(licensing): support bootstrap and activated JSON .lic imports, lock auto-update when unlicensed, and add upload confirmation warning
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 4m25s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m13s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 12m45s
2026-10-04 01:06:19 +02:00
me0nline 548b2fd8ad ci(workflow): remove release trigger from build-runner workflow
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 3m33s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m26s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 9m20s
2026-10-04 00:30:40 +02:00
me0nline 9b80007a86 fix(packaging): package src module tree into standalone executables and fix ISPP syntax
Build and Push LOGAR Runner / Build & Push Runner Image (push) Canceled after 54s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m20s
2026-10-04 00:28:59 +02:00
me0nline cd6b9baa8e fix(build): protect NSIS and InnoSetup version header definitions with ifndef guards
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m4s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m52s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 10m21s
2026-10-03 23:46:59 +02:00
me0nline ea2ff1c4a9 feat(ui,docs): add auto_update feature flag integration, UI card, and documentation
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m17s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 7m27s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Failing after 5m16s
2026-10-03 23:11:17 +02:00
me0nline 2d7357394f fix(packaging): fail fast with non-zero exit code if binary or installer compilation fails
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m23s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Failing after 3m45s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 7m34s
2026-10-03 22:40:28 +02:00
me0nline 4c1ab1ea86 fix(packaging): use standard NSIS !include /NONFATAL for version.nsh
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m31s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m0s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 9m53s
2026-10-03 22:39:08 +02:00
me0nline 2ecb356516 fix(updates): skip redownloading cached packages and disable background updater in CI
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 7m52s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 8m6s
2026-10-03 21:54:33 +02:00
me0nline 3e7870c278 fix(hub): offload upstream update check to thread executor and increase test timeout
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 3m26s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Canceled after 9m29s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 6m8s
2026-10-03 21:45:46 +02:00
me0nline 57eb05fab1 docs: finalize v0.3.5.2 release notes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 3m43s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 7m29s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 12m52s
2026-10-03 21:10:21 +02:00
me0nline e41c304208 Add /api/license/csr endpoint and support master_root_ca in LicenseImportRequest
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m47s
2026-10-03 20:32:20 +02:00
me0nline 57f3dd896f Bootstrap sys.path in root proxy files and add PYTHONPATH to CI workflow
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m43s
2026-10-03 20:26:26 +02:00
me0nline 303744a3f8 Remove licensing repository dependency and standalone submodule proxy from tests and CI
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m35s
2026-10-03 20:22:07 +02:00
me0nline d9bc8df002 Update runner Dockerfile, workflows, and resolve CI test dependencies
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 20s
Build and Push LOGAR Runner / Build & Push Runner Image (push) Successful in 4m15s
2026-10-03 20:08:30 +02:00
me0nline 8b12186b43 Implement automated Fleet Hub upstream client package caching and background update distribution
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s
2026-10-02 23:21:11 +02:00
me0nline df9f5d191b Organize codebase into hub, clients, and common packages with backward compatibility and comprehensive unit test suites
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m18s
2026-10-02 23:14:55 +02:00
me0nline 9ebfadbe89 Optimize server startup speed, client update caching, and HTTP 304 conditional downloads
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m28s
2026-10-02 22:43:18 +02:00
me0nline fe9d43cab6 Harden endpoint authorization, filename sanitization, and updater imports
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m21s
2026-10-02 22:31:17 +02:00
me0nline f0c4a82fc3 Harden PKI license verification, database seat reconciliation, and binary packaging
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m27s
2026-10-02 22:24:21 +02:00
me0nline 2c08f9fc15 Add server update alerting and fleet client auto-update system (synchronized release lockstep)
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s
2026-10-02 22:08:51 +02:00
me0nline f2a9e43619 Refactor Server.py into modular domain classes (models, auth, storage, queue, licensing, PKI, UI)
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s
2026-10-02 21:57:29 +02:00
me0nline 909dc640e3 Fix Gitea issues #1 and #2, add firewall automation, forwarder telemetry, and dynamic key rotation
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m24s
2026-10-02 21:39:51 +02:00
me0nline 0edae4d797 ci: auto-clean node:16-bullseye and intermediate build layers from Docker host
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m11s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m32s
2026-09-30 18:37:56 +02:00
me0nline cc25c31884 ci(runner): auto-cleanup local Docker image and build cache after push
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
2026-09-30 18:36:46 +02:00
me0nline 421eafb72d docs(runner): update pre-installed dependency list
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 22s
2026-09-30 18:34:12 +02:00
me0nline ca8b0153fa fix(ci): fix PyInstaller import casing and ensure pip packages are satisfied
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m24s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 3m44s
2026-09-30 18:32:11 +02:00
me0nline 50f76ca3c3 docs(release-notes): trim legacy release entries and focus on v0.3.5.1
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 22s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Failing after 18s
2026-09-30 18:21:44 +02:00
me0nline ea6410d391 ci: rely on pre-installed builder runner dependencies and remove redundant apt/pip steps
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
2026-09-30 18:20:04 +02:00
me0nline 41d7d66a56 feat(packaging): dynamically resolve installer version info from build context
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m31s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 3m50s
2026-09-30 18:18:53 +02:00
me0nline 959f2977f0 chore(release): bump version to 0.3.5.1 and clean up release notes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m20s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m27s
2026-09-30 18:13:45 +02:00
me0nline 40cdbf349c chore: remove redundant docs and wiki directories; reference Gitea wiki directly
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m27s
2026-09-30 18:10:12 +02:00
me0nline ca1b5b5d25 docs: streamline admin guide to focus on WebUI management
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
2026-09-30 18:08:59 +02:00
me0nline c1d4675321 docs: add administrator guide, update self-tests to 51 passing, update documentation and release notes for v0.3.5
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m17s
2026-09-30 17:58:35 +02:00
me0nline 2d33b14d60 feat(ui): display licensed feature capabilities in Sub-CA & Licensing tab
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
2026-09-30 17:48:01 +02:00
me0nline 2b0b680188 fix(security): ensure encryption at rest and buffering are never auto-enabled upon licensing without explicit admin configuration
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m16s
2026-09-30 17:43:30 +02:00
me0nline f0bf90e033 fix(config): safe config file path resolution and isolation
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m34s
2026-09-30 17:39:48 +02:00
me0nline 22072a0b88 feat(ui): remove item counts from dashboard tab text
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m18s
2026-09-30 17:37:24 +02:00
me0nline 730f0ba7ca feat(ui,api): clean DB tab header, gray out enterprise features when unlicensed, enforce strict license gating on API
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m22s
2026-09-30 17:33:50 +02:00
me0nline b7c27ee3a1 feat(ui): reorder DB Engine tab (cache mode on top, encryption in middle, DB engine switcher below)
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m18s
2026-09-30 17:28:53 +02:00
me0nline fa662da476 feat(db): complete SQL management UI, cache modes, PostgreSQL params, and OpenPGP data encryption at rest
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m18s
2026-09-30 17:17:57 +02:00
me0nline 6cb9631958 fix(ingest): process socket log payloads synchronously and return verified promotion counts to client
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m25s
2026-09-30 16:56:40 +02:00
me0nline c03bb348b5 fix(ci): make test_postgres_storage_endpoints_authorization resilient against starlette TestClient runtime errors
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m25s
2026-09-30 16:48:10 +02:00
me0nline 2d39f3c007 chore(submodule): update LOGAR-LICENSING to v0.3.5.1
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m45s
2026-09-30 16:41:10 +02:00
me0nline 78e62b31d2 feat(storage): implement in-memory ingestion queue, PostgreSQL enterprise backend, and gated DB WebUI tab
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m35s
2026-09-30 16:28:48 +02:00
me0nline 860e39b360 docs: remove branding guide badge from header and move to end of wiki links in README
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m2s
2026-09-30 15:56:40 +02:00
me0nline 2a04244db3 feat(security): implement persistent session secret and DB-backed sliding-window rate limiting
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m44s
2026-09-30 15:55:17 +02:00
me0nline 9f999fc6c3 feat(release): v0.3.4.2 - host binding locking and manual reassignment controls
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m54s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m21s
2026-09-30 15:40:16 +02:00
me0nline 3399c6d57b feat(licensing): add host-binding locking and reset-binding button for license reassignment
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m49s
2026-09-30 15:37:20 +02:00
me0nline c6d04f8896 feat(release): v0.3.4.1 - fix installer UI text overlap and branding
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m42s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m40s
2026-09-27 19:30:52 +02:00
me0nline 0a67b43619 fix(licensing): keep only Manage button in ACTIONS table column
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m48s
2026-09-27 19:29:40 +02:00
me0nline 3234acf435 feat(release): v0.3.4 - in-place installer upgrades, mutual exclusion guards, 2FA, and telemetry cap
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 1m13s
2026-09-27 19:13:22 +02:00
me0nline 702ca985d2 Update LOGAR-LICENSING submodule pointer for clickable capabilities and implementation overview
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m38s
2026-09-27 19:07:05 +02:00
me0nline 97d92d1bee Update LOGAR-LICENSING submodule with delete and reinstate buttons for revoked licenses
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m49s
2026-09-27 18:53:46 +02:00
me0nline a8cd2cd83c Add TOTP 2FA to Server Fleet Hub and limit verified telemetry stream to latest 50 events
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m15s
2026-09-27 18:48:40 +02:00
me0nline 6843b22ac1 Update LOGAR-LICENSING submodule pointer with container pruner
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m1s
2026-09-27 18:35:33 +02:00
me0nline bfd576bfdd Update LOGAR-LICENSING submodule pointer with working revoke button
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m54s
2026-09-27 18:32:05 +02:00
me0nline 9810220cb7 Use raw repository URL for banner image in README
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m16s
2026-09-27 18:06:54 +02:00
me0nline 13ca1cacda Align installer header text cleanly to the left with right-positioned header image
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m53s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m13s
2026-09-27 18:04:11 +02:00
me0nline 4d607d0a9e Add vector color swatches and update asset generator
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
2026-09-27 18:01:25 +02:00
me0nline ad80620e41 Update regenerated branding assets across main repo
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m8s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m6s
2026-09-27 17:57:45 +02:00
me0nline 6fb631f908 Fix XML entity in logo_banner.svg and asset generator
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 1s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
2026-09-27 17:57:24 +02:00
me0nline 65ec1b3b1b Update LOGAR-LICENSING submodule pointer with centered navbar and 2x2 grid
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
2026-09-27 17:55:38 +02:00
me0nline 7b3b822974 Update LOGAR-LICENSING submodule with navbar, minimalist footer, and impressum
CI Test Suite / Run Component Tests & Pipeline Verification (push) Canceled after 0s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
2026-09-27 17:41:42 +02:00
me0nline 5b97ccc1f2 remove legacy out, docs, and wiki folders from repo
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Canceled after 0s
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m26s
2026-09-27 17:37:00 +02:00
me0nline d6e5ef7107 update LOGAR-LICENSING pointer with dynamic health status indicator
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m23s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m56s
2026-09-27 17:32:24 +02:00
me0nline d689713234 add Impressum page and wiki documentation
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m29s
2026-09-27 17:31:00 +02:00
me0nline 61825571b1 update LOGAR-LICENSING submodule pointer with UI adjustments
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m20s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m21s
2026-09-27 17:28:54 +02:00
me0nline 06db6672ce ignore master_ca_certs test output
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m54s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m25s
2026-09-27 17:24:49 +02:00
me0nline a6eddc207c update LOGAR-LICENSING submodule with test fix
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m56s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m54s
2026-09-27 17:24:20 +02:00
me0nline a70ce9162d brand asset library, wiki branding guide, and v0.3.3 release
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m30s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m11s
2026-09-27 17:17:57 +02:00
me0nline 900d03b7f7 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m37s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m4s
2026-09-27 17:10:29 +02:00
me0nline a8d59b2f14 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m14s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m15s
2026-09-27 17:00:17 +02:00
me0nline b79022bc41 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m17s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m15s
2026-09-27 16:07:18 +02:00
me0nline fcba813edd windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m26s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m17s
2026-09-27 15:51:03 +02:00
me0nline 72e3527c79 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m29s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m18s
2026-09-27 15:06:32 +02:00
me0nline 4a40b4d395 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m41s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m47s
2026-09-27 15:00:26 +02:00
me0nline 399cf469c5 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 9m48s
2026-09-26 22:30:43 +02:00
me0nline ce7d2b97d2 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m21s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 7m43s
2026-09-26 22:09:40 +02:00
me0nline 2e256baf4a windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m13s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m51s
2026-09-26 22:00:50 +02:00
me0nline 1a984caaf7 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m24s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m42s
2026-09-26 21:42:51 +02:00
me0nline 96756ca5db windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m14s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m29s
2026-09-26 21:34:21 +02:00
me0nline 157229a84e windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m9s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 10m27s
2026-09-26 21:21:36 +02:00
me0nline 8287af3129 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m36s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 10m7s
2026-09-26 21:07:34 +02:00
me0nline a133050cb5 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m13s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 7m32s
2026-09-26 20:59:13 +02:00
129 changed files with 13632 additions and 5882 deletions
BIN
View File
Binary file not shown.
+15 -2
View File
@@ -1,9 +1,11 @@
name: Build and Push LOGAR Runner
on:
push:
paths:
- 'runner/**'
- '.gitea/workflows/build-runner.yml'
workflow_dispatch:
release:
types: [published]
jobs:
build-and-push:
@@ -56,3 +58,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."
+7 -4
View File
@@ -16,17 +16,20 @@ jobs:
name: Run Component Tests & Pipeline Verification
if: "!startsWith(github.ref, 'refs/tags/')"
runs-on: logar
env:
PYTHONPATH: .
LOGAR_DISABLE_AUTO_UPDATE: "1"
steps:
- name: Checkout Code
uses: actions/checkout@v3
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: |
+7 -37
View File
@@ -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: |
+13 -3
View File
@@ -9,6 +9,7 @@ __pycache__/
*$py.class
build/
dist/
out/
*.spec
*.exe
!compilation/nssm.exe
@@ -16,22 +17,31 @@ 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
updates/
# IDE & OS files
.vscode/
+53 -8
View File
@@ -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*
[![Version](https://img.shields.io/badge/version-0.3.1-blue.svg)](RELEASE_NOTES.md)
[![Tests](https://img.shields.io/badge/tests-35%20passed-success.svg)](tests/)
[![Version](https://img.shields.io/badge/version-0.3.5.2-blue.svg)](RELEASE_NOTES.md)
[![Tests](https://img.shields.io/badge/tests-94%20passed-success.svg)](tests/)
[![Security](https://img.shields.io/badge/mTLS-TLS%201.3-green.svg)](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
[![Admin Guide](https://img.shields.io/badge/wiki-Admin%20Guide-blueviolet.svg)](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.
**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, fleet auto-update package distribution, 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, fleet package distribution, 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,14 @@
- **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 (`enterprise_db`, `auto_update`) are introspected dynamically.
- **Fleet Auto-Update & Binary Distribution**: Centralized local binary distribution point with SHA-256 integrity verification, automated client version negotiation, and background pull orchestrator.
- **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 +116,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 +134,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
@@ -137,16 +153,43 @@ LOGAR/
- **`POST /api/client/enroll`**: Forwarder auto-enrollment and proactive certificate renewal.
- **`GET /api/clients`**: Returns active seat quota and registered edge forwarder telemetry *(Admin Auth)*.
- **`GET /api/client/update/check`**: Evaluates client version and returns upgrade manifest with SHA-256 integrity digest.
- **`GET /api/client/update/download`**: Delivers OS-specific standalone binary packages for forwarder upgrades.
- **`GET /api/hermes/report`**: Structured JSON anomaly stream for Hermes AI reasoning.
- **`POST /api/license/sync`**: Triggers immediate WebSocket sync with `logar.eibl.tech` *(Admin Auth)*.
- **`GET /health`**: Server health, encryption fingerprint, and mTLS status.
---
## Enterprise Feature Capabilities
LOGAR uses cryptographic X.509 v3 custom extensions embedded inside activated `.lic` intermediate CA certificates issued by the Master Authority:
| Feature Flag | Capability Name | Description | Default Status |
| :--- | :--- | :--- | :--- |
| **`enterprise_db`** | **External DB & Ingestion Buffer** | Unlocks PostgreSQL/MySQL storage backends, high-throughput in-memory RAM buffering with batch flushing, and OpenPGP zero-knowledge at-rest data encryption. | Enabled by default |
| **`auto_update`** | **Fleet Auto-Update Orchestration** | Enables central repository binary hosting, client version discovery, background update polling, and automated binary hot-upgrades. | Enabled by default |
---
## Fleet Auto-Update & Package Distribution
LOGAR operates a pull-based fleet package distribution architecture:
1. **Central Server Repository**: The Server Hub monitors upstream releases and hosts target client binaries (`LOGAR_Client_Setup.exe`, `Linux_Client.bin`) in the local repository (`/opt/logar-server/updates/` or `.\updates\`).
2. **Version Negotiation**: Edge clients periodically contact `GET /api/client/update/check?os=<windows|linux>&version=<current>` to compare active versions against the Hub.
3. **Integrity Verification**: Client packages are validated using streaming SHA-256 digests prior to installation or hot-swap.
4. **Client CLI Flags**:
- `--auto-update`: Enables automated background update polling and downloading.
- `--update-check-interval <seconds>`: Configures update poll frequency (default: 3600s).
- `--check-update-now`: Queries server for available updates and exits.
---
## 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 +205,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)
+56 -11
View File
@@ -1,13 +1,58 @@
# 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.2] - 2026-10-03
### ⚡ 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.
### 🚀 Fleet Hub Client Update Caching & Distribution
- **Local Package Caching**: The Fleet Hub automatically caches edge client binaries and packages locally on startup and upon scheduled refresh, minimizing external upstream traffic.
- **Edge Pull Mechanism**: Forwarder nodes retrieve validated update payloads directly from the local Fleet Hub.
### 🛡️ Windows Defender Firewall Automation (Fixes Issue #1)
- **Automated Port Exemptions**: Inno Setup (`installer_server.iss`) and NSIS (`installer_server.nsi`) server installers automatically configure inbound Windows Defender Firewall rules for WebUI (`TCP 8443`) and mTLS log ingestion (`TCP 9443`).
- **Clean Uninstallation**: Uninstallers automatically remove the created firewall rules cleanly upon server decommissioning.
### 📊 Forwarder Liveness, Silent Server Detection & System Telemetry (Fixes Issue #2)
- **Silent Node & Crash Detection**: Resolved the "Silent Server Down Bug" by tracking forwarder heartbeats (`last_seen`). Hermes reporting (`GET /api/hermes/report`) automatically flags edge nodes that have gone silent for >15 minutes as `CRITICAL` `NODE_OFFLINE` alerts.
- **Troubleshooting System Telemetry**: Cross-platform system telemetry metrics (CPU utilization %, Memory load %, and Free Disk space %) are collected and attached to client heartbeats and Hermes anomaly records without external dependencies.
- **Benign Service Noise Suppression**: Filtered routine repetitive Windows and Linux background service lifecycle events (e.g., `sppsvc`, `edgeupdate`, `trustedinstaller`, `wuauserv`, `backgroundTaskHost`) from false-positive anomaly alert fatigue.
### 🔑 Dynamic Enrollment Key Rotation & WebUI Key Management
- **Automatic Key Invalidation**: Clicking "Download Client Config" (`GET /api/client/export-profile`) automatically generates a fresh enrollment secret and invalidates previous secrets, preventing reuse of leaked keys.
- **WebUI Reset Shared Key**: Added "🔄 Reset Shared Key" controls in the WebUI header, Fleet tab, and Export Modal, backed by `POST /api/server/reset-enrollment-secret` to rotate keys and update quick-deploy command snippets on demand.
### 🔒 Security Hardening & Modular Architecture
- **Decoupled CI & Test Execution**: Clean modularization across server, client, and authority services with fully autonomous unit and integration test suites.
- **API Robustness & Validation**: Added cryptographic CSR validation endpoints and request sanitization.
---
## [v0.3.5.1] - 2026-09-30
### 🗄️ 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+43
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+54
View File
@@ -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 &amp; 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 &#8226; MUTUAL TLS 1.3 &#8226; TEMPORAL PERSISTENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+43
View File
@@ -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

+41
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+43
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+54
View File
@@ -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 &amp; 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 &#8226; MUTUAL TLS 1.3 &#8226; TEMPORAL PERSISTENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+43
View File
@@ -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

+41
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+471
View File
@@ -0,0 +1,471 @@
"""
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")
target_asset_dirs = [ROOT_ASSETS_DIR, COMPILATION_ASSETS_DIR]
licensing_dir = os.path.join(ROOT_DIR, "LOGAR-LICENSING")
if os.path.exists(licensing_dir):
target_asset_dirs.append(os.path.join(licensing_dir, "assets"))
for d in target_asset_dirs:
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 &amp; 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 &#8226; MUTUAL TLS 1.3 &#8226; 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 target_asset_dirs:
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()
+20
View File
@@ -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"
+22 -2
View File
@@ -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
+177 -12
View File
@@ -1,22 +1,35 @@
#ifexist "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;
+144 -10
View File
@@ -1,34 +1,59 @@
!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"
!include /NONFATAL "version.nsh"
!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 +62,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 +83,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 +180,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 +207,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 +234,5 @@ Section "Uninstall"
RMDir /r "$INSTDIR"
RMDir /r "$DATADIR"
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client"
DeleteRegKey /ifempty HKLM "Software\LOGAR"
SectionEnd
+152 -10
View File
@@ -1,22 +1,35 @@
#ifexist "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
@@ -32,9 +45,13 @@ Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppDirectory ""{app}""
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{code:GetDataDir}\server.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{code:GetDataDir}\server_err.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppParameters ""--config """"{code:GetDataDir}\server_config.json"""""""; Flags: runhidden
Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""LOGAR Server WebUI (8443)"" dir=in action=allow protocol=TCP localport=8443"; Flags: runhidden
Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""LOGAR Server Ingestion (9443)"" dir=in action=allow protocol=TCP localport=9443"; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Server"; Flags: runhidden
[UninstallRun]
Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LOGAR Server WebUI (8443)"""; Flags: runhidden
Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LOGAR Server Ingestion (9443)"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Server"; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Server confirm"; Flags: runhidden
@@ -51,6 +68,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 +133,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;
+96 -11
View File
@@ -6,29 +6,49 @@ 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"
!include /NONFATAL "version.nsh"
!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 +57,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 +66,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 +75,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"
@@ -69,15 +132,20 @@ Section "Install"
CreateDirectory "$DATADIR"
ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q'
# Configure Windows Defender Firewall inbound rules for WebUI (8443) and Ingestion (9443)
ExecWait 'netsh advfirewall firewall add rule name="LOGAR Server WebUI (8443)" dir=in action=allow protocol=TCP localport=8443'
ExecWait 'netsh advfirewall firewall add rule name="LOGAR Server Ingestion (9443)" dir=in action=allow protocol=TCP localport=9443'
# Install and configure service via NSSM
ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Server "$INSTDIR\Server.exe"'
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 +156,24 @@ 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 & clean firewall rules
ExecWait 'netsh advfirewall firewall delete rule name="LOGAR Server WebUI (8443)"'
ExecWait 'netsh advfirewall firewall delete rule name="LOGAR Server Ingestion (9443)"'
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 +186,5 @@ Section "Uninstall"
RMDir /r "$INSTDIR"
RMDir /r "$DATADIR"
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Server"
DeleteRegKey /ifempty HKLM "Software\LOGAR"
SectionEnd
+204 -24
View File
@@ -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()
@@ -38,11 +60,17 @@ def write_checksum_file(filename, digest, binary_filename):
def build_pyinstaller_binary(script_path, binary_name):
print(f"[*] Compiling {binary_name} with PyInstaller...")
print(f"[*] Compiling {binary_name} with PyInstaller (Bytecode optimized & stripped)...")
sep = ";" if platform.system() == "Windows" else ":"
cmd = [
sys.executable, "-m", "PyInstaller",
"--onefile",
"--clean",
"--strip",
"--optimize", "2",
"--paths", ROOT_DIR,
"--add-data", f"{SRC_DIR}{sep}src",
"--collect-all", "src",
"--distpath", DIST_DIR,
"--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"),
"--specpath", os.path.join(BUILD_TEMP, f"spec_{binary_name}"),
@@ -60,10 +88,14 @@ def build_linux_zipapp_fallback():
print("[*] Packaging Linux standalone zipapp fallback binaries...")
# Linux Client zipapp
app_dir = os.path.join(BUILD_TEMP, "linux_app")
if os.path.exists(app_dir):
shutil.rmtree(app_dir)
os.makedirs(app_dir, exist_ok=True)
shutil.copytree(SRC_DIR, os.path.join(app_dir, "src"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for f in os.listdir(SRC_DIR):
if f.endswith(".py"):
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_dir, f))
src_f = os.path.join(SRC_DIR, f)
if os.path.isfile(src_f) and f.endswith(".py"):
shutil.copy(src_f, os.path.join(app_dir, f))
client_out = os.path.join(DIST_DIR, "Linux_Client.bin")
zipapp.create_archive(
source=app_dir,
@@ -73,10 +105,14 @@ def build_linux_zipapp_fallback():
)
# Server zipapp
srv_dir = os.path.join(BUILD_TEMP, "linux_srv")
if os.path.exists(srv_dir):
shutil.rmtree(srv_dir)
os.makedirs(srv_dir, exist_ok=True)
shutil.copytree(SRC_DIR, os.path.join(srv_dir, "src"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for f in os.listdir(SRC_DIR):
if f.endswith(".py"):
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(srv_dir, f))
src_f = os.path.join(SRC_DIR, f)
if os.path.isfile(src_f) and f.endswith(".py"):
shutil.copy(src_f, os.path.join(srv_dir, f))
server_out = os.path.join(DIST_DIR, "Server.bin")
zipapp.create_archive(
source=srv_dir,
@@ -173,11 +209,15 @@ def create_windows_exe_zipapp(script_name, exe_name):
the standard Python Windows 64-bit PE launcher stub (t64.exe) to a compiled zipapp archive.
"""
app_temp_dir = os.path.join(BUILD_TEMP, f"win_app_{exe_name}")
if os.path.exists(app_temp_dir):
shutil.rmtree(app_temp_dir)
os.makedirs(app_temp_dir, exist_ok=True)
shutil.copytree(SRC_DIR, os.path.join(app_temp_dir, "src"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
for f in os.listdir(SRC_DIR):
if f.endswith(".py"):
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_temp_dir, f))
src_f = os.path.join(SRC_DIR, f)
if os.path.isfile(src_f) and f.endswith(".py"):
shutil.copy(src_f, os.path.join(app_temp_dir, f))
module_name = os.path.splitext(script_name)[0]
zipapp_temp = os.path.join(BUILD_TEMP, f"{exe_name}.zip")
@@ -268,6 +308,128 @@ 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"
f"!ifndef APP_VERSION\n"
f' !define APP_VERSION "{four_part_version}"\n'
f"!endif\n"
f"!ifndef DISPLAY_VERSION\n"
f' !define DISPLAY_VERSION "{display_version}"\n'
f"!endif\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"
f"#ifndef AppVersion\n"
f' #define AppVersion "{display_version}"\n'
f"#endif\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,20 +437,29 @@ 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")
success = True
makensis_cmd = [
makensis_bin,
f"-DAPP_VERSION={vi_ver}",
f"-DDISPLAY_VERSION={display_ver}"
]
failed_scripts = []
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}")
except Exception as e:
print(f"[!] Warning: Failed to compile {nsi} with {makensis_cmd}: {e}")
success = False
return success
print(f"[!] Error: Failed to compile {nsi} with {makensis_cmd}: {e}")
failed_scripts.append(nsi)
if failed_scripts:
raise RuntimeError(f"NSIS compilation failed for: {', '.join(failed_scripts)}")
return True
def compile_inno_setup_installers():
@@ -316,20 +487,29 @@ 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)
failed_scripts = []
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}")
print(f"[!] Error: Failed to compile {iss} with {iscc_cmd}: {e}")
failed_scripts.append(iss)
if failed_scripts:
raise RuntimeError(f"Inno Setup compilation failed for: {', '.join(failed_scripts)}")
return True
def compile_windows_installers():
if not compile_nsis_installers():
if ensure_makensis():
compile_nsis_installers()
else:
compile_inno_setup_installers()
@@ -384,14 +564,14 @@ def build_windows():
write_checksum_file("win_agent_sha256sum", client_hash, "Win_Client.exe")
sums.append(f"{client_hash} Win_Client.exe")
else:
print(f"[!] Warning: Expected {client_bin} was not found.")
raise RuntimeError(f"Expected Windows client binary was not produced: {client_bin}")
if os.path.exists(server_bin):
server_hash = calculate_sha256(server_bin)
write_checksum_file("win_server_sha256sum", server_hash, "Server.exe")
sums.append(f"{server_hash} Server.exe")
else:
print(f"[!] Warning: Expected {server_bin} was not found.")
raise RuntimeError(f"Expected Windows server binary was not produced: {server_bin}")
# Inno Setup output checksums if present
for inst in ["LOGAR-Client-Setup.exe", "LOGAR-Server-Setup.exe", "LOGAR-Windows-Client.zip", "LOGAR-Windows-Server.zip"]:
@@ -478,14 +658,14 @@ def build_linux():
write_checksum_file("linux_agent_sha256sum", client_hash, "Linux_Client.bin")
sums.append(f"{client_hash} Linux_Client.bin")
else:
print(f"[!] Warning: Expected {client_bin} was not found.")
raise RuntimeError(f"Expected Linux client binary was not produced: {client_bin}")
if os.path.exists(server_bin):
server_hash = calculate_sha256(server_bin)
write_checksum_file("linux_server_sha256sum", server_hash, "Server.bin")
sums.append(f"{server_hash} Server.bin")
else:
print(f"[!] Warning: Expected {server_bin} was not found.")
raise RuntimeError(f"Expected Linux server binary was not produced: {server_bin}")
for item in ["install_linux_client.sh", "install_linux_server.sh", "LOGAR-Linux-Client.tar.gz", "LOGAR-Linux-Server.tar.gz"]:
item_path = os.path.join(DIST_DIR, item)
+4
View File
@@ -6,4 +6,8 @@ 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
httpx>=0.27.0
pywin32>=306; sys_platform == "win32"
-139
View File
@@ -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"
}
-160
View File
@@ -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"
}
-134
View File
@@ -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"
```
-7
View File
@@ -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"
}
-159
View File
@@ -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
```
-14
View File
@@ -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"
}
+8 -1
View File
@@ -54,9 +54,16 @@ RUN pip3 install --no-cache-dir \
pgpy \
fastapi \
uvicorn \
websockets \
pydantic \
requests \
# Code quality / linter
httpx \
# Database drivers & ORM for multi-engine storage
sqlalchemy \
psycopg2-binary \
pymysql \
# Code quality / linter / testing
coverage \
flake8
WORKDIR /workspace
+1 -1
View File
@@ -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`
---
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env python3
"""
LOGAR Master License Authority Proxy
Delegates directly to the standalone submodule: LOGAR-LICENSING/License_Authority.py
Repo: https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
"""
import os
import importlib.util
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SUBMODULE_FILE = os.path.join(ROOT_DIR, "LOGAR-LICENSING", "License_Authority.py")
if not os.path.exists(SUBMODULE_FILE):
raise ImportError(f"LOGAR-LICENSING submodule not found at {SUBMODULE_FILE}. Run 'git submodule update --init --recursive'.")
spec = importlib.util.spec_from_file_location("_submodule_authority", SUBMODULE_FILE)
_sub = importlib.util.module_from_spec(spec)
spec.loader.exec_module(_sub)
# Export public symbols from submodule into this module's namespace
for attr in dir(_sub):
if not attr.startswith("__"):
globals()[attr] = getattr(_sub, attr)
if __name__ == "__main__":
_sub.main()
+7 -485
View File
@@ -1,492 +1,14 @@
import os
import sys
import json
import socket
import ssl
import struct
import argparse
import subprocess
import urllib.request
import warnings
from datetime import datetime, timezone, timedelta
from typing import Optional
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore")
_CUR = os.path.dirname(__file__)
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
import pgpy
for _p in [_CUR, _ROOT]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json"
from src.clients.Linux_Client import *
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.
"""
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
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="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 main():
parser = argparse.ArgumentParser(description="LOGAR Linux 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 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")
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_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).")
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__":
if __name__ == '__main__':
main()
+7 -3035
View File
File diff suppressed because it is too large Load Diff
+14 -540
View File
@@ -1,540 +1,14 @@
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()␍
import os
import sys
_CUR = os.path.dirname(__file__)
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
for _p in [_CUR, _ROOT]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
from src.clients.Win_Client import *
if __name__ == '__main__':
main()
+20
View File
@@ -0,0 +1,20 @@
"""
LOGAR - Central Cloud Ingestion Hub & Fleet Log Distribution System
Modular package namespace exposing:
- hub: Central server, web UI, storage, licensing, and PKI modules
- clients: Windows and Linux log collector forwarders, transport, and updater modules
- common: Versioning and shared utilities
"""
import os
import sys
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from src import hub
from src import clients
from src import common
__all__ = ["hub", "clients", "common"]
+11
View File
@@ -0,0 +1,11 @@
import os
import sys
_CUR = os.path.dirname(__file__)
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
for _p in [_CUR, _ROOT]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
from src.hub.auth_manager import *
+11
View File
@@ -0,0 +1,11 @@
import os
import sys
_CUR = os.path.dirname(__file__)
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
for _p in [_CUR, _ROOT]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
from src.clients.client_transport import *
+11
View File
@@ -0,0 +1,11 @@
import os
import sys
_CUR = os.path.dirname(__file__)
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
for _p in [_CUR, _ROOT]:
if _p and _p not in sys.path:
sys.path.insert(0, _p)
from src.clients.client_updater import *
+169
View File
@@ -0,0 +1,169 @@
"""
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 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:
from src.clients.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 src.clients.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,
check_and_apply_auto_update
)
except ImportError:
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,
check_and_apply_auto_update
)
CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json"
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 noise.
State Tracking: Skips events older than lookback window and already sent cursors.
"""
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 over mTLS or encrypted TCP envelope."""
return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux")
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():
parser = argparse.ArgumentParser(description="LOGAR Linux 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 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:
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_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})")
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
ensure_enrolled(config, machine_id)
collector = LinuxLogCollector(state=state, machine_id=machine_id)
def run_forwarder_cycle():
# Continuously verify/renew PKI enrollment if server is licensed & online
ensure_enrolled(config, machine_id)
# Periodically check server for client auto-updates
try:
check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="linux")
except Exception as update_err:
pass
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__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""
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
try:
from src.clients.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 src.clients.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,
check_and_apply_auto_update
)
except ImportError:
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,
check_and_apply_auto_update
)
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)
# Periodically check server for client auto-updates
try:
check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="windows")
except Exception as update_err:
pass
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()
+1
View File
@@ -0,0 +1 @@
# LOGAR Clients Package
+359
View File
@@ -0,0 +1,359 @@
"""
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
import pgpy
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict, Any
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore")
try:
from src.clients.log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path,
collect_system_metrics
)
except ImportError:
from log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path,
collect_system_metrics
)
try:
from src.common.version import LOGAR_VERSION
except ImportError:
try:
from src.version import LOGAR_VERSION
except ImportError:
from version import LOGAR_VERSION
try:
from src.clients.client_updater import ClientUpdater
except ImportError:
try:
from src.client_updater import ClientUpdater
except ImportError:
from client_updater import ClientUpdater
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,
"version": LOGAR_VERSION
}
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,
"metrics": collect_system_metrics(),
"version": LOGAR_VERSION
}
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,
"metrics": collect_system_metrics(),
"version": LOGAR_VERSION
}
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 check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]:
"""Helper to check and apply auto updates for edge forwarders."""
updater = ClientUpdater(config=config, os_type=os_type)
return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply)
+248
View File
@@ -0,0 +1,248 @@
"""
LOGAR - Client Auto-Update Agent Module
Handles checking the LOGAR Server Hub for client updates, downloading verified binaries (SHA-256),
and applying atomic in-place updates across Windows and Linux forwarders.
"""
import os
import sys
import json
import time
import shutil
import hashlib
import tempfile
import subprocess
import urllib.request
import urllib.error
import urllib.parse
from typing import Dict, Any, Optional, Tuple
try:
from src.version import LOGAR_VERSION, is_newer_version
except ImportError:
from version import LOGAR_VERSION, is_newer_version
class ClientUpdater:
def __init__(self, config: dict, os_type: Optional[str] = None):
self.config = config
self.os_type = (os_type or ("windows" if sys.platform == "win32" else "linux")).lower()
self.current_version = LOGAR_VERSION
@staticmethod
def calculate_sha256(filepath: str) -> str:
"""Computes SHA-256 digest of a local file."""
if not os.path.exists(filepath):
return ""
h = hashlib.sha256()
with open(filepath, "rb") as f:
while chunk := f.read(65536):
h.update(chunk)
return h.hexdigest().lower()
def get_server_base_url(self) -> str:
"""Resolves the HTTP base URL for server management endpoints."""
server_host = self.config.get("server_host", "127.0.0.1")
hermes_host = self.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 = int(self.config.get("hermes_port", 8443))
return f"http://{hermes_host}:{hermes_port}"
def check_for_update(self, client_id: str, timeout: int = 6) -> Dict[str, Any]:
"""
Queries the server Hub to check if a newer client binary is available.
"""
base_url = self.get_server_base_url()
url = (
f"{base_url}/api/client/update/check"
f"?client_id={urllib.parse.quote(client_id)}"
f"&os={urllib.parse.quote(self.os_type)}"
f"&current_version={urllib.parse.quote(self.current_version)}"
)
try:
req = urllib.request.Request(
url,
headers={"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status == 200:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
# Silent fallback during standard forwarder operation
pass
return {"update_available": False, "latest_version": self.current_version}
def download_package(self, download_path_rel: str, expected_sha256: str, target_temp: str, timeout: int = 30) -> bool:
"""
Downloads the client binary package from the server and validates its SHA-256 checksum.
Uses local binary hash checking and ETag conditional headers to avoid redundant downloads.
"""
# 1. If running binary already matches target SHA-256, skip downloading
target_exec = sys.executable
if expected_sha256 and os.path.exists(target_exec):
try:
local_sha = self.calculate_sha256(target_exec)
if local_sha and local_sha == expected_sha256.lower():
print(f"[*] Local binary already matches target checksum ({local_sha[:12]}...). Skipping redundant download.")
return True
except Exception:
pass
base_url = self.get_server_base_url()
download_url = f"{base_url.rstrip('/')}/{download_path_rel.lstrip('/')}"
print(f"[*] Downloading client update package from {download_url}...")
headers = {"User-Agent": f"LOGAR-Client/{self.current_version} ({self.os_type})"}
if expected_sha256:
headers["If-None-Match"] = f'"{expected_sha256}"'
req = urllib.request.Request(download_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status == 304:
print("[+] Server returned 304 Not Modified. Local binary package is current.")
return True
if resp.status != 200:
print(f"[!] Server returned HTTP {resp.status} during update download.")
return False
with open(target_temp, "wb") as f:
while chunk := resp.read(65536):
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code == 304:
print("[+] Server returned 304 Not Modified. Local binary package is current.")
return True
print(f"[!] HTTP error during update download: {e}")
return False
except Exception as e:
print(f"[!] Error downloading update package: {e}")
return False
# Verify SHA-256
actual_sha256 = self.calculate_sha256(target_temp)
if expected_sha256 and actual_sha256 != expected_sha256.lower():
print(f"[!] Update verification failed: Checksum mismatch! Expected: {expected_sha256}, Got: {actual_sha256}")
if os.path.exists(target_temp):
os.remove(target_temp)
return False
print(f"[+] Download verified successfully (SHA-256: {actual_sha256[:16]}...).")
return True
def apply_update_linux(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool:
"""
Applies update on Linux by setting permissions and atomically moving the binary.
"""
target = target_binary_path or sys.executable
try:
os.chmod(new_binary_path, 0o755)
# Atomically replace target binary
os.replace(new_binary_path, target)
print(f"[+] Successfully replaced client binary: {target}")
# If running as a systemd service, restart service
if shutil.which("systemctl"):
try:
subprocess.run(["systemctl", "restart", "logar-client"], check=False)
print("[+] Signaled systemd to restart logar-client service.")
except Exception:
pass
return True
except Exception as e:
print(f"[!] Failed to apply Linux update: {e}")
return False
def apply_update_windows(self, new_binary_path: str, target_binary_path: Optional[str] = None) -> bool:
"""
Applies update on Windows. Since the currently running .exe cannot be directly overwritten,
spawns a detached background helper script that waits for current PID to exit, swaps the binary,
and restarts the service or executable.
"""
target = target_binary_path or sys.executable
pid = os.getpid()
target_dir = os.path.dirname(os.path.abspath(target))
updater_bat = os.path.join(target_dir, f"_update_{pid}.bat")
bat_script = f"""@echo off
setlocal
echo [*] LOGAR Client Auto-Updater waiting for process {pid} to terminate...
:WAIT_LOOP
tasklist /FI "PID eq {pid}" | findstr "{pid}" >nul
if %ERRORLEVEL% == 0 (
timeout /t 1 /nobreak >nul
goto WAIT_LOOP
)
echo [*] Applying update to {target}...
move /Y "{new_binary_path}" "{target}" >nul
if %ERRORLEVEL% NEQ 0 (
copy /Y "{new_binary_path}" "{target}" >nul
)
if exist "{os.path.join(target_dir, 'nssm.exe')}" (
"{os.path.join(target_dir, 'nssm.exe')}" restart LOGAR-Client >nul 2>&1
) else (
start "" "{target}"
)
del "{new_binary_path}" 2>nul
(goto) 2>nul & del "%~f0"
"""
try:
with open(updater_bat, "w", encoding="utf-8") as f:
f.write(bat_script)
# Spawn detached batch process
DETACHED_PROCESS = 0x00000008
CREATE_NEW_PROCESS_GROUP = 0x00000200
subprocess.Popen(
["cmd.exe", "/c", updater_bat],
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
close_fds=True
)
print(f"[+] Detached updater script launched ({updater_bat}). Terminating for restart...")
return True
except Exception as e:
print(f"[!] Failed to launch Windows updater script: {e}")
return False
def check_and_apply_auto_update(self, client_id: str, auto_apply: bool = True) -> Dict[str, Any]:
"""
Main entry point for client periodic auto-update check.
Checks for update, downloads, verifies, and triggers update if available.
"""
update_info = self.check_for_update(client_id)
if not update_info.get("update_available"):
return {"status": "up_to_date", "current_version": self.current_version}
target_ver = update_info.get("latest_version")
download_url = update_info.get("download_url")
expected_sha256 = update_info.get("sha256", "")
print(f"[!] 🔔 New LOGAR Client update available: v{self.current_version} -> v{target_ver}")
if not auto_apply or not download_url:
return {"status": "available", "latest_version": target_ver}
# Prepare temporary file for download
temp_dir = os.path.dirname(os.path.abspath(sys.executable))
if not os.access(temp_dir, os.W_OK):
temp_dir = tempfile.gettempdir()
suffix = ".exe" if self.os_type == "windows" else ".bin"
temp_download = os.path.join(temp_dir, f"_update_pkg_{int(time.time())}{suffix}")
ok = self.download_package(download_url, expected_sha256, temp_download)
if not ok:
return {"status": "download_failed"}
print(f"[*] Applying client update to v{target_ver}...")
if self.os_type == "windows":
applied = self.apply_update_windows(temp_download)
if applied and getattr(sys, "frozen", False):
sys.exit(0)
else:
applied = self.apply_update_linux(temp_download)
return {"status": "updated" if applied else "apply_failed", "latest_version": target_ver}
+679
View File
@@ -0,0 +1,679 @@
"""
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 shutil
import ctypes
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 collect_system_metrics() -> Dict[str, Any]:
"""
Collects cross-platform host health and resource telemetry without requiring external wheels.
Returns:
{
"cpu_percent": float,
"memory_percent": float,
"memory_total_mb": int,
"memory_free_mb": int,
"disk_percent_free": float,
"disk_total_gb": float,
"disk_free_gb": float
}
"""
metrics = {
"cpu_percent": 0.0,
"memory_percent": 0.0,
"memory_total_mb": 0,
"memory_free_mb": 0,
"disk_percent_free": 0.0,
"disk_total_gb": 0.0,
"disk_free_gb": 0.0
}
# 1. Disk metrics
try:
path = "C:\\" if sys.platform == "win32" else "/"
total, used, free = shutil.disk_usage(path)
metrics["disk_total_gb"] = round(total / (1024 ** 3), 2)
metrics["disk_free_gb"] = round(free / (1024 ** 3), 2)
if total > 0:
metrics["disk_percent_free"] = round((free / total) * 100, 1)
except Exception:
pass
# 2. Memory metrics
try:
if sys.platform == "win32":
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", ctypes.c_ulong),
("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("sullAvailExtendedVirtual", ctypes.c_ulonglong),
]
stat = MEMORYSTATUSEX()
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
metrics["memory_percent"] = float(stat.dwMemoryLoad)
metrics["memory_total_mb"] = int(stat.ullTotalPhys / (1024 * 1024))
metrics["memory_free_mb"] = int(stat.ullAvailPhys / (1024 * 1024))
else:
# Linux: read /proc/meminfo
if os.path.exists("/proc/meminfo"):
mem_total = 0
mem_avail = 0
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
parts = line.split(":")
if len(parts) == 2:
k, v = parts[0].strip(), parts[1].strip().split()[0]
if k == "MemTotal":
mem_total = int(v)
elif k in ("MemAvailable", "MemFree") and mem_avail == 0:
mem_avail = int(v)
if mem_total > 0:
metrics["memory_total_mb"] = mem_total // 1024
metrics["memory_free_mb"] = mem_avail // 1024
metrics["memory_percent"] = round(((mem_total - mem_avail) / mem_total) * 100, 1)
except Exception:
pass
# 3. CPU metrics
try:
if hasattr(os, "getloadavg"):
load1, _, _ = os.getloadavg()
cpu_count = os.cpu_count() or 1
metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1)
else:
try:
import psutil
metrics["cpu_percent"] = float(psutil.cpu_percent(interval=None))
except ImportError:
metrics["cpu_percent"] = 0.0
except Exception:
pass
return metrics
def is_routine_service_noise(signature: str, message: str, severity: str) -> bool:
"""
Identifies high-frequency benign background service start/stop cycles
(e.g., sppsvc, edgeupdate, trustedinstaller, wuauserv, backgroundTaskHost)
to prevent false-positive anomaly fatigue and consecutive_run metric bloat.
"""
if str(severity).upper() != "INFO":
return False
sig_low = (signature or "").lower()
msg_low = (message or "").lower()
routine_services = [
"sppsvc",
"edgeupdate",
"trustedinstaller",
"wuauserv",
"backgroundtaskhost",
"service control manager",
"systemd",
"cron"
]
is_routine_svc = any(s in sig_low or s in msg_low for s in routine_services)
if is_routine_svc:
lifecycle_phrases = [
"entered the running state",
"entered the stopped state",
"entered the start pending state",
"entered the stop pending state",
"started successfully",
"stopped successfully",
"service stopped",
"service started",
"session opened",
"session closed",
"clean",
"reloaded"
]
if any(p in msg_low for p in lifecycle_phrases):
return True
return False
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)
+1
View File
@@ -0,0 +1 @@
# LOGAR Common Package
+67
View File
@@ -0,0 +1,67 @@
"""
LOGAR - Unified Versioning and Semantic Comparison Module
Provides canonical application version constants and semantic version comparison utilities.
"""
import os
import re
from typing import Tuple, Optional
LOGAR_VERSION = "0.3.5.2"
__version__ = LOGAR_VERSION
def parse_version(version_str: Optional[str]) -> Tuple[int, ...]:
"""
Parses a version string into a comparable tuple of integers.
Strips leading 'v' or 'V' and non-numeric suffixes.
e.g. 'v0.3.5.2' -> (0, 3, 5, 2), '0.3.5-beta' -> (0, 3, 5, 0)
"""
if not version_str:
return (0, 0, 0, 0)
clean = str(version_str).strip()
if clean.lower().startswith("v"):
clean = clean[1:].strip()
# Extract digit sequences separated by dots
match = re.match(r"^(\d+(?:\.\d+)*)", clean)
if not match:
return (0, 0, 0, 0)
parts = [int(p) for p in match.group(1).split(".")]
# Pad to at least 4 components for consistent 4-part semantic version comparison
while len(parts) < 4:
parts.append(0)
return tuple(parts)
def compare_versions(v1: Optional[str], v2: Optional[str]) -> int:
"""
Compares two version strings.
Returns:
1 if v1 > v2 (v1 is newer than v2)
0 if v1 == v2
-1 if v1 < v2 (v1 is older than v2)
"""
t1 = parse_version(v1)
t2 = parse_version(v2)
if t1 > t2:
return 1
elif t1 < t2:
return -1
return 0
def is_newer_version(candidate_version: Optional[str], current_version: Optional[str] = None) -> bool:
"""
Returns True if candidate_version is strictly newer than current_version (or LOGAR_VERSION).
"""
base = current_version or LOGAR_VERSION
return compare_versions(candidate_version, base) > 0
def get_current_version() -> str:
"""Returns the canonical current version string."""
return LOGAR_VERSION
+2536
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# LOGAR Hub Package
+398
View File
@@ -0,0 +1,398 @@
"""
LOGAR - Server Authentication & Access Control Managers
Provides modular classes for Session Management, Rate Limiting, Password Hashing,
Two-Factor Authentication (TOTP / RFC 6238), and Hermes API Tokens.
"""
import os
import time
import base64
import struct
import secrets
import hashlib
import hmac
import sqlite3
import urllib.parse
import threading
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List, Tuple
from fastapi import Request, HTTPException
class SessionManager:
"""Manages cryptographic session secrets and HMAC-signed session tokens."""
SESSION_COOKIE_NAME = "logar_server_session"
_CACHED_SESSION_SECRETS: Dict[str, str] = {}
@classmethod
def get_session_secret(cls, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> str:
"""Retrieves or persists a cryptographically secure session secret in the database."""
env_sec = os.environ.get("LOGAR_SESSION_SECRET")
if env_sec:
return env_sec
target_db = db_path or fallback_db
abs_db = os.path.abspath(target_db)
if abs_db in cls._CACHED_SESSION_SECRETS:
return cls._CACHED_SESSION_SECRETS[abs_db]
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS server_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
c.execute("SELECT value FROM server_meta WHERE key = 'session_secret'")
row = c.fetchone()
if row and row[0]:
cls._CACHED_SESSION_SECRETS[abs_db] = row[0]
conn.close()
return row[0]
new_secret = secrets.token_hex(32)
c.execute("INSERT OR REPLACE INTO server_meta (key, value) VALUES ('session_secret', ?)", (new_secret,))
conn.commit()
conn.close()
cls._CACHED_SESSION_SECRETS[abs_db] = new_secret
return new_secret
except Exception:
fallback = secrets.token_hex(32)
cls._CACHED_SESSION_SECRETS[abs_db] = fallback
return fallback
@classmethod
def generate_session_token(cls, username: str, db_path: Optional[str] = None, fallback_db: str = "logar_state.db") -> str:
"""Generates an HMAC-signed session cookie string."""
ts = int(datetime.now(timezone.utc).timestamp())
nonce = secrets.token_hex(8)
payload = f"{username}:{ts}:{nonce}"
secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db)
sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
@classmethod
def verify_session_token(
cls,
token: Optional[str],
max_age_hours: int = 24,
db_path: Optional[str] = None,
fallback_db: str = "logar_state.db"
) -> Optional[str]:
"""Verifies session token authenticity and age."""
if not token or ":" not in token:
return None
parts = token.split(":")
if len(parts) != 4:
return None
username, ts_str, nonce, sig = parts
payload = f"{username}:{ts_str}:{nonce}"
secret = cls.get_session_secret(db_path=db_path, fallback_db=fallback_db)
expected_sig = hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected_sig):
return None
try:
ts = int(ts_str)
if datetime.now(timezone.utc).timestamp() - ts > max_age_hours * 3600:
return None
except Exception:
return None
return username
class RateLimiter:
"""Sliding-window IP rate limiter with persistent SQLite tracking and memory fallback."""
def __init__(self):
self._lock = threading.Lock()
self._login_failures: Dict[str, List[float]] = {}
self._enroll_failures: Dict[str, List[float]] = {}
@staticmethod
def get_client_ip(request: Optional[Request] = None) -> str:
"""Extracts client IP address respecting reverse-proxy headers."""
if not request:
return "127.0.0.1"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client:
return request.client.host
return "127.0.0.1"
def check_ip_rate_limit(
self,
ip: str,
store: Any = None,
max_attempts: int = 10,
window_seconds: int = 60,
scope: str = "login",
db_path: Optional[str] = None,
fallback_db: str = "logar_state.db"
) -> bool:
"""Checks whether the client IP has exceeded failed attempts within the sliding window."""
now = datetime.now(timezone.utc).timestamp()
cutoff = now - window_seconds
target_db = db_path or fallback_db
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
c.execute("DELETE FROM rate_limits WHERE attempt_time < ?", (cutoff,))
c.execute("SELECT COUNT(*) FROM rate_limits WHERE ip = ? AND scope = ? AND attempt_time >= ?", (ip, scope, cutoff))
cnt = c.fetchone()[0]
conn.commit()
conn.close()
return cnt < max_attempts
except Exception:
with self._lock:
effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures)
history = effective_store.get(ip, [])
history = [ts for ts in history if now - ts < window_seconds]
effective_store[ip] = history
return len(history) < max_attempts
def record_failed_ip_attempt(
self,
ip: str,
store: Any = None,
scope: str = "login",
db_path: Optional[str] = None,
fallback_db: str = "logar_state.db"
):
"""Records a persistent failed attempt timestamp for the client IP and scope."""
now = datetime.now(timezone.utc).timestamp()
target_db = db_path or fallback_db
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS rate_limits (ip TEXT NOT NULL, scope TEXT NOT NULL, attempt_time REAL NOT NULL)")
c.execute("INSERT INTO rate_limits (ip, scope, attempt_time) VALUES (?, ?, ?)", (ip, scope, now))
conn.commit()
conn.close()
except Exception:
pass
with self._lock:
effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures)
if ip not in effective_store:
effective_store[ip] = []
effective_store[ip].append(now)
def reset_failed_ip_attempts(
self,
ip: str,
store: Any = None,
scope: str = "login",
db_path: Optional[str] = None,
fallback_db: str = "logar_state.db"
):
"""Resets persistent failed attempt records for client IP upon successful authentication."""
target_db = db_path or fallback_db
try:
conn = sqlite3.connect(target_db, timeout=5.0)
c = conn.cursor()
c.execute("DELETE FROM rate_limits WHERE ip = ? AND scope = ?", (ip, scope))
conn.commit()
conn.close()
except Exception:
pass
with self._lock:
effective_store = store if isinstance(store, dict) else (self._login_failures if scope == "login" else self._enroll_failures)
if ip in effective_store:
del effective_store[ip]
class PasswordManager:
"""PBKDF2-HMAC-SHA256 password hashing and validation."""
@staticmethod
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
"""Hashes a password using PBKDF2-HMAC-SHA256 with 100,000 iterations."""
if not salt:
salt = secrets.token_hex(16)
pwd_hash = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000).hex()
return pwd_hash, salt
@staticmethod
def verify_password(password: str, salt: str, password_hash: str) -> bool:
"""Verifies a password against the stored salt and PBKDF2 hash."""
test_hash, _ = PasswordManager.hash_password(password, salt=salt)
return hmac.compare_digest(test_hash, password_hash)
@staticmethod
def verify_user_credentials(username: str, password: str, db_path: str = "logar_state.db") -> bool:
"""Checks user credentials against SQLite users table."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT password_hash, salt FROM users WHERE username = ?", (username,))
row = c.fetchone()
conn.close()
if not row:
return False
return PasswordManager.verify_password(password, row[1], row[0])
@staticmethod
def change_user_password(username: str, new_password: str, db_path: str = "logar_state.db") -> bool:
"""Updates a user's password."""
pwd_hash, salt = PasswordManager.hash_password(new_password)
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("UPDATE users SET password_hash = ?, salt = ? WHERE username = ?", (pwd_hash, salt, username))
changed = c.rowcount > 0
conn.commit()
conn.close()
return changed
class TOTPManager:
"""RFC 6238 Two-Factor Authentication (TOTP) generator and validator."""
@staticmethod
def generate_totp_secret(length: int = 20) -> str:
"""Generates a cryptographically random Base32-encoded TOTP secret key."""
return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "")
@staticmethod
def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str:
"""Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238."""
if intervals_no is None:
intervals_no = int(time.time()) // 30
key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True)
msg = struct.pack(">Q", intervals_no)
h = hmac.new(key, msg, hashlib.sha1).digest()
o = h[19] & 15
h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000
return f"{h_int:06d}"
@staticmethod
def verify_totp_code(secret: str, code: str, window: int = 1) -> bool:
"""Verifies a 6-digit TOTP code with standard drift window (+/- 30s)."""
if not secret or not code:
return False
code = str(code).strip().replace(" ", "")
if len(code) != 6 or not code.isdigit():
return False
now = int(time.time())
current_step = now // 30
for offset in range(-window, window + 1):
if hmac.compare_digest(TOTPManager.get_totp_token(secret, current_step + offset), code):
return True
return False
@staticmethod
def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str:
"""Generates standard otpauth URI for QR code generation."""
user_enc = urllib.parse.quote(username)
issuer_enc = urllib.parse.quote(issuer)
return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30"
@staticmethod
def get_user_totp_info(username: str, db_path: str = "logar_state.db") -> Dict[str, Any]:
"""Retrieves TOTP configuration for a user from the database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,))
row = c.fetchone()
if row:
return {"secret": row[0] or "", "enabled": bool(row[1])}
except Exception:
pass
finally:
conn.close()
return {"secret": "", "enabled": False}
@staticmethod
def enable_user_totp(username: str, secret: str, db_path: str = "logar_state.db") -> bool:
"""Enables and activates TOTP for a user in the database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username))
changed = c.rowcount > 0
conn.commit()
return changed
finally:
conn.close()
@staticmethod
def disable_user_totp(username: str, db_path: str = "logar_state.db") -> bool:
"""Disables TOTP for a user in the database."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
try:
c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,))
changed = c.rowcount > 0
conn.commit()
return changed
finally:
conn.close()
class HermesTokenManager:
"""Manages Bearer API tokens for Hermes AI anomaly integration."""
@staticmethod
def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = "logar_state.db") -> Dict[str, Any]:
"""Creates or saves a named Hermes API token."""
if not token:
token = f"hermes_{secrets.token_hex(20)}"
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO hermes_tokens (token, name, created_at, status) VALUES (?, ?, ?, 'active')",
(token, name, now_iso)
)
conn.commit()
conn.close()
return {"token": token, "name": name, "created_at": now_iso, "status": "active"}
@staticmethod
def list_hermes_tokens(db_path: str = "logar_state.db") -> List[Dict[str, Any]]:
"""Lists all registered Hermes access tokens."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT token, name, created_at, last_used, status FROM hermes_tokens ORDER BY created_at DESC")
rows = [
{"token": r[0], "name": r[1], "created_at": r[2], "last_used": r[3] or "Never", "status": r[4]}
for r in c.fetchall()
]
conn.close()
return rows
@staticmethod
def revoke_hermes_token(token: str, db_path: str = "logar_state.db") -> bool:
"""Revokes an active Hermes token."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("UPDATE hermes_tokens SET status = 'revoked' WHERE token = ?", (token,))
changed = c.rowcount > 0
conn.commit()
conn.close()
return changed
@staticmethod
def delete_hermes_token(token: str, db_path: str = "logar_state.db") -> bool:
"""Deletes a Hermes token."""
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("DELETE FROM hermes_tokens WHERE token = ?", (token,))
changed = c.rowcount > 0
conn.commit()
conn.close()
return changed
@staticmethod
def verify_hermes_token(token: str, db_path: str = "logar_state.db") -> bool:
"""Verifies a Hermes token and updates last_used timestamp."""
if not token:
return False
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT token FROM hermes_tokens WHERE token = ? AND status = 'active'", (token,))
row = c.fetchone()
if row:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
c.execute("UPDATE hermes_tokens SET last_used = ? WHERE token = ?", (now_iso, token))
conn.commit()
conn.close()
return row is not None
+114
View File
@@ -0,0 +1,114 @@
"""
LOGAR - Ingestion Queue Manager
Provides a high-throughput thread-safe ring buffer and batch processor for incoming telemetry.
Eliminates disk write contention by decoupling TCP frame ingress from physical database writes.
"""
import time
import threading
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Callable
class IngestQueueManager:
"""
High-throughput in-memory buffering and batch processor for incoming telemetry and log anomalies.
Eliminates SQLite / SQL disk write contention by decoupling TCP frame ingress from physical DB writes.
"""
def __init__(self, flush_interval: float = 1.0, batch_size: int = 100, max_queue: int = 50000):
self.flush_interval = flush_interval
self.batch_size = batch_size
self.max_queue = max_queue
self._lock = threading.Lock()
self._buffer: List[Dict[str, Any]] = []
self._total_ingested = 0
self._total_flushed = 0
self._dropped_count = 0
self._last_flush_time = "Never"
self._avg_flush_latency_ms = 0.0
self._flush_count = 0
def push(self, payload: Dict[str, Any]) -> bool:
"""Pushes a log payload into the ring buffer."""
with self._lock:
if len(self._buffer) >= self.max_queue:
self._dropped_count += 1
return False
self._buffer.append(payload)
self._total_ingested += len(payload.get("logs", []))
return True
def get_stats(self) -> Dict[str, Any]:
"""Returns live statistics about queue occupancy, throughput, and flush latency."""
with self._lock:
return {
"queue_size": len(self._buffer),
"total_ingested": self._total_ingested,
"total_flushed": self._total_flushed,
"dropped_count": self._dropped_count,
"last_flush_time": self._last_flush_time,
"avg_flush_latency_ms": round(self._avg_flush_latency_ms, 2),
"flush_count": self._flush_count,
"flush_interval_seconds": self.flush_interval,
"batch_size_threshold": self.batch_size
}
def flush(
self,
db_path: Optional[str] = None,
window_hours: int = 12,
min_runs: int = 1,
process_callback: Optional[Callable[..., Dict[str, Any]]] = None
) -> Dict[str, Any]:
"""Flushes all queued items to persistent storage."""
with self._lock:
if not self._buffer:
return {"status": "noop", "flushed_runs": 0, "processed_logs": 0}
items_to_process = list(self._buffer)
self._buffer.clear()
start_time = time.time()
target_db = db_path or "logar_state.db"
total_processed = 0
promoted_total = 0
try:
for payload in items_to_process:
if process_callback:
res = process_callback(
payload=payload,
db_path=target_db,
window_hours=window_hours,
min_runs=min_runs
)
else:
# Fallback if imported directly
from src import Server
res = Server.process_ingested_logs(
payload=payload,
db_path=target_db,
window_hours=window_hours,
min_runs=min_runs
)
total_processed += res.get("processed", 0)
promoted_total += res.get("promoted_verified", 0)
except Exception as e:
print(f"[!] Error during batch flush to DB: {e}")
elapsed_ms = (time.time() - start_time) * 1000.0
with self._lock:
self._total_flushed += total_processed
self._flush_count += 1
self._avg_flush_latency_ms = (
(self._avg_flush_latency_ms * (self._flush_count - 1) + elapsed_ms) / self._flush_count
)
self._last_flush_time = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return {
"status": "success",
"flushed_runs": len(items_to_process),
"processed_logs": total_processed,
"promoted_verified": promoted_total,
"flush_latency_ms": round(elapsed_ms, 2)
}
+260
View File
@@ -0,0 +1,260 @@
"""
LOGAR - Licensing & Sub-CA Introspection Manager
Provides modular classes for PKI X.509 extension parsing, capability gating,
Subordinate CA seat tracking, and asynchronous license reporting.
"""
import os
import json
import socket
import sqlite3
import threading
from typing import Optional, Dict, Any, List, Tuple
try:
from src import server_enrollment as enrollment
except ImportError:
import server_enrollment as enrollment
class LicenseManager:
"""Manages PKI licensing introspection, feature unlocking, and upstream seat syncing."""
DEFAULT_DB_FILE = "logar_state.db"
@staticmethod
def export_license_request_lic(config: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
"""Generates CA CSR and packages into exportable .lic dictionary and JSON string."""
cert_dir = config.get("cert_dir", "certs")
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
hostname = socket.gethostname()
seats = config.get("max_seats", 10)
fp = config.get("server_fingerprint", "")
return enrollment.create_license_request_lic(
cert_dir=cert_dir,
license_server=lic_server,
server_fingerprint=fp,
hostname=hostname,
requested_seats=seats
)
@staticmethod
def get_active_licensed_features(db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> List[str]:
"""
Returns the list of all active enterprise capability identifiers
authorized either via verified X.509 certificate extensions or the persistent database license config.
"""
features = set()
state = server_state or {}
cert_dir = state.get("config", {}).get("cert_dir", "certs")
upstream_pem = None
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_file):
try:
with open(upstream_file, "r", encoding="utf-8") as f:
upstream_pem = f.read()
except Exception:
pass
# 1. Check in-memory / active Subordinate CA certificate
ca_pem = state.get("ca_cert_pem")
if not ca_pem:
ca_file = os.path.join(cert_dir, "ca.crt")
if os.path.exists(ca_file):
try:
with open(ca_file, "r", encoding="utf-8") as f:
ca_pem = f.read()
except Exception:
pass
cert_is_verified = False
if ca_pem:
try:
info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem)
cert_is_verified = info.get("signature_verified", False) or not info.get("is_self_signed", False)
for feat in info.get("features", []):
if feat:
features.add(str(feat).strip())
except Exception:
pass
# 2. Check license_config table in database
target_db = db_path or state.get("config", {}).get("db_path", LicenseManager.DEFAULT_DB_FILE)
if target_db and os.path.exists(target_db):
try:
conn = sqlite3.connect(target_db, timeout=3.0)
c = conn.cursor()
c.execute("SELECT features FROM license_config WHERE id = 1")
row = c.fetchone()
conn.close()
if row and row[0]:
feat_raw = row[0]
if isinstance(feat_raw, str):
if feat_raw.startswith("["):
feat_list = json.loads(feat_raw)
else:
feat_list = [f.strip() for f in feat_raw.split(",") if f.strip()]
for f_item in feat_list:
if f_item:
features.add(str(f_item).strip())
elif isinstance(feat_raw, list):
for f_item in feat_raw:
if f_item:
features.add(str(f_item).strip())
except Exception:
pass
return sorted(list(features))
@staticmethod
def is_feature_licensed(feature: str, db_path: Optional[str] = None, server_state: Optional[Dict[str, Any]] = None) -> bool:
"""Checks whether a specific enterprise capability (e.g. 'enterprise_db') is authorized."""
if not feature:
return False
return feature in LicenseManager.get_active_licensed_features(db_path=db_path, server_state=server_state)
@staticmethod
def get_license_summary(server_state: Optional[Dict[str, Any]] = None, db_path: Optional[str] = None) -> Dict[str, Any]:
"""Inspects certs/ca.crt, database quotas, verifies cryptographic signatures, and returns comprehensive licensing status."""
state = server_state or {}
config = state.get("config", {})
cert_dir = config.get("cert_dir", "certs")
ca_path = os.path.join(cert_dir, "ca.crt")
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
target_db = db_path or config.get("db_path", LicenseManager.DEFAULT_DB_FILE)
upstream_pem = None
if os.path.exists(upstream_path):
try:
with open(upstream_path, "r", encoding="utf-8") as f:
upstream_pem = f.read().strip()
except Exception:
pass
db_max_seats = config.get("max_seats", 0)
license_status_db = "unlicensed"
try:
conn = sqlite3.connect(target_db)
c = conn.cursor()
c.execute("SELECT max_seats, license_status FROM license_config WHERE id = 1")
row = c.fetchone()
if row:
if row[0] is not None:
db_max_seats = row[0]
if len(row) > 1 and row[1]:
license_status_db = row[1]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
active_seats = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
revoked_seats = c.fetchone()[0]
conn.close()
except Exception:
active_seats = 0
revoked_seats = 0
csr_path = os.path.join(cert_dir, "ca.csr")
csr_exists = os.path.exists(csr_path)
cert_seats = None
if os.path.exists(ca_path):
try:
with open(ca_path, "r", encoding="utf-8") as f:
ca_pem = f.read()
info = enrollment.extract_license_info_from_cert(ca_pem, upstream_ca_pem=upstream_pem)
if info.get("seats") is not None:
cert_seats = info["seats"]
except Exception:
info = {"valid": False}
else:
info = {"valid": False, "is_self_signed": False, "is_expired": False, "days_remaining": 0}
# Anti-tampering reconciliation: If database max_seats diverged from certified quota, enforce certified quota and update DB
if cert_seats is not None:
max_seats = cert_seats
if db_max_seats != cert_seats and os.path.exists(target_db):
try:
conn = sqlite3.connect(target_db)
conn.execute("UPDATE license_config SET max_seats = ? WHERE id = 1", (cert_seats,))
conn.commit()
conn.close()
except Exception:
pass
else:
max_seats = db_max_seats
license_id = info.get("license_id") or "UNLICENSED"
issuer_url = info.get("issuer_url") or config.get("license_server_url", "https://logar.eibl.tech")
if info.get("is_expired"):
status_label = "EXPIRED"
elif info.get("seats") and not info.get("is_self_signed"):
status_label = "ACTIVE"
elif max_seats > 0 and license_status_db == "active":
status_label = "ACTIVE"
elif info.get("is_self_signed"):
status_label = "SELF-SIGNED" if max_seats > 0 else "UNLICENSED"
else:
status_label = "UNLICENSED" if max_seats <= 0 else "ACTIVE"
pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0
return {
"status": status_label,
"active_seats": active_seats,
"active_seats_taken": active_seats,
"revoked_seats": revoked_seats,
"max_seats": max_seats,
"available_seats": max(0, max_seats - active_seats),
"percentage": min(100, pct),
"license_id": license_id,
"issuer_url": issuer_url,
"issuer_cn": info.get("issuer_cn", "N/A"),
"subject_cn": info.get("subject_cn", "N/A"),
"is_self_signed": info.get("is_self_signed", False),
"signature_verified": info.get("signature_verified", False),
"not_valid_before": info.get("not_valid_before", "N/A"),
"not_valid_after": info.get("not_valid_after", "N/A"),
"days_remaining": info.get("days_remaining", 0),
"fingerprint": info.get("fingerprint", "N/A"),
"csr_exists": csr_exists,
"server_fingerprint": config.get("server_fingerprint", "N/A"),
"features": LicenseManager.get_active_licensed_features(db_path=target_db, server_state=state)
}
@staticmethod
def report_seat_quota_to_authority_sync(server_state: Optional[Dict[str, Any]] = None):
"""Background helper to push active seat count to upstream authority without blocking."""
try:
state = server_state or {}
config = state.get("config", {})
cert_dir = config.get("cert_dir", "certs")
db_path = config.get("db_path", LicenseManager.DEFAULT_DB_FILE)
lic_server = config.get("license_server_url", "https://logar.eibl.tech")
if not os.path.exists(os.path.join(cert_dir, "ca.crt")) or not os.path.exists(os.path.join(cert_dir, "ca.key")):
return
active_seats = 0
if os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone()
if row:
active_seats = row[0]
conn.close()
except Exception:
pass
enrollment.renew_ca_cert_mtls(
cert_dir=cert_dir,
license_server_url=lic_server,
seats_taken=active_seats,
active_seats=active_seats,
hostname=config.get("server_name", ""),
server_fingerprint=config.get("server_fingerprint", "")
)
except Exception:
pass
@staticmethod
def trigger_async_seat_report(server_state: Optional[Dict[str, Any]] = None):
"""Fires a non-blocking background thread to sync seat usage with upstream authority."""
threading.Thread(target=LicenseManager.report_seat_quota_to_authority_sync, args=(server_state,), daemon=True).start()

Some files were not shown because too many files have changed in this diff Show More