Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4348e6ce9e | ||
|
|
d261802f8d | ||
|
|
39e0e6f219 | ||
|
|
62d4993e74 | ||
|
|
52519e258e | ||
|
|
1bf3f0ed6f | ||
|
|
b44191ed89 | ||
|
|
fc97964e95 | ||
|
|
6b281626da | ||
|
|
72d9646cc4 | ||
|
|
cccf928f29 | ||
|
|
bcaae4a9ee | ||
|
|
5456d50774 | ||
|
|
6b2b39b080 | ||
|
|
285360b59f | ||
|
|
02bc65698b | ||
|
|
b069e1c1c0 | ||
|
|
41563a8fb8 | ||
|
|
f0f6eba53b | ||
|
|
8f8a605c98 | ||
|
|
548b2fd8ad | ||
|
|
9b80007a86 | ||
|
|
cd6b9baa8e | ||
|
|
ea2ff1c4a9 | ||
|
|
2d7357394f | ||
|
|
4c1ab1ea86 | ||
|
|
2ecb356516 | ||
|
|
3e7870c278 | ||
|
|
57eb05fab1 | ||
|
|
e41c304208 | ||
|
|
57f3dd896f | ||
|
|
303744a3f8 | ||
|
|
d9bc8df002 | ||
|
|
8b12186b43 | ||
|
|
df9f5d191b | ||
|
|
9ebfadbe89 | ||
|
|
fe9d43cab6 | ||
|
|
f0c4a82fc3 | ||
|
|
2c08f9fc15 | ||
|
|
f2a9e43619 | ||
|
|
909dc640e3 | ||
|
|
0edae4d797 | ||
|
|
cc25c31884 | ||
|
|
421eafb72d | ||
|
|
ca8b0153fa | ||
|
|
50f76ca3c3 | ||
|
|
ea6410d391 | ||
|
|
41d7d66a56 | ||
|
|
959f2977f0 | ||
|
|
40cdbf349c | ||
|
|
ca1b5b5d25 | ||
|
|
c1d4675321 | ||
|
|
2d33b14d60 | ||
|
|
2b0b680188 | ||
|
|
f0bf90e033 | ||
|
|
22072a0b88 | ||
|
|
730f0ba7ca | ||
|
|
b7c27ee3a1 | ||
|
|
fa662da476 | ||
|
|
6cb9631958 | ||
|
|
c03bb348b5 | ||
|
|
2d39f3c007 | ||
|
|
78e62b31d2 | ||
|
|
860e39b360 | ||
|
|
2a04244db3 | ||
|
|
9f999fc6c3 | ||
|
|
3399c6d57b | ||
|
|
c6d04f8896 | ||
|
|
0a67b43619 | ||
|
|
3234acf435 | ||
|
|
702ca985d2 | ||
|
|
97d92d1bee | ||
|
|
a8cd2cd83c | ||
|
|
6843b22ac1 | ||
|
|
bfd576bfdd | ||
|
|
9810220cb7 | ||
|
|
13ca1cacda | ||
|
|
4d607d0a9e | ||
|
|
ad80620e41 | ||
|
|
6fb631f908 | ||
|
|
65ec1b3b1b | ||
|
|
7b3b822974 | ||
|
|
5b97ccc1f2 | ||
|
|
d6e5ef7107 | ||
|
|
d689713234 | ||
|
|
61825571b1 | ||
|
|
06db6672ce | ||
|
|
a6eddc207c | ||
|
|
a70ce9162d | ||
|
|
900d03b7f7 | ||
|
|
a8d59b2f14 | ||
|
|
b79022bc41 | ||
|
|
fcba813edd | ||
|
|
72e3527c79 | ||
|
|
4a40b4d395 | ||
|
|
399cf469c5 | ||
|
|
ce7d2b97d2 | ||
|
|
2e256baf4a | ||
|
|
1a984caaf7 | ||
|
|
96756ca5db | ||
|
|
157229a84e | ||
|
|
8287af3129 | ||
|
|
a133050cb5 | ||
|
|
37f959e402 | ||
|
|
f1e5e8f986 | ||
|
|
5a0fdb4374 | ||
|
|
fdfa8dfe7f | ||
|
|
b40966ac4c | ||
|
|
e47fe609a6 | ||
|
|
48bb279601 | ||
|
|
fd34fe9911 | ||
|
|
a7ff1c845d | ||
|
|
b0066c9ea6 | ||
|
|
079c6cb70f | ||
|
|
49525594a3 | ||
|
|
04ccc999a8 | ||
|
|
267aa54076 | ||
|
|
2c9f8d9b91 | ||
|
|
125870b135 | ||
|
|
f0df5a6e4d | ||
|
|
5b59b18c2c | ||
|
|
ec80908d5b | ||
|
|
92d858841d | ||
|
|
0e616d614c | ||
|
|
38b36c28a0 | ||
|
|
c51194e699 | ||
|
|
3a3d251817 | ||
|
|
25adb46d48 | ||
|
|
b33294f36c | ||
|
|
b8069ee66b | ||
|
|
d025689193 | ||
|
|
96126cdf84 | ||
|
|
17c1791b7d | ||
|
|
911014fae7 | ||
|
|
de95a3283d | ||
|
|
b0bf322275 | ||
|
|
a496b7e822 | ||
|
|
29784e166f | ||
|
|
56844983e2 | ||
|
|
07b8f48631 | ||
|
|
fa8b9e0af5 | ||
|
|
93d9f0e463 | ||
|
|
41cb956416 | ||
|
|
42916dc8ee | ||
|
|
a34934959d | ||
|
|
fe296b42e7 | ||
|
|
e04082d4b8 | ||
|
|
ad022cc447 | ||
|
|
344d57ff49 | ||
|
|
19c60616b1 | ||
|
|
14b3586b10 | ||
|
|
a9d5d641cd | ||
|
|
1d8449515e | ||
|
|
668f6e66cd | ||
|
|
6ac2c89aac | ||
|
|
cce439f0e2 | ||
|
|
2c6265b6cc | ||
|
|
4396052ed3 | ||
|
|
c2fed0379c | ||
|
|
e01421ea8c | ||
|
|
2e10aa4819 | ||
|
|
777d6300a8 | ||
|
|
c3bb440eff | ||
|
|
ea3a2cd541 | ||
|
|
8c6d3f57f9 | ||
|
|
f7584320f9 | ||
|
|
d9d895e468 | ||
|
|
4c8db1ec94 | ||
|
|
5fe74325ec | ||
|
|
871d3c15ab | ||
|
|
7c57cf0645 | ||
|
|
2d75b595e6 | ||
|
|
d152b65f6e | ||
|
|
eced9bc5a1 | ||
|
|
3292702dd3 | ||
|
|
fac6ae8847 | ||
|
|
f4996a53cc | ||
|
|
05f703b024 | ||
|
|
eb628ff40b | ||
|
|
79a2720363 | ||
|
|
27de5e9e06 | ||
|
|
dacf01d5b5 | ||
|
|
432a488019 | ||
|
|
384d096de2 | ||
|
|
85bae117b6 | ||
|
|
c8b309d0af | ||
|
|
3b2c3e0fd7 | ||
|
|
b7da54feb2 | ||
|
|
2cd7a990b5 | ||
|
|
2980ad2268 | ||
|
|
95c33c1bf3 | ||
|
|
561ce2e037 | ||
|
|
e89ff9fa27 | ||
|
|
494fef07ba | ||
|
|
4be0107329 | ||
|
|
914db938c3 | ||
|
|
506d3fc234 | ||
|
|
ff5caaf8c7 | ||
|
|
63e8ff5492 | ||
|
|
18e28e2797 | ||
|
|
0e9e3f0f64 | ||
|
|
ea8e98218c | ||
|
|
d9cfb06fad | ||
|
|
76812a1711 | ||
|
|
9584b2c229 | ||
|
|
c9e9a5d267 | ||
|
|
91aee0e591 | ||
|
|
86ae3a11bc | ||
|
|
76d53fbd92 | ||
|
|
39790d371b | ||
|
|
6300481349 | ||
|
|
59029aa3d9 | ||
|
|
431352ddce | ||
|
|
52be1f23a6 | ||
|
|
353001b40f | ||
|
|
99743d9074 | ||
|
|
cbda93517b | ||
|
|
38b634b68f | ||
|
|
16539837c0 | ||
|
|
7b9160af9c | ||
|
|
f72bb06115 | ||
|
|
840daf0688 | ||
|
|
947a786868 | ||
|
|
b457c7575f | ||
|
|
ed9c4aa9d6 | ||
|
|
e4ea8f8deb | ||
|
|
6399912995 | ||
|
|
e77437a042 | ||
|
|
1c2d46cef8 | ||
|
|
9ff116fc4d | ||
|
|
983312573d | ||
|
|
bc2cc65571 | ||
|
|
4b74ea23af | ||
|
|
82eb10889d | ||
|
|
efa9ea7720 | ||
|
|
25a36c8092 | ||
|
|
4171649bd3 | ||
|
|
5670965e9d | ||
|
|
032a5a61b4 | ||
|
|
b1327a4336 | ||
|
|
6332dff670 |
@@ -0,0 +1,71 @@
|
||||
name: Build and Push LOGAR Runner
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'runner/**'
|
||||
- '.gitea/workflows/build-runner.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push Runner Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Ensure Docker CLI is available
|
||||
run: |
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker CLI not found. Downloading static Docker CLI binary..."
|
||||
curl -fSL --connect-timeout 15 --max-time 120 --retry 3 https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz -o /tmp/docker.tgz
|
||||
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
|
||||
chmod +x /usr/local/bin/docker
|
||||
rm -f /tmp/docker.tgz
|
||||
fi
|
||||
docker version
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
run: |
|
||||
REGISTRY="gitea.eibl.tech"
|
||||
USER="${{ secrets.GITEAUSER || secrets.PACKAGESUSER || github.actor }}"
|
||||
PASS="${{ secrets.GITEATOKEN || secrets.PACKAGESTOKEN || secrets.TAG_TOKEN || github.token }}"
|
||||
echo "Logging in to ${REGISTRY} as ${USER}..."
|
||||
echo "${PASS}" | docker login "${REGISTRY}" -u "${USER}" --password-stdin
|
||||
|
||||
- name: Build Docker Image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "0"
|
||||
run: |
|
||||
IMAGE_TAG="gitea.eibl.tech/me0nline/logar-runner:latest"
|
||||
echo "Building ${IMAGE_TAG}..."
|
||||
docker build \
|
||||
-t "${IMAGE_TAG}" \
|
||||
-f runner/Dockerfile \
|
||||
runner/
|
||||
|
||||
- name: Push to Gitea Container Registry
|
||||
run: |
|
||||
IMAGE_TAG="gitea.eibl.tech/me0nline/logar-runner:latest"
|
||||
echo "Pushing ${IMAGE_TAG} to Gitea Container Registry..."
|
||||
docker push "${IMAGE_TAG}"
|
||||
echo "Successfully pushed ${IMAGE_TAG} to Gitea Container Registry!"
|
||||
|
||||
- name: Trigger Portainer redeploy via webhook
|
||||
run: |
|
||||
echo "Notifying Portainer to redeploy the runner stack..."
|
||||
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."
|
||||
@@ -15,19 +15,21 @@ jobs:
|
||||
test:
|
||||
name: Run Component Tests & Pipeline Verification
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: logar
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
LOGAR_DISABLE_AUTO_UPDATE: "1"
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install System Dependencies & Python
|
||||
- name: Verify Environment & Dependencies
|
||||
run: |
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -y
|
||||
apt-get install -y python3 python3-pip python3-venv curl
|
||||
fi
|
||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
||||
pip3 install -r compilation/requirements.txt --break-system-packages || pip3 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: |
|
||||
@@ -35,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Run Component Unit Tests
|
||||
run: |
|
||||
python3 -m unittest discover -s tests -v
|
||||
PYTHONWARNINGS="ignore" python3 -m unittest discover -s tests -v
|
||||
|
||||
- name: Run End-to-End Pipeline Integration Test
|
||||
run: |
|
||||
@@ -50,10 +52,10 @@ jobs:
|
||||
SERVER_PID=$!
|
||||
echo "[*] Server launched in background with PID $SERVER_PID"
|
||||
|
||||
# 3. Poll Hermes health / report endpoint until server is listening
|
||||
# 3. Poll /health endpoint until server is listening
|
||||
READY=0
|
||||
for i in $(seq 1 20); do
|
||||
if curl -s http://127.0.0.1:8443/api/hermes/report >/dev/null 2>&1; then
|
||||
if python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8443/health', timeout=1)" >/dev/null 2>&1; then
|
||||
echo "[+] LOGAR Server is ready after ${i}s."
|
||||
READY=1
|
||||
break
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
name: Release Binaries & Installers
|
||||
name: Release Binaries & Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish assets to (default: v2.0.1)'
|
||||
description: 'Release tag to publish assets to (default: v0.3.1)'
|
||||
required: false
|
||||
default: 'v2.0.1'
|
||||
default: 'v0.3.1'
|
||||
|
||||
jobs:
|
||||
release-linux:
|
||||
name: Build & Release Linux Binaries
|
||||
runs-on: ubuntu-latest
|
||||
release:
|
||||
name: Build & Release Standalone Binaries (Linux & Windows Cross-Compilation)
|
||||
runs-on: logar
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Python and Build Dependencies
|
||||
- name: Verify Builder Runner Environment & Dependencies
|
||||
run: |
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -y
|
||||
apt-get install -y python3 python3-pip python3-venv binutils zip
|
||||
fi
|
||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
||||
pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -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 Linux Binaries
|
||||
- name: Compile Standalone Binaries (Linux & Windows)
|
||||
run: |
|
||||
python3 compilation/package_dist.py --target linux
|
||||
python3 compilation/package_dist.py --target all
|
||||
|
||||
- name: Publish Linux Release Assets
|
||||
- name: Publish Unified Release Assets
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
@@ -40,85 +44,3 @@ jobs:
|
||||
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
python3 compilation/upload_release.py --skip-build
|
||||
|
||||
release-windows:
|
||||
name: Build & Release Windows Binaries & Installers
|
||||
# Note: Requires a registered Gitea Act Runner with label 'windows-latest'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
shell: powershell
|
||||
run: |
|
||||
$server = "${{ github.server_url }}"
|
||||
$token = "${{ secrets.TAG_TOKEN || github.token }}"
|
||||
$repo = "${{ github.repository }}"
|
||||
$cleanUrl = $server -replace "^https?://", ""
|
||||
$authUrl = "https://${token}@${cleanUrl}/${repo}.git"
|
||||
|
||||
if (-not (Test-Path ".git")) {
|
||||
git init
|
||||
git remote add origin $authUrl
|
||||
} else {
|
||||
git remote set-url origin $authUrl
|
||||
}
|
||||
git fetch --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -f FETCH_HEAD
|
||||
|
||||
- name: Install Dependencies
|
||||
shell: powershell
|
||||
run: |
|
||||
$py = "python"
|
||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||
$py = "py -3.12"
|
||||
}
|
||||
}
|
||||
& $py -m pip install --upgrade pip
|
||||
& $py -m pip install pyinstaller cryptography -r compilation/requirements.txt
|
||||
|
||||
- name: Compile Standalone Windows Binaries
|
||||
shell: powershell
|
||||
run: |
|
||||
$py = "python"
|
||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||
$py = "py -3.12"
|
||||
}
|
||||
}
|
||||
& $py compilation/package_dist.py --target windows
|
||||
|
||||
- name: Compile Inno Setup Installers
|
||||
shell: powershell
|
||||
run: |
|
||||
$iscc = $null
|
||||
if (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") {
|
||||
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
} elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") {
|
||||
$iscc = "C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||
} elseif (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue) {
|
||||
$iscc = "ISCC.exe"
|
||||
}
|
||||
|
||||
if ($iscc) {
|
||||
Write-Host "[*] Compiling Windows Inno Setup installers using $iscc..."
|
||||
& $iscc compilation/installer_client.iss
|
||||
& $iscc compilation/installer_server.iss
|
||||
} else {
|
||||
Write-Host "[!] Inno Setup compiler (ISCC.exe) not found on runner host. Skipping installer compilation."
|
||||
}
|
||||
|
||||
- name: Publish Windows Release Assets
|
||||
shell: powershell
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||
run: |
|
||||
$py = "python"
|
||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||
$py = "py -3.12"
|
||||
}
|
||||
}
|
||||
& $py compilation/upload_release.py --skip-build
|
||||
|
||||
@@ -9,6 +9,7 @@ __pycache__/
|
||||
*$py.class
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
*.spec
|
||||
*.exe
|
||||
!compilation/nssm.exe
|
||||
@@ -16,19 +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/
|
||||
|
||||
@@ -1,405 +1,215 @@
|
||||
# LOGAR: Edge-Thin Log Analysis & Temporal Verification System
|
||||
<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** is an enterprise log aggregation, verification, and anomaly detection architecture designed for heterogeneous server fleets (Windows & Linux). It combines lightweight zero-state edge forwarders with a centralized cloud hub that applies mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI licensing and quota management, temporal persistence tracking across 12-hour evaluation windows, an automated 4-run rule to filter transient warnings, and immediate pass-through for critical errors before reporting verified anomalies to **Hermes**.
|
||||
# LOGAR: Fleet Log Aggregation & Centralized Provisioning System
|
||||
### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis*
|
||||
|
||||
[](RELEASE_NOTES.md)
|
||||
[](tests/)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||
|
||||
**LOGAR** is an enterprise-grade log aggregation, normalization, and centralized provisioning system built for heterogeneous server fleets (Windows & Linux). It pairs ultra-lightweight, zero-state edge forwarders with a secure centralized server hub that enforces mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI fleet licensing with cryptographic seat leasing, 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.
|
||||
|
||||
> 📚 **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)**.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Core Philosophy](#core-philosophy)
|
||||
2. [Architecture & Data Flow](#architecture--data-flow)
|
||||
3. [mTLS Security, Dynamic PKI & Licensing](#mtls-security-dynamic-pki--licensing)
|
||||
4. [Cloud-Side Temporal Persistence & 4-Run Rule](#cloud-side-temporal-persistence--4-run-rule)
|
||||
5. [Agentic Hermes & Client Management API](#agentic-hermes--client-management-api)
|
||||
6. [Dynamic Machine & Domain Identification](#dynamic-machine--domain-identification)
|
||||
7. [Automated Service Installers (Linux & Windows)](#automated-service-installers-linux--windows)
|
||||
8. [Repository & Shippables Structure](#repository--shippables-structure)
|
||||
9. [Getting Started & Deployment](#getting-started--deployment)
|
||||
10. [Running Tests](#running-tests)
|
||||
11. [Automated Releases via Gitea Actions](#automated-releases-via-gitea-actions)
|
||||
## Key Capabilities
|
||||
|
||||
- **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. 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, clean management console for live anomaly telemetry, client seat leasing, modular licensing status, storage engine configuration, and Sub-CA lifecycle management.
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### 1. Edge Thinness & Zero State
|
||||
Site agents running on Windows and Linux act strictly as lightweight forwarders:
|
||||
- **No Local Database**: Clients maintain zero local SQLite or heavy cache files.
|
||||
- **Source-Level Filtering**: Agents stream candidate entries from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), stripping verbose debugging noise (`DEBUG`, trace entries) and skipping events older than 24 hours.
|
||||
- **Transport Security (mTLS 1.3)**: Logs are streamed directly over mutual TLS 1.3 sockets with hardware-bound / machine-unique client certificates.
|
||||
- **Zero Configuration Overhead**: Clients auto-bootstrap certificate enrollment on first run if configured with an enrollment secret.
|
||||
|
||||
### 2. Cloud-Side Temporal Persistence & Severity Routing
|
||||
The central Python hub handles state and verification:
|
||||
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
||||
- Candidate issues are evaluated over a **12-hour temporal evaluation window**.
|
||||
- **Warning Persistence (4-Run Rule)**: `WARNING` issues must persist across **at least 4 consecutive runs / cycles** within the 12-hour window to be confirmed as genuine anomalies, automatically filtering transient infrastructure blips.
|
||||
- **Immediate Error Pass**: Critical errors (`ERROR`, `CRITICAL`, `FATAL`) bypass the 4-run threshold and are promoted immediately to `VERIFIED` on their first occurrence.
|
||||
|
||||
### 3. Agentic Integration with Hermes
|
||||
Instead of engineers manually sifting through logs, **Hermes** ingests pre-filtered anomalies directly from the cloud hub (`GET /api/hermes/report`), treating verified errors and 4-run validated warnings as actionable system artifacts to trigger precise notifications and remediations.
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Data Flow
|
||||
## System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Edge Nodes [Lightweight Edge Forwarders]
|
||||
W[Win_Client.exe / Win_Client.py<br/>Windows Event Log Ingestion]
|
||||
L[Linux_Client.bin / Linux_Client.py<br/>systemd journalctl -p warning]
|
||||
subgraph Authority [Master License Authority - logar.eibl.tech]
|
||||
M_CA[Master Root CA]
|
||||
AUTH_WS[WebSocket Licensing Hub]
|
||||
end
|
||||
|
||||
subgraph Enrollment [Dynamic PKI & Licensing]
|
||||
ENR[POST /api/client/enroll<br/>License Quota & Secret Validation]
|
||||
CA[Internal Root CA<br/>Signs RSA-2048 Client Cert]
|
||||
subgraph ServerHub [LOGAR Server Hub]
|
||||
SUB_CA[Subordinate CA]
|
||||
SEAT_MGR[Dynamic Seat Manager]
|
||||
MTLS_SRV[mTLS 1.3 Ingestion Server<br/>Port 9443]
|
||||
TEMP_ENG[12h Temporal Persistence Engine]
|
||||
PROV_API[Hermes Provisioning & WebUI API<br/>Port 8443]
|
||||
|
||||
SUB_CA --> SEAT_MGR
|
||||
SEAT_MGR --> MTLS_SRV
|
||||
MTLS_SRV --> TEMP_ENG
|
||||
TEMP_ENG --> PROV_API
|
||||
end
|
||||
|
||||
subgraph Transport [mTLS 1.3 Security Layer]
|
||||
MTLS[Mutual TLS 1.3 Handshake<br/>Port 9443 - Client Cert Required]
|
||||
AUTH[Extract Client CN & Fingerprint<br/>Validate Active License in SQLite]
|
||||
subgraph EdgeNodes [Heterogeneous Edge Forwarders]
|
||||
WIN[Win_Client.exe<br/>Windows Event Logs]
|
||||
LIN[Linux_Client.bin<br/>systemd journalctl]
|
||||
end
|
||||
|
||||
subgraph Hub [LOGAR Server Hub]
|
||||
INGEST[Length-Prefixed Frame Ingestion]
|
||||
DB[(SQLite Persistence<br/>active_issues, clients, license_config)]
|
||||
RULE{12h Window &<br/>4-Run Rule}
|
||||
subgraph Downstream [Hermes AI Layer]
|
||||
HERMES[Hermes AI Engine]
|
||||
end
|
||||
|
||||
subgraph Downstream [Hermes Agent & Monitoring]
|
||||
API[FastAPI Reporting & Management<br/>Port 8443]
|
||||
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
||||
end
|
||||
|
||||
W -->|Auto-Enrollment| ENR
|
||||
L -->|Auto-Enrollment| ENR
|
||||
ENR --> CA
|
||||
CA -->|ca.crt, client.crt, client.key| W
|
||||
CA -->|ca.crt, client.crt, client.key| L
|
||||
|
||||
W -->|mTLS Stream| MTLS
|
||||
L -->|mTLS Stream| MTLS
|
||||
MTLS --> AUTH
|
||||
AUTH --> INGEST
|
||||
INGEST --> RULE
|
||||
RULE --> DB
|
||||
DB --> API
|
||||
API --> HERMES
|
||||
M_CA -->|Signs Sub-CA Cert| SUB_CA
|
||||
AUTH_WS <==>|Live WebSocket Sync| SUB_CA
|
||||
|
||||
WIN -->|1. Auto-Enrollment| SUB_CA
|
||||
LIN -->|1. Auto-Enrollment| SUB_CA
|
||||
WIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||
LIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||
PROV_API ==>|3. GET /api/hermes/report| HERMES
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mTLS Security, Dynamic PKI & Fleet Licensing
|
||||
## Quick Start & Automated Installers
|
||||
|
||||
### 1. TLS 1.3 Mutual Authentication (mTLS)
|
||||
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
|
||||
- Both the hub and edge clients verify each other's certificates:
|
||||
- Client verifies server certificate against `ca.crt`.
|
||||
- Server verifies client certificate against the Subordinate/Root CA.
|
||||
- **Client CN Identification & Revocation**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
|
||||
LOGAR provides automated production service installers for both Linux (`systemd`) and Windows (`services.msc` via NSSM):
|
||||
|
||||
### 2. Master License Authority (`logar.eibl.tech`) & Seat-Bearing Subordinate CA
|
||||
- **Master Root Authority**: Standalone PKI repository & submodule ([`LOGAR-LICENSING/`](file:///u:/max/Desktop/LOGAR/LOGAR-LICENSING/README.md), upstream at [`https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git)) deployed at `logar.eibl.tech`.
|
||||
### 1. Server Hub Installation
|
||||
|
||||
- **Custom Enterprise X.509 OIDs**:
|
||||
- `OID_LOGAR_SEATS (1.3.6.1.4.1.59999.1.1)`: Embeds the exact licensed seat quota directly inside the signed Subordinate CA certificate.
|
||||
- `OID_LOGAR_LICENSE_ID (1.3.6.1.4.1.59999.1.2)`: Uniquely tracks enterprise license entitlement.
|
||||
- `OID_LOGAR_ISSUER_URL (1.3.6.1.4.1.59999.1.3)`: Embedded endpoint coordinate (`https://logar.eibl.tech`).
|
||||
- **Exportable `.lic` Request Files**:
|
||||
- The LOGAR server generates its Subordinate CA private key (`certs/ca.key`), Certificate Signing Request (`certs/ca.csr`), and packages them into an exportable `logar_server.lic` file containing the CSR, server fingerprint, and license authority coordinate.
|
||||
- Exportable directly from the WebUI or via CLI (`python src/Server.py --export-lic server.lic`).
|
||||
- **One-Time Pass & Zero-Touch mTLS Renewals**:
|
||||
- The signed CA certificate only needs to be imported **once** into the LOGAR Server (via WebUI or CLI `--import-ca`).
|
||||
- Once imported, the server hub's background watchdog monitors `ca.crt` expiration (< 30 days).
|
||||
- Renewals are performed **automatically over mTLS**: the LOGAR Server authenticates directly against `https://logar.eibl.tech/api/license/renew` using its existing active certificate and key, receives the renewed Subordinate CA certificate preserving its seat quota, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services.
|
||||
|
||||
### 3. Integrated Fleet Licensing & WebUI Dashboard
|
||||
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
|
||||
- **Visual Seat Quota Meter**: Real-time progress bar displaying active forwarders vs. allocated seat limit.
|
||||
- **Licensing Actions**:
|
||||
- 1-click **Export .lic Request** file download.
|
||||
- Interactive **View & Copy CA CSR** modal.
|
||||
- Drag-and-drop or paste **Import Signed CA Certificate** modal.
|
||||
- **Renew via mTLS Now** button for immediate upstream renewal.
|
||||
- **Seat Management & Revocation Table**:
|
||||
- Lists all connected Windows and Linux edge forwarders.
|
||||
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
|
||||
- **Reactivate Seat**: Restores the client to active status if within seat quota limits.
|
||||
|
||||
### 4. Cloud-Side In-Flight Certificate Watchdog
|
||||
- **Continuous Hub PKI Watchdog**:
|
||||
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours).
|
||||
- Automatically renews Subordinate CA certificates over mTLS with `logar.eibl.tech` and Server TLS certificates in-flight, dynamically reloading the active `ssl.SSLContext`.
|
||||
- **Client Proactive Check & Reactive Self-Healing**:
|
||||
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If expiring within 30 days, they contact `/api/client/enroll` to renew.
|
||||
- **Reactive Self-Healing**: If the server CA rotates or a handshake error occurs, edge clients catch the exception, re-bootstrap enrollment against the hub, and re-establish streaming cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Cloud-Side Temporal Persistence & 4-Run Rule
|
||||
|
||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
||||
- **12-Hour Evaluation Window**:
|
||||
- The hub compares `(now - last_seen)`.
|
||||
- If more than 12 hours have elapsed since the issue was last recorded, the previous window expires and the cycle resets to `run_count = 1`.
|
||||
- **4-Run Rule for Warnings**:
|
||||
- The 4-run persistence threshold applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
||||
- Each distinct run batch increments `run_count`.
|
||||
- Warnings with `run_count < 4` are marked as `TRANSIENT` and excluded from Hermes reports.
|
||||
- When `run_count >= 4` within the active 12-hour window, the warning transitions to `VERIFIED`.
|
||||
- **Immediate Verification for Errors**:
|
||||
- High-severity events (`ERROR`, `CRITICAL`, `FATAL`) **always pass immediately**.
|
||||
- On their very first ingestion (`run_count = 1`), errors are promoted directly to `VERIFIED` and surfaced to Hermes without waiting for 4 runs.
|
||||
|
||||
---
|
||||
|
||||
## Agentic Hermes & Client Management API
|
||||
|
||||
The server hub exposes a management and reporting REST API (default port `8443`):
|
||||
|
||||
### `POST /api/client/enroll`
|
||||
Client enrollment endpoint:
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"client_id": "web-worker-01.corp.internal",
|
||||
"hostname": "web-worker-01",
|
||||
"os": "linux",
|
||||
"enrollment_secret": "<SECRET>"
|
||||
}
|
||||
```
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"ca_cert": "-----BEGIN CERTIFICATE-----\n...",
|
||||
"client_cert": "-----BEGIN CERTIFICATE-----\n...",
|
||||
"client_key": "-----BEGIN RSA PRIVATE KEY-----\n..."
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/clients`
|
||||
Returns seat quota status and registered client telemetry:
|
||||
```json
|
||||
{
|
||||
"active_seats": 2,
|
||||
"max_seats": 10,
|
||||
"clients": [
|
||||
{
|
||||
"client_id": "web-worker-01.corp.internal",
|
||||
"hostname": "web-worker-01",
|
||||
"os_type": "linux",
|
||||
"cert_fingerprint": "7D5B660B...",
|
||||
"status": "active",
|
||||
"first_seen": "2026-09-04 18:00:00",
|
||||
"last_seen": "2026-09-04 19:15:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/hermes/report`
|
||||
Returns all **verified anomalies** (immediate critical errors and warnings verified after 4 consecutive runs within the 12-hour window):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"fingerprint": "corp.internal:web-worker-01.corp.internal:PostgresPoolExhausted",
|
||||
"site": "corp.internal",
|
||||
"server": "web-worker-01.corp.internal",
|
||||
"signature": "PostgresPoolExhausted",
|
||||
"severity": "WARNING",
|
||||
"message": "Connection pool saturated (>95%) across 4 runs",
|
||||
"os_type": "linux",
|
||||
"first_seen": "2026-09-04T07:00:00+00:00",
|
||||
"last_seen": "2026-09-04T19:00:00+00:00",
|
||||
"consecutive_runs": 4,
|
||||
"evaluation_window": "12h",
|
||||
"verified": true,
|
||||
"status": "VERIFIED"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `GET /api/hermes/all`
|
||||
Diagnostic endpoint listing all candidate issues (`TRANSIENT` and `VERIFIED`).
|
||||
|
||||
### `GET /health`
|
||||
Returns hub health, encryption fingerprint, listener ports, and mTLS status.
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Machine & Domain Identification
|
||||
|
||||
Client configurations intentionally contain **no hardcoded machine name or site name**. Both forwarders dynamically identify their host and domain at runtime via `get_machine_identifier()`:
|
||||
1. **Fully Qualified Domain Name (FQDN)**: Checked via `socket.getfqdn()`.
|
||||
2. **OS-Specific Domain Discovery**:
|
||||
- **Windows**: Checks Active Directory environment variables (`USERDNSDOMAIN`, `USERDOMAIN`).
|
||||
- **Linux**: Parses `/etc/resolv.conf` `domain` and `search` directives.
|
||||
3. **Reverse DNS Lookup**: Resolves canonical hostname via `socket.gethostbyaddr`.
|
||||
4. **Fallback**: Local hostname `socket.gethostname()`.
|
||||
|
||||
The server automatically infers site attribution from domain qualifiers (e.g. `node01.corp.internal` $\rightarrow$ site `corp.internal`).
|
||||
|
||||
---
|
||||
|
||||
## Automated Service Installers (Linux & Windows)
|
||||
|
||||
LOGAR provides production-grade installation scripts and installer builders for automated service deployment:
|
||||
|
||||
### 1. Linux Service Installers
|
||||
- **Client Installer (`compilation/install_linux_client.sh`)**:
|
||||
- Non-interactive script deploying to `/opt/logar-client`.
|
||||
- Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||
- Installs and enables `logar-client.service` systemd unit.
|
||||
```bash
|
||||
sudo ./compilation/install_linux_client.sh "http://hub.example.com:8443" "<ENROLLMENT_SECRET>"
|
||||
```
|
||||
- **Server Installer (`compilation/install_linux_server.sh`)**:
|
||||
- Deploys server to `/opt/logar-server`.
|
||||
- Configures logging and installs `logar-server.service` with `LimitNOFILE=65536`.
|
||||
- **Linux (Automated systemd Installer)**:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_server.sh
|
||||
```
|
||||
*Deploys standalone `Server.bin` to `/opt/logar-server`, initializes PKI, and enables `logar-server.service`.*
|
||||
|
||||
### 2. Windows Inno Setup Installers
|
||||
- Built using **Inno Setup 6** and bundled with **NSSM** (`compilation/nssm.exe`):
|
||||
- **Client Setup (`compilation/installer_client.iss`)**: Compiles `LOGAR-Client-Setup.exe`. Installs `Win_Client.exe` into `{autopf}\LOGAR`, sets up `LOGAR_Client` service via NSSM with stdout/stderr redirection to `{commonappdata}\LOGAR\client.log`, and starts the service. Clean uninstallation stops and removes the service.
|
||||
- **Server Setup (`compilation/installer_server.iss`)**: Compiles `LOGAR-Server-Setup.exe`. Installs `Server.exe` and sets up `LOGAR_Server` Windows service via NSSM.
|
||||
- **Windows (Inno Setup / NSIS Installer)**:
|
||||
Download and execute `LOGAR-Server-Setup.exe` from [Releases](https://gitea.eibl.tech/me0nline/LOGAR/releases). Installs `Server.exe` into `C:\Program Files\LOGAR-Server` and registers `LOGAR_Server` Windows service.
|
||||
|
||||
#### Default WebUI Management Credentials
|
||||
Once installed or executed, access the web console:
|
||||
- **URL**: `http://<server-ip>:8443/webui`
|
||||
- **Default Username**: `admin`
|
||||
- **Default Password**: `admin_logar_2026!` *(Configurable via `LOGAR_ADMIN_USER` / `LOGAR_ADMIN_PASS` or in the WebUI user settings)*
|
||||
|
||||
### 2. Edge Forwarder Installation
|
||||
|
||||
- **Linux Client (1-Line Non-Interactive Installer)**:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||
```
|
||||
*Auto-enrolls mTLS certificates to `/etc/logar/certs` (`chmod 700`), generates configuration, and activates `logar-client.service`.*
|
||||
|
||||
- **Windows Client (Inno Setup Installer)**:
|
||||
1. Download and run `LOGAR-Client-Setup.exe` (supports silent install `/VERYSILENT`).
|
||||
2. Place `client_config.json` (exported from Server WebUI) into `C:\Program Files\LOGAR\client_config.json`.
|
||||
3. The `LOGAR_Client` service starts and auto-enrolls immediately.
|
||||
|
||||
*(Alternatively, standalone binaries `./Server.bin` / `./Linux_Client.bin` / `.\Server.exe` / `.\Win_Client.exe` can be executed directly without service installation).*
|
||||
|
||||
---
|
||||
|
||||
## Repository & Shippables Structure
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
LOGAR/
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── ci.yml # CI pipeline: syntax, 25 unit tests & mTLS pipeline test
|
||||
│ └── release.yml # Consolidated release workflow (Linux binaries & Windows installers)
|
||||
├── compilation/ # Packaging, installers, and release automation
|
||||
│ ├── install_linux_client.sh # Automated Linux client systemd installation script
|
||||
│ ├── install_linux_server.sh # Automated Linux server systemd installation script
|
||||
│ ├── installer_client.iss # Inno Setup Windows Client installer script
|
||||
│ ├── installer_server.iss # Inno Setup Windows Server installer script
|
||||
│ ├── nssm.exe # Official 64-bit NSSM service manager binary
|
||||
│ ├── package_dist.py # Standalone binary compiler & packager
|
||||
│ ├── requirements.txt # Unified project dependencies
|
||||
│ └── upload_release.py # Gitea REST API release asset publisher
|
||||
├── src/ # Core application source modules
|
||||
│ ├── __init__.py
|
||||
│ ├── Server.py # Central mTLS server, temporal engine, and Hermes REST API
|
||||
│ ├── server_enrollment.py # Dynamic PKI, Root CA, and client certificate generator
|
||||
│ ├── Win_Client.py # Windows edge forwarder with auto-enrollment
|
||||
│ └── Linux_Client.py # Linux edge forwarder with auto-enrollment
|
||||
├── tests/ # Automated test suites
|
||||
│ ├── test_linux_client.py # Linux client unit tests & mTLS certificate validation
|
||||
│ ├── test_pipeline.py # End-to-end mTLS integration & 4-run verification test
|
||||
│ ├── test_server.py # Server unit tests, PKI generation, and seat quota tests
|
||||
│ └── test_win_client.py # Windows client unit tests & mTLS certificate validation
|
||||
├── .gitignore # Ignores venv, caches, DBs, and private keys
|
||||
├── README.md # Architecture and usage documentation
|
||||
├── RELEASE_NOTES.md # Release history and changelog
|
||||
├── server_config.sample.json # Reference server configuration
|
||||
└── out/ # Component guides and sample configs
|
||||
├── linux_server/
|
||||
│ ├── README.md
|
||||
│ └── server_config.sample.json
|
||||
├── win_server/
|
||||
│ ├── README.md
|
||||
│ └── server_config.sample.json
|
||||
├── linux_client/
|
||||
│ ├── README.md
|
||||
│ └── client_config.sample.json
|
||||
└── win_client/
|
||||
├── README.md
|
||||
└── client_config.sample.json
|
||||
├── 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
|
||||
│ └── upload_release.py # Gitea release asset publisher
|
||||
├── src/ # Application source code
|
||||
│ ├── Server.py # Central mTLS hub, temporal engine & REST API
|
||||
│ ├── 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 (42 unit & integration tests)
|
||||
├── LOGAR-LICENSING/ # Standalone Master License Authority (Git submodule)
|
||||
├── RELEASE_NOTES.md # Version history and security changelog
|
||||
└── README.md # Repository overview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started & Deployment
|
||||
## Ports & REST API Summary
|
||||
|
||||
### 1. Central Server Hub
|
||||
| Port | Protocol | Service | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`9443`** | `TCP / TLS 1.3` | **mTLS Log Ingestion** | Mutual TLS socket listener for edge forwarders |
|
||||
| **`8443`** | `HTTP / REST` | **Management & Hermes API** | WebUI, forwarder enrollment, seat management, and AI reports |
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
pip install -r compilation/requirements.txt
|
||||
```
|
||||
2. **Start the server** (generates `server_config.json`, Root CA, and server certs on first run):
|
||||
```bash
|
||||
python src/Server.py
|
||||
```
|
||||
3. **Export a client configuration**:
|
||||
```bash
|
||||
python src/Server.py --create-client-config --server-host <SERVER_IP> --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
### Core Endpoints
|
||||
|
||||
### 2. Windows Client Deployment
|
||||
1. Download `LOGAR-Client-Setup.exe` from releases and run it, or place `Win_Client.exe` and `client_config.json` in `C:\Program Files\LOGAR`.
|
||||
2. On first run with `client_config.json`, `Win_Client.exe` automatically enrolls with the hub, receives its mTLS certificates, and establishes secure streaming.
|
||||
|
||||
### 3. Linux Client Deployment
|
||||
1. Run the automated installer:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||
```
|
||||
2. The installer enrolls the client, configures `/etc/logar/certs`, and activates `logar-client.service`.
|
||||
- **`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.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
## 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
|
||||
|
||||
### 1. Component Unit Tests
|
||||
```bash
|
||||
# Run full unit test suite (51 tests)
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
Runs all **25 unit tests**, covering:
|
||||
- Dynamic Root CA generation and server TLS certificate issuance.
|
||||
- Dynamic client certificate issuance with CN and authority key extensions.
|
||||
- Enrollment secret authentication, seat limits, and certificate revocation.
|
||||
- Proactive certificate validity checks, Root CA auto-renewal, and in-flight server SSLContext reload.
|
||||
- Windows & Linux event log collection, deduplication, and mTLS certificate verification.
|
||||
- 12-hour evaluation window and 4-run rule progression.
|
||||
|
||||
### 2. End-to-End Pipeline Integration Test
|
||||
```bash
|
||||
# 1. Initialize test configuration
|
||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
||||
|
||||
# 2. Launch server in background
|
||||
python src/Server.py &
|
||||
|
||||
# 3. Run integration test
|
||||
# Run end-to-end mTLS pipeline integration test
|
||||
python tests/test_pipeline.py
|
||||
|
||||
# Cross-compile native Linux and Windows binaries on Linux runner
|
||||
python compilation/package_dist.py --target all
|
||||
```
|
||||
Tests client enrollment, secret rejection, mTLS TLS 1.3 socket handshake, warning 4-run rule promotion, immediate error promotion, and Hermes report output.
|
||||
|
||||
---
|
||||
|
||||
## Releases & Binary Distribution
|
||||
## Documentation & Deep Dives
|
||||
|
||||
Releases can be built and published through three complementary channels:
|
||||
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||
|
||||
### 1. Tag Push Automation (Gitea Actions)
|
||||
Pushing a release tag automatically triggers the build workflows:
|
||||
```bash
|
||||
git tag v2.0.1
|
||||
git push origin v2.0.1
|
||||
```
|
||||
The consolidated workflow **`release.yml`** defines two parallel jobs:
|
||||
- **`release-linux`** (`ubuntu-latest`): Compiles standalone native ELF binaries (`Linux_Client.bin`, `Server.bin`) and checksums.
|
||||
- **`release-windows`** (`windows-latest`): Compiles Windows executables (`Win_Client.exe`, `Server.exe`), builds Inno Setup installers, and publishes checksums (requires self-hosted Windows Act Runner).
|
||||
|
||||
### 2. Manual Workflow Dispatch (Gitea UI)
|
||||
Workflows can be manually triggered on demand from the Gitea web interface:
|
||||
1. Navigate to **Actions** $\rightarrow$ **Release Binaries & Installers** (`release.yml`).
|
||||
2. Click **Run workflow**, set the release tag (defaults to `v2.0.1`), and run.
|
||||
|
||||
### 3. Native Local Windows Build & Direct Release Upload
|
||||
For environments without a registered Windows CI runner, Windows executables can be built and published directly to Gitea releases:
|
||||
```powershell
|
||||
# 1. Build Windows binaries locally
|
||||
python compilation/package_dist.py --target windows
|
||||
|
||||
# 2. Upload assets and release notes directly to the Gitea release
|
||||
python compilation/upload_release.py --tag v2.0.1 --token <GITEA_TOKEN> --skip-build
|
||||
```
|
||||
* [WebUI Administrator Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||
* [System State Machines & Subsystem Lifecycles](https://gitea.eibl.tech/me0nline/LOGAR/wiki/State-Machines-and-Lifecycles)
|
||||
* [PKI Architecture & Custom X.509 Seat OIDs](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
* [Complete REST API Specification](https://gitea.eibl.tech/me0nline/LOGAR/wiki/API-Reference)
|
||||
* [Production Deployment & Service Management](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Deployment-and-Installation)
|
||||
* [CI/CD Pipeline & Cross-Compilation Workflow](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Testing-and-Releases)
|
||||
* [Branding & Design System Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Branding-Guide)
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
# LOGAR Release v2.0.1
|
||||
# LOGAR Release Notes
|
||||
|
||||
Maintenance and deployment release consolidating Gitea Actions release automation into a unified single workflow file, standardizing release dispatching across platforms, and bumping version definitions across server hub and Windows installers.
|
||||
## [v0.3.5.2] - 2026-10-03
|
||||
|
||||
### Key Highlights & Changes in v2.0.1:
|
||||
### 🚀 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.
|
||||
|
||||
- **Consolidated Single Release Automation Workflow (`.gitea/workflows/release.yml`)**:
|
||||
- Unified separate platform release files into a single, cohesive workflow (`release.yml`) running parallel jobs (`release-linux` on `ubuntu-latest` and `release-windows` on `windows-latest`).
|
||||
- Standardized tag matching for Gitea Actions on `push: tags: ['v*']`.
|
||||
- Added streamlined manual `workflow_dispatch` triggers with automated release tag defaulting (`v2.0.1`).
|
||||
- Retired deprecated `release-linux.yml` and `release-windows.yml` files.
|
||||
### 🛡️ 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.
|
||||
|
||||
- **Installer & Engine Version Bump**:
|
||||
- Updated FastAPI Hub engine version to `2.0.1` in `src/Server.py`.
|
||||
- Bumped Inno Setup Windows Client Installer (`compilation/installer_client.iss`) `AppVersion` to `2.0.1`.
|
||||
- Bumped Inno Setup Windows Server Installer (`compilation/installer_server.iss`) `AppVersion` to `2.0.1`.
|
||||
### 📊 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.
|
||||
|
||||
- **Distribution & Release Documentation**:
|
||||
- Updated root and distribution documentation across all 5 deployment targets to reflect the 25 passing unit tests and tripartite release options.
|
||||
### 🔑 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.
|
||||
|
||||
---
|
||||
|
||||
# LOGAR Release v2.0.0
|
||||
## [v0.3.5.1] - 2026-09-30
|
||||
|
||||
Major architectural release introducing Mutual TLS 1.3 (mTLS) transport security, built-in dynamic PKI & license accounting, in-flight certificate validity monitoring and auto-renewal, and automated Windows and Linux service installers.
|
||||
### 🗄️ 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.
|
||||
|
||||
### Key Highlights & Changes in v2.0.0:
|
||||
### ⚡ 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.
|
||||
|
||||
- **mTLS 1.3 Transport Security & Runtime Licensing**:
|
||||
- Replaced legacy plain TCP sockets with mutual TLS 1.3 authentication (`ssl.CERT_REQUIRED`, TLS 1.3 minimum version).
|
||||
- Hub dynamically validates incoming client Common Name (`client_id`) against active license seats in SQLite during the TLS handshake.
|
||||
- Drops unauthorized, un-enrolled, or revoked clients at the transport layer before payload reading.
|
||||
### 🔐 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 Hub PKI Engine (`src/server_enrollment.py`)**:
|
||||
- Automatically initializes an internal RSA-4096 Root CA (`ca.crt` / `ca.key`).
|
||||
- Generates RSA-2048 Server TLS certificates with SANs for localhost, loopback, and server hostnames.
|
||||
- Full OpenSSL 3.x and Python 3.12–3.14 compatibility via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` certificate extensions.
|
||||
- Generates and signs client certificates on demand via `POST /api/client/enroll`.
|
||||
### 📜 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.
|
||||
|
||||
- **In-Flight Certificate Validity Watchdog & Dynamic SSLContext Reloading**:
|
||||
- Server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, evaluated every 12 hours) alongside startup checks.
|
||||
- Automatically checks Root CA and server TLS certificate expiration against a 30-day threshold.
|
||||
- Generates renewed certificates on disk with timestamped backups (`.bak`), and reloads active `ssl.SSLContext` in memory dynamically without dropping socket listeners or restarting the background service.
|
||||
|
||||
- **Client Proactive Expiry Check & Reactive Self-Healing Auto-Renewal**:
|
||||
- **Proactive**: Forwarders (`Win_Client.py` and `Linux_Client.py`) evaluate `client.crt` validity before each run, auto-renewing via `/api/client/enroll` if expiring within 30 days.
|
||||
- **Reactive**: If the hub rotates its Root CA or a TLS verification error (`ssl.SSLError` / `SSLCertVerificationError`) occurs, clients automatically catch the error, re-enroll with the hub using their enrollment secret, and reconnect cleanly.
|
||||
|
||||
- **Database Schema & License Quota Accounting**:
|
||||
- SQLite tables `license_config` (`max_seats`, `enrollment_secret`) and `clients` (`client_id`, `hostname`, `os_type`, `cert_fingerprint`, `status`, timestamps).
|
||||
- Enforces seat limits on enrollment (`HTTP 403 License seat limit reached`) while allowing active registered nodes to re-enroll/renew indefinitely.
|
||||
- Added `GET /api/clients` endpoint for license auditing and telemetry tracking.
|
||||
|
||||
- **Automated Service Installers**:
|
||||
- **Windows**: Self-contained Inno Setup installers (`LOGAR-Client-Setup.exe` and `LOGAR-Server-Setup.exe`) bundling `nssm.exe` to register, configure, and start Windows services automatically.
|
||||
- **Linux**: Automated installer scripts (`compilation/install_linux_client.sh` and `install_linux_server.sh`) deploying systemd service units with auto-restart policies.
|
||||
|
||||
- **CI/CD Release Workflows**:
|
||||
- Windows workflow (`.gitea/workflows/release-windows.yml`) and Linux workflow (`.gitea/workflows/release-linux.yml`) automated to build native executables, installers, and upload release assets on tag push.
|
||||
### 🛠️ Dynamic Installer Context Versioning & Streamlined CI/CD
|
||||
- **Context-Aware Installer Builds**: NSIS and Inno Setup compiler scripts dynamically derive version information directly from build context/tags (`v0.3.5.1`) without hardcoded version strings.
|
||||
- **Pre-Integrated Builder Runner**: Streamlined CI/CD workflows to rely directly on pre-installed container tools and libraries without runtime `apt`/`pip` overhead.
|
||||
|
||||
### 📖 WebUI Administrator Guide & 51 Automated Tests
|
||||
- **WebUI Administrator Guide**: Published comprehensive WebUI management guide on the [Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide) covering console access, telemetry filtering, seat management, licensing, database switching, queue tuning, zero-knowledge crypto, Hermes AI, and 2FA.
|
||||
- **Expanded Test Suite**: 51 automated unit and integration tests passing with 100% test coverage.
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="100%" stop-color="#070C18"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#00FFA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="bannerGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Card -->
|
||||
<rect width="960" height="240" rx="16" fill="url(#bannerBg)" stroke="#162A45" stroke-width="2"/>
|
||||
|
||||
<!-- Left Emblem Group (scaled & positioned) -->
|
||||
<g transform="translate(40, 20) scale(0.39)">
|
||||
<!-- Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="#0E1A2E" stroke="url(#cyanGrad)" stroke-width="12" filter="url(#bannerGlow)"/>
|
||||
<!-- Inner Telemetry Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="4" stroke-dasharray="10 8"/>
|
||||
<!-- Stylized L & Telemetry Bars -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
<!-- Connection Points -->
|
||||
<circle cx="238" cy="190" r="9" fill="#00FFA3"/>
|
||||
<circle cx="304" cy="130" r="9" fill="#00E5FF"/>
|
||||
<circle cx="380" cy="349" r="10" fill="#00E5FF"/>
|
||||
</g>
|
||||
|
||||
<!-- Typography & Tagline -->
|
||||
<text x="270" y="98" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="4" fill="url(#textCyan)" filter="url(#bannerGlow)">LOGAR</text>
|
||||
<text x="272" y="142" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="20" font-weight="600" fill="#E2E8F0" letter-spacing="0.5">Fleet Log Aggregation & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0072FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00E5FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00FFA3" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#070C18" stroke="#1e293b" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0E1A2E" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#94A3B8" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#EF4444" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#F59E0B" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#FFFFFF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 650 B |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="100%" stop-color="#070C18"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="textCyan" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#00FFA3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="bannerGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Card -->
|
||||
<rect width="960" height="240" rx="16" fill="url(#bannerBg)" stroke="#162A45" stroke-width="2"/>
|
||||
|
||||
<!-- Left Emblem Group (scaled & positioned) -->
|
||||
<g transform="translate(40, 20) scale(0.39)">
|
||||
<!-- Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="#0E1A2E" stroke="url(#cyanGrad)" stroke-width="12" filter="url(#bannerGlow)"/>
|
||||
<!-- Inner Telemetry Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="4" stroke-dasharray="10 8"/>
|
||||
<!-- Stylized L & Telemetry Bars -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
<!-- Connection Points -->
|
||||
<circle cx="238" cy="190" r="9" fill="#00FFA3"/>
|
||||
<circle cx="304" cy="130" r="9" fill="#00E5FF"/>
|
||||
<circle cx="380" cy="349" r="10" fill="#00E5FF"/>
|
||||
</g>
|
||||
|
||||
<!-- Typography & Tagline -->
|
||||
<text x="270" y="98" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="56" font-weight="900" letter-spacing="4" fill="url(#textCyan)" filter="url(#bannerGlow)">LOGAR</text>
|
||||
<text x="272" y="142" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" font-size="20" font-weight="600" fill="#E2E8F0" letter-spacing="0.5">Fleet Log Aggregation & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Base Hexagon Shield -->
|
||||
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
|
||||
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0E1A2E"/>
|
||||
<stop offset="50%" stop-color="#08101E"/>
|
||||
<stop offset="100%" stop-color="#040810"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5FF"/>
|
||||
<stop offset="100%" stop-color="#0072FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00FFA3"/>
|
||||
<stop offset="100%" stop-color="#00E5FF"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
|
||||
|
||||
<!-- Inner Telemetry Grid / Ring -->
|
||||
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
|
||||
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
|
||||
|
||||
<!-- Stylized L and Log Streams -->
|
||||
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
|
||||
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
|
||||
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
|
||||
|
||||
<!-- Connection Nodes / Spark Points -->
|
||||
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
|
||||
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
|
||||
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0072FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00E5FF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#00FFA3" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#070C18" stroke="#1e293b" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#0E1A2E" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#94A3B8" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#EF4444" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#F59E0B" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="#FFFFFF" stroke="#334155" stroke-width="1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,41 @@
|
||||
import sys
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
def main():
|
||||
try:
|
||||
import pip # noqa: F401
|
||||
print("[+] pip is already installed.")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import ensurepip
|
||||
ensurepip.bootstrap()
|
||||
print("[+] Successfully bootstrapped pip via ensurepip.")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"[*] ensurepip bootstrap skipped ({e}), downloading get-pip.py...")
|
||||
|
||||
ver = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
url = f"https://bootstrap.pypa.io/pip/{ver}/get-pip.py" if sys.version_info < (3, 10) else "https://bootstrap.pypa.io/get-pip.py"
|
||||
dest = "/tmp/get-pip.py" if sys.platform != "win32" else "get-pip.py"
|
||||
|
||||
print(f"[*] Downloading get-pip.py from {url} for Python {ver}...")
|
||||
try:
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
cmd = [sys.executable, dest]
|
||||
if sys.version_info >= (3, 11):
|
||||
cmd.append("--break-system-packages")
|
||||
res = subprocess.run(cmd)
|
||||
if res.returncode == 0:
|
||||
print("[+] Successfully installed pip via get-pip.py")
|
||||
else:
|
||||
# Retry without break-system-packages in case old pip version rejected it
|
||||
subprocess.run([sys.executable, dest])
|
||||
except Exception as ex:
|
||||
print(f"[!] Warning during pip bootstrap: {ex}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 & Telemetry Hub</text>
|
||||
<text x="272" y="174" font-family="'JetBrains Mono', 'Fira Code', monospace" font-size="13" font-weight="400" fill="#00E5FF" letter-spacing="1">ZERO-STATE FORWARDERS • MUTUAL TLS 1.3 • TEMPORAL PERSISTENCE</text>
|
||||
</svg>"""
|
||||
|
||||
|
||||
def main():
|
||||
print("[*] Generating comprehensive branding assets for LOGAR...")
|
||||
|
||||
# 1. Generate SVG Logos
|
||||
svg_logo_dark = generate_svg_logo(transparent_bg=False)
|
||||
svg_logo_trans = generate_svg_logo(transparent_bg=True)
|
||||
svg_banner = generate_svg_banner()
|
||||
|
||||
# 2. Generate PNG images across multiple dimensions
|
||||
png_sizes = [16, 32, 48, 64, 128, 256, 512]
|
||||
png_buffers = []
|
||||
png_dict = {}
|
||||
for s in png_sizes:
|
||||
png_data = create_png_rgba(s, s, draw_logo_rgba)
|
||||
png_buffers.append((s, s, png_data))
|
||||
png_dict[s] = png_data
|
||||
|
||||
# 3. Generate Windows ICO File (multi-res 16..256)
|
||||
ico_data = generate_ico_file(png_buffers[:-1])
|
||||
|
||||
# 4. Generate BMP files for installers
|
||||
header_bmp_data = create_bmp_24bit(150, 57, draw_wizard_header_white_bgr)
|
||||
small_bmp_data = create_bmp_24bit(55, 55, draw_wizard_small_bgr)
|
||||
sidebar_bmp_data = create_bmp_24bit(164, 314, draw_wizard_sidebar_bgr)
|
||||
|
||||
# 5. Generate Palette Swatch SVGs
|
||||
swatches = {
|
||||
"00e5ff": "#00E5FF",
|
||||
"00ffa3": "#00FFA3",
|
||||
"0072ff": "#0072FF",
|
||||
"0e1a2e": "#0E1A2E",
|
||||
"070c18": "#070C18",
|
||||
"ef4444": "#EF4444",
|
||||
"f59e0b": "#F59E0B",
|
||||
"94a3b8": "#94A3B8",
|
||||
"ffffff": "#FFFFFF",
|
||||
}
|
||||
swatch_svgs = {}
|
||||
for name, hex_code in swatches.items():
|
||||
border = "#334155" if hex_code.upper() != "#070C18" else "#1e293b"
|
||||
swatch_svgs[name] = f'<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="16" height="16" rx="4" fill="{hex_code}" stroke="{border}" stroke-width="1.5"/></svg>'
|
||||
|
||||
# 6. Write to target directories
|
||||
for target_dir in 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()
|
||||
@@ -7,19 +7,70 @@ ENROLL_SECRET="${2:-}"
|
||||
INSTALL_DIR="/opt/logar-client"
|
||||
CONFIG_DIR="/etc/logar"
|
||||
|
||||
echo "[+] Installing LOGAR Client..."
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||
|
||||
if [ -f "dist/Linux_Client.bin" ]; then
|
||||
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
||||
elif [ -f "dist/Linux_Client" ]; then
|
||||
cp dist/Linux_Client "${INSTALL_DIR}/Linux_Client"
|
||||
else
|
||||
echo "[!] Warning: dist/Linux_Client binary not found in current directory. Continuing with existing binary if present."
|
||||
# 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
|
||||
|
||||
if [ -f "${INSTALL_DIR}/Linux_Client" ]; then
|
||||
# 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"
|
||||
|
||||
BIN_INSTALLED=0
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
|
||||
if [ -f "${SCRIPT_DIR}/Linux_Client.bin" ]; then
|
||||
cp "${SCRIPT_DIR}/Linux_Client.bin" "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
elif [ -f "${SCRIPT_DIR}/Linux_Client" ]; then
|
||||
cp "${SCRIPT_DIR}/Linux_Client" "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
elif [ -f "dist/Linux_Client.bin" ]; then
|
||||
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
elif [ -f "dist/Linux_Client" ]; then
|
||||
cp dist/Linux_Client "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
elif [ -f "Linux_Client.bin" ]; then
|
||||
cp Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
elif [ -f "Linux_Client" ]; then
|
||||
cp Linux_Client "${INSTALL_DIR}/Linux_Client"
|
||||
BIN_INSTALLED=1
|
||||
fi
|
||||
|
||||
# If binary is not available locally, fetch standalone binary from LOGAR Hub
|
||||
if [ "${BIN_INSTALLED}" -eq 0 ] && [ ! -f "${INSTALL_DIR}/Linux_Client" ]; then
|
||||
echo "[*] Client binary not found locally. Fetching standalone Linux client binary from LOGAR Hub (${HUB_URL}/api/client/update/download?os=linux)..."
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "${HUB_URL}/api/client/update/download?os=linux" -o "${INSTALL_DIR}/Linux_Client" || true
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "${INSTALL_DIR}/Linux_Client" "${HUB_URL}/api/client/update/download?os=linux" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "${INSTALL_DIR}/Linux_Client" ] && [ -s "${INSTALL_DIR}/Linux_Client" ]; then
|
||||
chmod +x "${INSTALL_DIR}/Linux_Client"
|
||||
echo "[+] Standalone Linux Client executable installed at ${INSTALL_DIR}/Linux_Client"
|
||||
else
|
||||
echo "[-] Error: Failed to acquire LOGAR Linux client executable binary." >&2
|
||||
echo "[-] Please provide Linux_Client.bin in the installer directory or verify HUB_URL connectivity." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bootstrap certificate if missing and enrollment secret is provided
|
||||
|
||||
@@ -4,7 +4,51 @@ set -euo pipefail
|
||||
INSTALL_DIR="/opt/logar-server"
|
||||
CONFIG_DIR="/etc/logar"
|
||||
|
||||
echo "[+] Installing LOGAR Server..."
|
||||
# Parse command line options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--config-dir|--data-dir)
|
||||
CONFIG_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "============================================================"
|
||||
echo " LOGAR Server Enterprise Linux Installer"
|
||||
echo "============================================================"
|
||||
|
||||
# 1. Mutual Exclusion Check: Deny installation if LOGAR Client is present
|
||||
if [ -f "/opt/logar-client/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||
echo "[-] Error: LOGAR Client is already installed on this machine." >&2
|
||||
echo "[-] LOGAR Server cannot be installed on a host where LOGAR Client is present (LOGAR Server includes built-in log collection)." >&2
|
||||
echo "[-] Please uninstall LOGAR Client first before installing LOGAR Server." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Existing LOGAR Server Detection for In-Place Update Mode
|
||||
IS_UPDATE=0
|
||||
if [ -f "${INSTALL_DIR}/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||
IS_UPDATE=1
|
||||
echo "[*] Existing LOGAR Server installation detected. Performing in-place upgrade..."
|
||||
fi
|
||||
|
||||
# Interactive configuration if running in a terminal without CLI arguments (only for fresh install)
|
||||
if [ "${IS_UPDATE}" -eq 0 ] && [ -t 0 ]; then
|
||||
read -r -p "Enter Data and Configuration Directory [${CONFIG_DIR}]: " USER_CFG
|
||||
if [ -n "${USER_CFG}" ]; then
|
||||
CONFIG_DIR="${USER_CFG}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[*] Stopping LOGAR Server service for upgrade..."
|
||||
systemctl stop logar-server.service || true
|
||||
fi
|
||||
|
||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" "/var/log/logar"
|
||||
|
||||
if [ -f "dist/Server.bin" ]; then
|
||||
|
||||
@@ -1,27 +1,262 @@
|
||||
#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=2.0.1
|
||||
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
|
||||
SetupIconFile=assets\logo.ico
|
||||
WizardImageFile=assets\wizard_sidebar.bmp
|
||||
WizardSmallImageFile=assets\wizard_small.bmp
|
||||
VersionInfoCompany=LOGAR
|
||||
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||
VersionInfoVersion={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Client
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Dirs]
|
||||
Name: "{commonappdata}\LOGAR"; Permissions: users-modify
|
||||
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client.exe"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{commonappdata}\LOGAR\client.log"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{commonappdata}\LOGAR\client_err.log"""; 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"""" --interval 30"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Client"; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Client confirm"; Flags: runhidden
|
||||
|
||||
[UninstallDelete]
|
||||
Type: filesandordirs; Name: "{app}\certs"
|
||||
Type: files; Name: "{app}\client_config.json"
|
||||
Type: files; Name: "{app}\client_state.json"
|
||||
Type: files; Name: "{app}\*.log"
|
||||
Type: filesandordirs; Name: "{app}\runtime"
|
||||
Type: filesandordirs; Name: "{app}"
|
||||
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
|
||||
DataDirPage := CreateInputDirPage(
|
||||
wpSelectDir,
|
||||
'Data and Configuration Location',
|
||||
'Where should LOGAR Client store its state tracking and certificates?',
|
||||
'Select the folder where LOGAR Client should store state tracking (client_state.json), configuration (client_config.json), and mTLS certificates.'#13#10#13#10 +
|
||||
'By default, this is stored in Common AppData (C:\ProgramData\LOGAR), which is accessible to background Windows services.',
|
||||
False,
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
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;
|
||||
|
||||
function GetConfigFile(Param: String): String;
|
||||
begin
|
||||
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, AppDir, PowerShellCmd: String;
|
||||
I, ResultCode: Integer;
|
||||
begin
|
||||
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
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
!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 Centralized Log Aggregator && Forwarder"
|
||||
|
||||
!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"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
|
||||
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."
|
||||
!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
|
||||
|
||||
# Directory Page 2: Data Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Data & Storage Location"
|
||||
!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
|
||||
|
||||
# Finish Page
|
||||
!define MUI_FINISHPAGE_TITLE "Installation Complete"
|
||||
!define MUI_FINISHPAGE_TEXT "LOGAR Client has been successfully installed and configured as an automated background service."
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
# Uninstaller Pages
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!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"
|
||||
File /r "../dist/runtime"
|
||||
|
||||
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$\" --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
|
||||
|
||||
Section "Uninstall"
|
||||
SetShellVarContext all
|
||||
ReadRegStr $DATADIR HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||
${If} $DATADIR == ""
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||
${EndIf}
|
||||
|
||||
# 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"
|
||||
Delete "$INSTDIR\client_state.json"
|
||||
Delete "$INSTDIR\*.log"
|
||||
Delete "$INSTDIR\Win_Client.exe"
|
||||
Delete "$INSTDIR\nssm.exe"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir /r "$INSTDIR"
|
||||
RMDir /r "$DATADIR"
|
||||
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client"
|
||||
DeleteRegKey /ifempty HKLM "Software\LOGAR"
|
||||
SectionEnd
|
||||
@@ -1,27 +1,240 @@
|
||||
#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=2.0.1
|
||||
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
|
||||
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={#AppVersion}
|
||||
VersionInfoProductName=LOGAR Server
|
||||
VersionInfoProductVersion={#AppVersion}
|
||||
|
||||
[Files]
|
||||
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Dirs]
|
||||
Name: "{commonappdata}\LOGAR-Server"; Permissions: users-modify
|
||||
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Server ""{app}\Server.exe"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppDirectory ""{app}"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{commonappdata}\LOGAR-Server\server.log"""; Flags: runhidden
|
||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{commonappdata}\LOGAR-Server\server_err.log"""; Flags: runhidden
|
||||
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
|
||||
|
||||
[UninstallDelete]
|
||||
Type: filesandordirs; Name: "{app}\certs"
|
||||
Type: files; Name: "{app}\server_config.json"
|
||||
Type: files; Name: "{app}\logar_state.db"
|
||||
Type: files; Name: "{app}\logar_state.db-*"
|
||||
Type: files; Name: "{app}\*.log"
|
||||
Type: filesandordirs; Name: "{app}\runtime"
|
||||
Type: filesandordirs; Name: "{app}"
|
||||
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
|
||||
DataDirPage := CreateInputDirPage(
|
||||
wpSelectDir,
|
||||
'Data and Configuration Location',
|
||||
'Where should LOGAR Server store its database, certificates, and configuration?',
|
||||
'Select the folder where LOGAR Server should store the database (logar_state.db), configuration (server_config.json), and mTLS certificates.'#13#10#13#10 +
|
||||
'By default, this is stored in Common AppData (C:\ProgramData\LOGAR-Server), which is accessible to background Windows services.',
|
||||
False,
|
||||
''
|
||||
);
|
||||
DataDirPage.Add('Data Directory:');
|
||||
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 = 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, AppDir, PowerShellCmd: String;
|
||||
I, ResultCode: Integer;
|
||||
begin
|
||||
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
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
Name "LOGAR Server"
|
||||
OutFile "../dist/LOGAR-Server-Setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\LOGAR-Server"
|
||||
RequestExecutionLevel admin
|
||||
|
||||
BrandingText "LOGAR Centralized Log Aggregator && Ingestion Hub"
|
||||
|
||||
!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"
|
||||
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR"
|
||||
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
|
||||
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."
|
||||
!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
|
||||
|
||||
# Directory Page 2: Data Directory
|
||||
!define MUI_PAGE_HEADER_TEXT "Choose Data & Storage Location"
|
||||
!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
|
||||
|
||||
# Instfiles Page
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
|
||||
# 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"
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
# Uninstaller Pages
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!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"
|
||||
File /r "../dist/runtime"
|
||||
|
||||
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" start LOGAR_Server'
|
||||
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "InstallDir" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\LOGAR\LOGAR-Server" "DataDir" "$DATADIR"
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
SetShellVarContext all
|
||||
ReadRegStr $DATADIR HKLM "Software\LOGAR\LOGAR-Server" "DataDir"
|
||||
${If} $DATADIR == ""
|
||||
StrCpy $DATADIR "$APPDATA\LOGAR-Server"
|
||||
${EndIf}
|
||||
|
||||
# 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"
|
||||
Delete "$INSTDIR\logar_state.db"
|
||||
Delete "$INSTDIR\logar_state.db-*"
|
||||
Delete "$INSTDIR\*.log"
|
||||
Delete "$INSTDIR\Server.exe"
|
||||
Delete "$INSTDIR\nssm.exe"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir /r "$INSTDIR"
|
||||
RMDir /r "$DATADIR"
|
||||
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Server"
|
||||
DeleteRegKey /ifempty HKLM "Software\LOGAR"
|
||||
SectionEnd
|
||||
@@ -12,20 +12,65 @@ SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||
DIST_DIR = os.path.join(ROOT_DIR, "dist")
|
||||
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()
|
||||
with open(filepath, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def write_checksum_file(filename, digest, binary_filename):
|
||||
out_path = os.path.join(DIST_DIR, filename)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{digest} {binary_filename}\n")
|
||||
print(f"[+] Generated checksum file: {filename} ({digest[:16]}...)")
|
||||
|
||||
|
||||
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}"),
|
||||
@@ -38,25 +83,19 @@ def build_pyinstaller_binary(script_path, binary_name):
|
||||
raise RuntimeError(f"Failed to build {binary_name}")
|
||||
print(f"[+] Successfully compiled {binary_name}")
|
||||
|
||||
def calculate_sha256(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def write_checksum_file(filename, digest, binary_filename):
|
||||
out_path = os.path.join(DIST_DIR, filename)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"{digest} {binary_filename}\n")
|
||||
print(f"[+] Generated checksum file: {filename} ({digest[:16]}...)")
|
||||
|
||||
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.copy(os.path.join(SRC_DIR, "Linux_Client.py"), os.path.join(app_dir, "Linux_Client.py"))
|
||||
shutil.copytree(SRC_DIR, os.path.join(app_dir, "src"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
for f in os.listdir(SRC_DIR):
|
||||
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,
|
||||
@@ -66,8 +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.copy(os.path.join(SRC_DIR, "Server.py"), os.path.join(srv_dir, "Server.py"))
|
||||
shutil.copytree(SRC_DIR, os.path.join(srv_dir, "src"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
||||
for f in os.listdir(SRC_DIR):
|
||||
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,
|
||||
@@ -76,16 +121,441 @@ def build_linux_zipapp_fallback():
|
||||
main="Server:main"
|
||||
)
|
||||
|
||||
def build_windows():
|
||||
print("[*] Compiling Windows standalone executables...")
|
||||
win_client_script = os.path.join(SRC_DIR, "Win_Client.py")
|
||||
build_pyinstaller_binary(win_client_script, "Win_Client")
|
||||
|
||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
||||
build_pyinstaller_binary(server_script, "Server")
|
||||
def prepare_windows_runtime():
|
||||
"""
|
||||
Downloads the official Python 3.11 embeddable 64-bit runtime, configures ._pth,
|
||||
and downloads/unpacks all required dependencies (wheels) for standalone execution.
|
||||
"""
|
||||
runtime_dir = os.path.join(DIST_DIR, "runtime")
|
||||
if os.path.exists(runtime_dir):
|
||||
return runtime_dir
|
||||
|
||||
os.makedirs(runtime_dir, exist_ok=True)
|
||||
site_packages = os.path.join(runtime_dir, "site-packages")
|
||||
os.makedirs(site_packages, exist_ok=True)
|
||||
|
||||
# 1. Download & Extract Python 3.11 embeddable zip
|
||||
embed_zip_name = "python-3.11.9-embed-amd64.zip"
|
||||
embed_zip_path = os.path.join(BUILD_TEMP, embed_zip_name)
|
||||
embed_url = f"https://www.python.org/ftp/python/3.11.9/{embed_zip_name}"
|
||||
|
||||
if not os.path.exists(embed_zip_path):
|
||||
print(f"[*] Downloading Python 3.11 embeddable runtime from {embed_url}...")
|
||||
import urllib.request
|
||||
urllib.request.urlretrieve(embed_url, embed_zip_path)
|
||||
|
||||
import zipfile
|
||||
with zipfile.ZipFile(embed_zip_path, "r") as zf:
|
||||
zf.extractall(runtime_dir)
|
||||
|
||||
# 2. Configure python311._pth
|
||||
pth_file = os.path.join(runtime_dir, "python311._pth")
|
||||
with open(pth_file, "w", encoding="utf-8") as f:
|
||||
f.write("python311.zip\n.\nsite-packages\nsite-packages/win32\nsite-packages/win32/lib\nsite-packages/Pythonwin\nimport site\n")
|
||||
|
||||
# 3. Download and unpack Windows AMD64 dependencies
|
||||
wheels_dir = os.path.join(BUILD_TEMP, "win_wheels")
|
||||
os.makedirs(wheels_dir, exist_ok=True)
|
||||
|
||||
print("[*] Fetching required Windows x64 dependency wheels...")
|
||||
# Build PGPy 0.6.0 wheel from git (supports modern cryptography)
|
||||
try:
|
||||
subprocess.run([
|
||||
sys.executable, "-m", "pip", "wheel", "--no-deps",
|
||||
"git+https://github.com/SecurityInnovation/PGPy.git",
|
||||
"--wheel-dir", wheels_dir
|
||||
], check=False, capture_output=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Download binary wheels for win_amd64 cp311
|
||||
deps = [
|
||||
"cryptography", "fastapi", "uvicorn", "pydantic",
|
||||
"pywin32", "websockets", "pyasn1", "six", "cffi", "pycparser"
|
||||
]
|
||||
pip_cmd = [
|
||||
sys.executable, "-m", "pip", "download",
|
||||
"--only-binary=:all:",
|
||||
"--platform", "win_amd64",
|
||||
"--python-version", "311",
|
||||
"--implementation", "cp",
|
||||
"--abi", "cp311",
|
||||
"--dest", wheels_dir
|
||||
] + deps
|
||||
|
||||
subprocess.run(pip_cmd, check=False, capture_output=True)
|
||||
|
||||
for whl in os.listdir(wheels_dir):
|
||||
if whl.endswith(".whl"):
|
||||
whl_path = os.path.join(wheels_dir, whl)
|
||||
with zipfile.ZipFile(whl_path, "r") as zf:
|
||||
zf.extractall(site_packages)
|
||||
|
||||
# 4. Copy pywin32 system DLLs to runtime root for immediate DLL loader resolution
|
||||
sys32_dir = os.path.join(site_packages, "pywin32_system32")
|
||||
if os.path.exists(sys32_dir):
|
||||
for dll in os.listdir(sys32_dir):
|
||||
if dll.lower().endswith(".dll"):
|
||||
shutil.copy(os.path.join(sys32_dir, dll), runtime_dir)
|
||||
|
||||
print("[+] Successfully assembled standalone Windows Python 3.11 runtime environment.")
|
||||
return runtime_dir
|
||||
|
||||
|
||||
def create_windows_exe_zipapp(script_name, exe_name):
|
||||
"""
|
||||
Cross-compilation helper: Creates a standalone Windows .exe by prepending
|
||||
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):
|
||||
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")
|
||||
zipapp.create_archive(
|
||||
source=app_temp_dir,
|
||||
target=zipapp_temp,
|
||||
interpreter=r"runtime\python.exe",
|
||||
main=f"{module_name}:main"
|
||||
)
|
||||
|
||||
stub_bytes = b""
|
||||
try:
|
||||
import pip._vendor.distlib.resources as distlib_res
|
||||
finder = distlib_res.finder("pip._vendor.distlib")
|
||||
res = finder.find("t64.exe")
|
||||
if res:
|
||||
stub_bytes = res.bytes
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not stub_bytes:
|
||||
try:
|
||||
import distlib.resources as distlib_res
|
||||
finder = distlib_res.finder("distlib")
|
||||
res = finder.find("t64.exe")
|
||||
if res:
|
||||
stub_bytes = res.bytes
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_exe_path = os.path.join(DIST_DIR, f"{exe_name}.exe")
|
||||
with open(out_exe_path, "wb") as out_f:
|
||||
if stub_bytes:
|
||||
out_f.write(stub_bytes)
|
||||
with open(zipapp_temp, "rb") as in_f:
|
||||
out_f.write(in_f.read())
|
||||
print(f"[+] Successfully cross-compiled Windows PE executable: {exe_name}.exe (PE Stub attached: {bool(stub_bytes)})")
|
||||
|
||||
|
||||
def ensure_makensis():
|
||||
"""Finds or attempts to install makensis on the current system."""
|
||||
for cmd in ["makensis", "/usr/bin/makensis", "/usr/local/bin/makensis",
|
||||
r"C:\Program Files (x86)\NSIS\makensis.exe",
|
||||
r"C:\Program Files\NSIS\makensis.exe"]:
|
||||
p = shutil.which(cmd) if not os.path.isabs(cmd) else cmd
|
||||
if p and (os.path.exists(p) or shutil.which(p)):
|
||||
return p
|
||||
|
||||
if platform.system() == "Linux":
|
||||
print("[*] Attempting to install 'nsis' package dynamically...")
|
||||
sudo = ["sudo"] if hasattr(os, "geteuid") and os.geteuid() != 0 and shutil.which("sudo") else []
|
||||
if shutil.which("apt-get"):
|
||||
try:
|
||||
subprocess.run(sudo + ["apt-get", "update", "--allow-releaseinfo-change", "-o", "Acquire::Check-Valid-Until=false", "-y"], check=False)
|
||||
subprocess.run(sudo + ["apt-get", "-f", "install", "-y"], check=False)
|
||||
subprocess.run(sudo + ["apt-get", "install", "-y", "--fix-broken", "--fix-missing", "--no-install-recommends", "nsis", "nsis-common"], check=False)
|
||||
except Exception as e:
|
||||
print(f"[!] Warning: Dynamic apt install failed: {e}")
|
||||
elif shutil.which("apk"):
|
||||
try:
|
||||
subprocess.run(sudo + ["apk", "add", "--no-cache", "nsis"], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
elif shutil.which("dnf"):
|
||||
try:
|
||||
subprocess.run(sudo + ["dnf", "install", "-y", "mingw32-nsis", "nsis"], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cmd in ["makensis", "/usr/bin/makensis", "/usr/local/bin/makensis"]:
|
||||
p = shutil.which(cmd) if not os.path.isabs(cmd) else cmd
|
||||
if p and (os.path.exists(p) or shutil.which(p)):
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def ensure_libpython():
|
||||
"""Attempts to install libpython shared library on Debian/Ubuntu for PyInstaller."""
|
||||
if platform.system() == "Linux" and shutil.which("apt-get"):
|
||||
py_ver = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
sudo = ["sudo"] if hasattr(os, "geteuid") and os.geteuid() != 0 and shutil.which("sudo") else []
|
||||
try:
|
||||
subprocess.run(sudo + ["apt-get", "update", "--allow-releaseinfo-change", "-o", "Acquire::Check-Valid-Until=false", "-y"], check=False)
|
||||
subprocess.run(sudo + ["apt-get", "-f", "install", "-y"], check=False)
|
||||
subprocess.run(sudo + ["apt-get", "install", "-y", "--fix-broken", "--fix-missing", "--no-install-recommends", f"libpython{py_ver}", "python3-dev", f"python{py_ver}-dev", "libpython3-dev"], check=False)
|
||||
except Exception:
|
||||
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()
|
||||
if not makensis_bin:
|
||||
print("[!] Note: NSIS compiler (makensis) not found in PATH or standard directories.")
|
||||
return False
|
||||
|
||||
display_ver, vi_ver = get_context_version()
|
||||
write_version_headers(display_ver, vi_ver)
|
||||
|
||||
compilation_dir = os.path.join(ROOT_DIR, "compilation")
|
||||
makensis_cmd = [
|
||||
makensis_bin,
|
||||
f"-DAPP_VERSION={vi_ver}",
|
||||
f"-DDISPLAY_VERSION={display_ver}"
|
||||
]
|
||||
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} (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"[!] 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():
|
||||
"""Compiles Inno Setup installers if iscc or innosetup wrapper is available."""
|
||||
iscc_cmd = None
|
||||
user_iscc = os.path.expanduser(r"~\AppData\Local\Programs\Inno Setup 6\ISCC.exe")
|
||||
if shutil.which("iscc"):
|
||||
iscc_cmd = ["iscc"]
|
||||
elif shutil.which("innosetup"):
|
||||
iscc_cmd = ["innosetup"]
|
||||
elif os.path.exists(user_iscc):
|
||||
iscc_cmd = [user_iscc]
|
||||
elif os.path.exists("C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe"):
|
||||
iscc_cmd = ["C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe"]
|
||||
elif os.path.exists("C:\\Program Files\\Inno Setup 6\\ISCC.exe"):
|
||||
iscc_cmd = ["C:\\Program Files\\Inno Setup 6\\ISCC.exe"]
|
||||
elif shutil.which("wine") and os.path.exists("/opt/innosetup/app/ISCC.exe"):
|
||||
iscc_cmd = ["wine", "/opt/innosetup/app/ISCC.exe"]
|
||||
elif shutil.which("wine"):
|
||||
wine_iscc = os.path.expanduser("~/.wine/drive_c/Program Files (x86)/Inno Setup 6/ISCC.exe")
|
||||
if os.path.exists(wine_iscc):
|
||||
iscc_cmd = ["wine", wine_iscc]
|
||||
|
||||
if not iscc_cmd:
|
||||
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} (v{display_ver})...")
|
||||
try:
|
||||
subprocess.run(iscc_cmd + [f"/DAppVersion={display_ver}", iss_path], check=True)
|
||||
print(f"[+] Successfully compiled {iss}")
|
||||
except Exception as 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 ensure_makensis():
|
||||
compile_nsis_installers()
|
||||
else:
|
||||
compile_inno_setup_installers()
|
||||
|
||||
|
||||
def build_windows():
|
||||
print("[*] Packaging self-contained Windows standalone executables...")
|
||||
prepare_windows_runtime()
|
||||
create_windows_exe_zipapp("Win_Client.py", "Win_Client")
|
||||
create_windows_exe_zipapp("Server.py", "Server")
|
||||
|
||||
compile_windows_installers()
|
||||
|
||||
client_bin = os.path.join(DIST_DIR, "Win_Client.exe")
|
||||
server_bin = os.path.join(DIST_DIR, "Server.exe")
|
||||
runtime_dir = os.path.join(DIST_DIR, "runtime")
|
||||
|
||||
# Windows Zip Bundles
|
||||
import zipfile
|
||||
if os.path.exists(client_bin):
|
||||
client_zip = os.path.join(DIST_DIR, "LOGAR-Windows-Client.zip")
|
||||
with zipfile.ZipFile(client_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(client_bin, arcname="Win_Client.exe")
|
||||
sample_cfg = os.path.join(ROOT_DIR, "out", "win_client", "client_config.sample.json")
|
||||
if os.path.exists(sample_cfg):
|
||||
zf.write(sample_cfg, arcname="client_config.sample.json")
|
||||
if os.path.exists(runtime_dir):
|
||||
for root, _, files in os.walk(runtime_dir):
|
||||
for file in files:
|
||||
full_p = os.path.join(root, file)
|
||||
rel_p = os.path.relpath(full_p, DIST_DIR)
|
||||
zf.write(full_p, arcname=rel_p)
|
||||
print(f"[+] Packaged Windows Client Zip: {os.path.basename(client_zip)}")
|
||||
|
||||
if os.path.exists(server_bin):
|
||||
server_zip = os.path.join(DIST_DIR, "LOGAR-Windows-Server.zip")
|
||||
with zipfile.ZipFile(server_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(server_bin, arcname="Server.exe")
|
||||
sample_cfg = os.path.join(ROOT_DIR, "server_config.sample.json")
|
||||
if os.path.exists(sample_cfg):
|
||||
zf.write(sample_cfg, arcname="server_config.sample.json")
|
||||
if os.path.exists(runtime_dir):
|
||||
for root, _, files in os.walk(runtime_dir):
|
||||
for file in files:
|
||||
full_p = os.path.join(root, file)
|
||||
rel_p = os.path.relpath(full_p, DIST_DIR)
|
||||
zf.write(full_p, arcname=rel_p)
|
||||
print(f"[+] Packaged Windows Server Zip: {os.path.basename(server_zip)}")
|
||||
|
||||
sums = []
|
||||
if os.path.exists(client_bin):
|
||||
@@ -94,29 +564,42 @@ 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"]:
|
||||
inst_path = os.path.join(DIST_DIR, inst)
|
||||
if os.path.exists(inst_path):
|
||||
inst_hash = calculate_sha256(inst_path)
|
||||
sums.append(f"{inst_hash} {inst}")
|
||||
|
||||
sums_path = os.path.join(DIST_DIR, "SHA256SUMS_windows.txt")
|
||||
with open(sums_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(sums) + "\n")
|
||||
|
||||
|
||||
def build_linux():
|
||||
print("[*] Compiling Linux standalone binaries...")
|
||||
print("[*] Compiling Linux standalone binaries and packaging installers...")
|
||||
is_linux_host = platform.system() == "Linux"
|
||||
|
||||
if is_linux_host:
|
||||
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
||||
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
||||
ensure_libpython()
|
||||
try:
|
||||
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
||||
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
||||
|
||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
||||
build_pyinstaller_binary(server_script, "Server.bin")
|
||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
||||
build_pyinstaller_binary(server_script, "Server.bin")
|
||||
except Exception as e:
|
||||
print(f"[!] PyInstaller compilation encountered an issue ({e}). Falling back to standalone zipapp packaging...")
|
||||
build_linux_zipapp_fallback()
|
||||
|
||||
# Normalize extensions in case PyInstaller dropped .bin
|
||||
for name in ["Linux_Client", "Server"]:
|
||||
@@ -137,9 +620,37 @@ def build_linux():
|
||||
print("[!] Note: Host platform is not Linux. Generating executable zipapps for Linux target.")
|
||||
build_linux_zipapp_fallback()
|
||||
|
||||
# Copy Linux installation scripts directly to dist
|
||||
for sh in ["install_linux_client.sh", "install_linux_server.sh"]:
|
||||
sh_src = os.path.join(ROOT_DIR, "compilation", sh)
|
||||
if os.path.exists(sh_src):
|
||||
sh_dst = os.path.join(DIST_DIR, sh)
|
||||
shutil.copy(sh_src, sh_dst)
|
||||
print(f"[+] Copied Linux installer script: {sh}")
|
||||
|
||||
# Package Linux tarballs
|
||||
import tarfile
|
||||
client_bin = os.path.join(DIST_DIR, "Linux_Client.bin")
|
||||
server_bin = os.path.join(DIST_DIR, "Server.bin")
|
||||
|
||||
if os.path.exists(client_bin):
|
||||
client_tar = os.path.join(DIST_DIR, "LOGAR-Linux-Client.tar.gz")
|
||||
with tarfile.open(client_tar, "w:gz") as tf:
|
||||
tf.add(client_bin, arcname="Linux_Client.bin")
|
||||
client_sh = os.path.join(DIST_DIR, "install_linux_client.sh")
|
||||
if os.path.exists(client_sh):
|
||||
tf.add(client_sh, arcname="install_linux_client.sh")
|
||||
print(f"[+] Packaged Linux Client Tarball: {os.path.basename(client_tar)}")
|
||||
|
||||
if os.path.exists(server_bin):
|
||||
server_tar = os.path.join(DIST_DIR, "LOGAR-Linux-Server.tar.gz")
|
||||
with tarfile.open(server_tar, "w:gz") as tf:
|
||||
tf.add(server_bin, arcname="Server.bin")
|
||||
server_sh = os.path.join(DIST_DIR, "install_linux_server.sh")
|
||||
if os.path.exists(server_sh):
|
||||
tf.add(server_sh, arcname="install_linux_server.sh")
|
||||
print(f"[+] Packaged Linux Server Tarball: {os.path.basename(server_tar)}")
|
||||
|
||||
sums = []
|
||||
if os.path.exists(client_bin):
|
||||
client_hash = calculate_sha256(client_bin)
|
||||
@@ -147,25 +658,48 @@ 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)
|
||||
if os.path.exists(item_path):
|
||||
item_hash = calculate_sha256(item_path)
|
||||
sums.append(f"{item_hash} {item}")
|
||||
|
||||
sums_path = os.path.join(DIST_DIR, "SHA256SUMS_linux.txt")
|
||||
with open(sums_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(sums) + "\n")
|
||||
|
||||
|
||||
def build_all():
|
||||
print("[*] Building standalone distribution artifacts for both Linux and Windows...")
|
||||
build_linux()
|
||||
build_windows()
|
||||
|
||||
# Consolidated master checksums file
|
||||
all_sums = []
|
||||
for f in sorted(os.listdir(DIST_DIR)):
|
||||
fpath = os.path.join(DIST_DIR, f)
|
||||
if os.path.isfile(fpath) and not f.endswith(".txt") and not "sha256sum" in f:
|
||||
h = calculate_sha256(fpath)
|
||||
all_sums.append(f"{h} {f}")
|
||||
with open(os.path.join(DIST_DIR, "SHA256SUMS.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(all_sums) + "\n")
|
||||
|
||||
|
||||
def main(target=None):
|
||||
if target is None:
|
||||
parser = argparse.ArgumentParser(description="LOGAR Standalone Binary Compiler & Packager")
|
||||
parser.add_argument(
|
||||
"--target", "-t",
|
||||
choices=["windows", "win", "linux", "auto"],
|
||||
choices=["all", "windows", "win", "linux", "auto"],
|
||||
default="auto",
|
||||
help="Target platform to compile binaries for (default: auto-detect)"
|
||||
)
|
||||
@@ -173,19 +707,21 @@ def main(target=None):
|
||||
target = args.target
|
||||
|
||||
if target == "auto":
|
||||
target = "windows" if platform.system() == "Windows" else "linux"
|
||||
target = "all"
|
||||
elif target == "win":
|
||||
target = "windows"
|
||||
|
||||
print("=" * 60)
|
||||
print(" LOGAR Binary Packaging")
|
||||
print(" LOGAR Binary Packaging & Cross-Compilation")
|
||||
print(f" Host Platform: {platform.system()} ({platform.machine()})")
|
||||
print(f" Target Platform: {target.upper()}")
|
||||
print("=" * 60)
|
||||
|
||||
clean_and_prep()
|
||||
|
||||
if target == "windows":
|
||||
if target == "all":
|
||||
build_all()
|
||||
elif target == "windows":
|
||||
build_windows()
|
||||
elif target == "linux":
|
||||
build_linux()
|
||||
@@ -202,5 +738,6 @@ def main(target=None):
|
||||
print(f" - {f} ({sz / (1024*1024):.2f} MB)" if sz > 1024*1024 else f" - {f} ({sz} bytes)")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -3,5 +3,11 @@ standard-imghdr>=3.13.0; python_version >= "3.13"
|
||||
cryptography>=42.0.0
|
||||
fastapi>=0.110.0
|
||||
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"
|
||||
|
||||
@@ -13,9 +13,21 @@ import time
|
||||
DEFAULT_GITEA_URL = os.environ.get("GITEA_SERVER_URL", "https://gitea.eibl.tech")
|
||||
DEFAULT_REPO = os.environ.get("GITEA_REPOSITORY", "me0nline/LOGAR")
|
||||
|
||||
import base64
|
||||
|
||||
def get_auth_header(token):
|
||||
if not token:
|
||||
return ""
|
||||
if token.startswith("Basic ") or token.startswith("token ") or token.startswith("Bearer "):
|
||||
return token
|
||||
if ":" in token:
|
||||
b64 = base64.b64encode(token.encode("utf-8")).decode("ascii")
|
||||
return f"Basic {b64}"
|
||||
return f"token {token}"
|
||||
|
||||
def get_existing_assets(base_url, repo, release_id, token):
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}", "Accept": "application/json"})
|
||||
req = urllib.request.Request(url, headers={"Authorization": get_auth_header(token), "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
@@ -24,7 +36,7 @@ def get_existing_assets(base_url, repo, release_id, token):
|
||||
|
||||
def delete_asset(base_url, repo, release_id, asset_id, token):
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset_id}"
|
||||
req = urllib.request.Request(url, method="DELETE", headers={"Authorization": f"token {token}"})
|
||||
req = urllib.request.Request(url, method="DELETE", headers={"Authorization": get_auth_header(token)})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
pass
|
||||
@@ -47,7 +59,7 @@ def upload_file_to_release(base_url, repo, release_id, token, file_path, max_ret
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
req = urllib.request.Request(url, data=file_bytes, method="POST")
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Authorization", get_auth_header(token))
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
req.add_header("Accept", "application/json")
|
||||
|
||||
@@ -70,28 +82,32 @@ def upload_file_to_release(base_url, repo, release_id, token, file_path, max_ret
|
||||
else:
|
||||
return None
|
||||
|
||||
def create_or_get_release(base_url, repo, tag, token, title=None, notes=None):
|
||||
def create_or_get_release(base_url, repo, tag, token, title=None, notes=None, prerelease=None):
|
||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Authorization": get_auth_header(token),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
is_prerelease = prerelease if prerelease is not None else any(x in tag.lower() for x in ["-rc", "-pre", "-beta", "-alpha", "preview"])
|
||||
|
||||
# Check if release exists
|
||||
check_url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}"
|
||||
check_req = urllib.request.Request(check_url, headers={"Authorization": f"token {token}"})
|
||||
check_req = urllib.request.Request(check_url, headers={"Authorization": get_auth_header(token)})
|
||||
try:
|
||||
with urllib.request.urlopen(check_req) as resp:
|
||||
existing = json.loads(resp.read().decode("utf-8"))
|
||||
print(f"[*] Found existing release for tag {tag} (ID: {existing['id']})")
|
||||
if notes or title:
|
||||
if notes or title or (prerelease is not None):
|
||||
patch_url = f"{base_url}/api/v1/repos/{repo}/releases/{existing['id']}"
|
||||
patch_payload = {}
|
||||
if title:
|
||||
patch_payload["name"] = title
|
||||
if notes:
|
||||
patch_payload["body"] = notes
|
||||
if prerelease is not None:
|
||||
patch_payload["prerelease"] = is_prerelease
|
||||
patch_req = urllib.request.Request(
|
||||
patch_url,
|
||||
data=json.dumps(patch_payload).encode("utf-8"),
|
||||
@@ -113,7 +129,7 @@ def create_or_get_release(base_url, repo, tag, token, title=None, notes=None):
|
||||
"name": title or f"LOGAR Release {tag}",
|
||||
"body": notes or f"Automated binary release for {tag}.",
|
||||
"draft": False,
|
||||
"prerelease": False
|
||||
"prerelease": is_prerelease
|
||||
}
|
||||
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST")
|
||||
try:
|
||||
|
||||
@@ -1,138 +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.
|
||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Script (Recommended)
|
||||
|
||||
Run the automated installer script:
|
||||
```bash
|
||||
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||
```
|
||||
This script:
|
||||
1. Installs the binary to `/opt/logar-client/Linux_Client`.
|
||||
2. Creates `/etc/logar/certs` with strict permissions.
|
||||
3. Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||
4. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-client.service`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generating & Deploying the Configuration File
|
||||
|
||||
### Step 1: Generate `client_config.json` on the Server
|
||||
Run the following command on your central LOGAR server:
|
||||
```bash
|
||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||
|
||||
### Step 2: Configuration Structure
|
||||
The generated `client_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"hermes_host": "192.168.1.100",
|
||||
"hermes_port": 8443,
|
||||
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"auth_token": "a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||
|
||||
### Step 3: Copy to Edge Node
|
||||
Place `Linux_Client.bin` and `client_config.json` into the target directory (e.g. `/opt/logar/`):
|
||||
```bash
|
||||
sudo mkdir -p /opt/logar
|
||||
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
||||
sudo chmod +x /opt/logar/Linux_Client.bin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running Manually
|
||||
|
||||
Test the forwarder interactively:
|
||||
```bash
|
||||
cd /opt/logar
|
||||
./Linux_Client.bin --hours 24
|
||||
```
|
||||
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||
|
||||
### Command-Line Arguments
|
||||
| Argument | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `--config` | `client_config.json` | Path to client configuration file |
|
||||
| `--hours` | `24` | Lookback window in hours for journal logs |
|
||||
| `--state-file` | `client_state.json` | Path to persistent state file |
|
||||
| `--no-state` | `False` | Disable state tracking and send all events matching lookback window |
|
||||
|
||||
---
|
||||
|
||||
## 4. Manual Systemd Service & Timer Setup
|
||||
|
||||
### Step 1: Create the Systemd Service Unit
|
||||
Create `/etc/systemd/system/logar-client.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LOGAR Edge Log Forwarder
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/logar
|
||||
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Step 2: Enable and Start the Service
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now logar-client.service
|
||||
```
|
||||
|
||||
### Step 3: Check Logs
|
||||
```bash
|
||||
sudo journalctl -u logar-client.service -n 50 -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Uninstallation & Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now logar-client.service
|
||||
sudo rm -f /etc/systemd/system/logar-client.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/logar-client /opt/logar /etc/logar
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"server_fingerprint": "PASTE_SERVER_FINGERPRINT_HERE",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"auth_token": "PASTE_AUTH_TOKEN_HERE"
|
||||
}
|
||||
@@ -1,151 +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.
|
||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
|
||||
- **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 | 4-Run Rule: Warnings | Immediate Pass: Errors
|
||||
============================================================
|
||||
[*] 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": 4,
|
||||
"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 Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
||||
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 a signed CA certificate via CLI:
|
||||
```bash
|
||||
./Server.bin --import-ca ca.crt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual Systemd Service Management
|
||||
|
||||
Check service status:
|
||||
```bash
|
||||
sudo systemctl status logar-server.service
|
||||
```
|
||||
|
||||
Inspect live service logs:
|
||||
```bash
|
||||
sudo journalctl -u logar-server.service -f -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Uninstallation & Removal
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now logar-server.service
|
||||
sudo rm -f /etc/systemd/system/logar-server.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"server_name": "LOGAR-Linux-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "replace_with_secure_random_hex_token",
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 4,
|
||||
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
|
||||
}
|
||||
@@ -1,133 +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.
|
||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Automated Installation via Inno Setup (Recommended)
|
||||
|
||||
Run the self-contained installer built from `compilation/installer_client.iss`:
|
||||
```powershell
|
||||
.\LOGAR-Client-Setup.exe
|
||||
```
|
||||
This installer:
|
||||
1. Installs `Win_Client.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR\`.
|
||||
2. Sets up directory permissions in `C:\ProgramData\LOGAR\`.
|
||||
3. Registers and starts the `LOGAR_Client` Windows service automatically via NSSM.
|
||||
4. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR\client.log` and `client_err.log`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generating & Deploying the Configuration File
|
||||
|
||||
### Step 1: Generate `client_config.json` on the Server
|
||||
Run the following command on your central LOGAR server:
|
||||
```bash
|
||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||
```
|
||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||
|
||||
### Step 2: Configuration Structure
|
||||
The generated `client_config.json` contains:
|
||||
```json
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"hermes_host": "192.168.1.100",
|
||||
"hermes_port": 8443,
|
||||
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||
"auth_token": "a1b2c3d4e5f6..."
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||
|
||||
### Step 3: Copy to Edge Node
|
||||
Place `client_config.json` next to `Win_Client.exe` (e.g. `C:\Program Files\LOGAR\` or `C:\LOGAR\`):
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
||||
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running Manually
|
||||
|
||||
Test the forwarder interactively from PowerShell or Command Prompt:
|
||||
```powershell
|
||||
cd C:\LOGAR
|
||||
.\Win_Client.exe --hours 24
|
||||
```
|
||||
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||
|
||||
### Command-Line Arguments
|
||||
| Argument | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `--config` | `client_config.json` | Path to client configuration file |
|
||||
| `--hours` | `24` | Lookback window in hours for event logs |
|
||||
| `--state-file` | `client_state.json` | Path to persistent state tracking file |
|
||||
| `--no-state` | `False` | Disable state tracking and send all events matching lookback window |
|
||||
|
||||
---
|
||||
|
||||
## 4. Manual Service Installation (NSSM or Scheduled Task)
|
||||
|
||||
### Method A: Windows Service via Bundled NSSM
|
||||
```powershell
|
||||
# From the compilation directory or with bundled nssm.exe:
|
||||
.\nssm.exe install LOGAR_Client "C:\LOGAR\Win_Client.exe" "--hours 24"
|
||||
.\nssm.exe set LOGAR_Client AppDirectory "C:\LOGAR"
|
||||
.\nssm.exe set LOGAR_Client AppStdout "C:\ProgramData\LOGAR\client.log"
|
||||
.\nssm.exe set LOGAR_Client AppStderr "C:\ProgramData\LOGAR\client_err.log"
|
||||
.\nssm.exe start LOGAR_Client
|
||||
```
|
||||
|
||||
### Method B: Windows Scheduled Task via PowerShell
|
||||
```powershell
|
||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
||||
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
||||
|
||||
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
||||
-Action $Action `
|
||||
-Trigger $Trigger `
|
||||
-Settings $Settings `
|
||||
-User "NT AUTHORITY\SYSTEM" `
|
||||
-RunLevel Highest `
|
||||
-Description "LOGAR Windows Edge Log Forwarder Service"
|
||||
|
||||
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Uninstallation
|
||||
|
||||
If installed via the Inno Setup installer, use **Windows Add/Remove Programs** or run `unins000.exe` in `C:\Program Files\LOGAR\`.
|
||||
|
||||
If installed manually via NSSM:
|
||||
```powershell
|
||||
.\nssm.exe stop LOGAR_Client
|
||||
.\nssm.exe remove LOGAR_Client confirm
|
||||
Remove-Item -Recurse -Force "C:\LOGAR"
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"server_host": "192.168.1.100",
|
||||
"server_port": 9443,
|
||||
"server_fingerprint": "PASTE_SERVER_FINGERPRINT_HERE",
|
||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n",
|
||||
"auth_token": "PASTE_AUTH_TOKEN_HERE"
|
||||
}
|
||||
@@ -1,150 +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.
|
||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
|
||||
- **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 | 4-Run Rule: Warnings | Immediate Pass: Errors
|
||||
============================================================
|
||||
[*] 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": 4,
|
||||
"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 Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
||||
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 a signed CA certificate via CLI:
|
||||
```powershell
|
||||
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Manual Windows Service Setup (via NSSM)
|
||||
|
||||
```powershell
|
||||
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
||||
.\nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
||||
.\nssm.exe set LOGAR_Server AppStdout "C:\ProgramData\LOGAR-Server\server.log"
|
||||
.\nssm.exe set LOGAR_Server AppStderr "C:\ProgramData\LOGAR-Server\server_err.log"
|
||||
.\nssm.exe start LOGAR_Server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Uninstallation
|
||||
|
||||
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
||||
|
||||
If installed manually via NSSM:
|
||||
```powershell
|
||||
.\nssm.exe stop LOGAR_Server
|
||||
.\nssm.exe remove LOGAR_Server confirm
|
||||
```
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"server_name": "LOGAR-Windows-Hub",
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": "replace_with_secure_random_hex_token",
|
||||
"db_path": "logar_state.db",
|
||||
"evaluation_window_hours": 12,
|
||||
"min_persistence_runs": 4,
|
||||
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
|
||||
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Gitea Instance URL
|
||||
GITEA_INSTANCE_URL=https://gitea.eibl.tech
|
||||
|
||||
# Gitea Runner Registration Token (From Gitea Settings -> Actions -> Runners -> Create Runner)
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN=your_runner_registration_token_here
|
||||
|
||||
# Runner Configuration
|
||||
GITEA_RUNNER_NAME=logar-build-runner
|
||||
@@ -0,0 +1,71 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
LABEL maintainer="me0nline <me0nline@eibl.tech>"
|
||||
LABEL description="LOGAR CI/CD, Binary Packaging & Release Runner"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# ── 1. Core System Tools, Python Dev Headers & NSIS Compiler ───────────────────
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
# Basic utilities
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
tar \
|
||||
gzip \
|
||||
zip \
|
||||
unzip \
|
||||
# Native compilation toolchains
|
||||
build-essential \
|
||||
gcc \
|
||||
make \
|
||||
# Python 3 & dynamic shared libraries (required for PyInstaller standalone binaries)
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
libpython3-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
# NSIS (for compiling Windows Setup executables on Linux)
|
||||
nsis \
|
||||
nsis-common \
|
||||
nsis-pluginapi \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# ── 2. Node.js 20 LTS (required for Act / Gitea Actions runners) ───────────────
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y -qq nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# ── 3. Pre-installed Python Build & Application Dependencies ───────────────────
|
||||
RUN pip3 install --no-cache-dir \
|
||||
# Binary compiler & PE packaging tools
|
||||
pyinstaller \
|
||||
distlib \
|
||||
cryptography \
|
||||
pefile \
|
||||
cffi \
|
||||
# LOGAR core dependencies
|
||||
pgpy \
|
||||
fastapi \
|
||||
uvicorn \
|
||||
websockets \
|
||||
pydantic \
|
||||
requests \
|
||||
httpx \
|
||||
# Database drivers & ORM for multi-engine storage
|
||||
sqlalchemy \
|
||||
psycopg2-binary \
|
||||
pymysql \
|
||||
# Code quality / linter / testing
|
||||
coverage \
|
||||
flake8
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# LOGAR Custom CI/CD Runner
|
||||
|
||||
This directory contains the Docker configuration for the custom **LOGAR Build & Release Runner** (`gitea.eibl.tech/me0nline/logar-runner:latest`).
|
||||
|
||||
---
|
||||
|
||||
## Included Toolchains & Dependencies
|
||||
|
||||
- **Operating System**: Ubuntu 22.04 LTS
|
||||
- **Python Development**:
|
||||
- `python3`, `python3-dev`, `libpython3-dev` (shared libraries for native ELF compilation)
|
||||
- `python3-pip`, `python3-venv`, `python3-setuptools`, `python3-wheel`
|
||||
- **Native NSIS Cross-Compiler**:
|
||||
- `nsis`, `nsis-common`, `nsis-pluginapi` (`makensis` CLI for compiling Windows `.exe` installers on Linux)
|
||||
- **Build Essentials**:
|
||||
- `gcc`, `make`, `binutils`, `build-essential`, `libffi-dev`, `libssl-dev`
|
||||
- **Pre-installed Python Packages**:
|
||||
- `pyinstaller`, `distlib`, `cryptography`, `pefile`, `pgpy`, `fastapi`, `uvicorn`, `websockets`, `pydantic`, `requests`, `sqlalchemy`, `psycopg2-binary`, `pymysql`
|
||||
|
||||
---
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
You can run CI/CD jobs directly inside this container image in `.gitea/workflows/`:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Standalone Binaries
|
||||
runs-on: logar
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 compilation/package_dist.py --target all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building & Pushing the Runner
|
||||
|
||||
Trigger the automated workflow [`.gitea/workflows/build-runner.yml`](../.gitea/workflows/build-runner.yml) via the Gitea Actions UI (Actions → Build and Push LOGAR Runner → Run Workflow) or push changes to the `runner/` folder.
|
||||
|
||||
---
|
||||
|
||||
## Deploying the Runner Stack via Portainer
|
||||
|
||||
1. In Gitea, navigate to **Site Administration** (or Repo **Settings**) → **Actions** → **Runners** → **Create Runner**. Copy the registration token.
|
||||
2. In Portainer, navigate to **Stacks** → **Add stack**.
|
||||
3. Set the name to `logar-act-runner`.
|
||||
4. Paste the contents of [`runner/docker-compose.yml`](docker-compose.yml) into the Web editor (or link to this Git repository).
|
||||
5. In Environment variables, set:
|
||||
- `GITEA_RUNNER_REGISTRATION_TOKEN`: `<your-gitea-runner-token>`
|
||||
6. Click **Deploy the stack**.
|
||||
|
||||
The runner will automatically register with Gitea under the dedicated label `logar`, executing jobs inside the pre-configured `gitea.eibl.tech/me0nline/logar-runner:latest` container.
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
logar-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: logar-act-runner
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://gitea.eibl.tech}
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||
- GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-logar-build-runner}
|
||||
- GITEA_RUNNER_LABELS=logar:docker://gitea.eibl.tech/me0nline/logar-runner:latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- logar_runner_data:/data
|
||||
networks:
|
||||
- runner-net
|
||||
|
||||
volumes:
|
||||
logar_runner_data:
|
||||
name: logar_runner_data
|
||||
|
||||
networks:
|
||||
runner-net:
|
||||
name: logar_runner_net
|
||||
@@ -1,28 +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 sys
|
||||
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()
|
||||
@@ -1,490 +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, Dict, Any, List
|
||||
|
||||
# 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)
|
||||
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()
|
||||
@@ -1,457 +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, Dict, Any, List
|
||||
|
||||
# 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 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_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):
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 *
|
||||
@@ -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 *
|
||||
@@ -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 *
|
||||
@@ -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()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
try:
|
||||
from src.common.system_info import get_os_details
|
||||
except ImportError:
|
||||
try:
|
||||
from common.system_info import get_os_details
|
||||
except ImportError:
|
||||
get_os_details = lambda: {"os_type": "windows"}
|
||||
|
||||
|
||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||
node_os = get_os_details().get("os_type", "windows")
|
||||
return transport_send_encrypted_logs(config=config, logs=logs, os_type=node_os)
|
||||
|
||||
|
||||
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||
node_os = get_os_details().get("os_type", "windows")
|
||||
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type=node_os)
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
# LOGAR Clients Package
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
from src.common.system_info import get_full_system_inventory, get_os_details
|
||||
except ImportError:
|
||||
try:
|
||||
from common.system_info import get_full_system_inventory, get_os_details
|
||||
except ImportError:
|
||||
try:
|
||||
from system_info import get_full_system_inventory, get_os_details
|
||||
except ImportError:
|
||||
get_full_system_inventory = lambda: {}
|
||||
get_os_details = lambda: {"os_type": "windows" if sys.platform == "win32" else "linux", "os_name": "", "is_server": False}
|
||||
|
||||
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"
|
||||
inventory = get_full_system_inventory()
|
||||
effective_os = inventory.get("os_type") or os_type
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": effective_os,
|
||||
"os_name": inventory.get("os_name", ""),
|
||||
"is_server": inventory.get("is_server", False),
|
||||
"server_roles": inventory.get("server_roles", []),
|
||||
"client_info": inventory,
|
||||
"metrics": collect_system_metrics(),
|
||||
"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:
|
||||
inventory = get_full_system_inventory()
|
||||
payload = {
|
||||
"server": actual_machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs,
|
||||
"metrics": collect_system_metrics(),
|
||||
"version": LOGAR_VERSION,
|
||||
"os_type": inventory.get("os_type", node_os),
|
||||
"os_name": inventory.get("os_name", ""),
|
||||
"is_server": inventory.get("is_server", False),
|
||||
"server_roles": inventory.get("server_roles", []),
|
||||
"client_info": inventory
|
||||
}
|
||||
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}.")
|
||||
|
||||
inventory = get_full_system_inventory()
|
||||
payload = {
|
||||
"server": actual_machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs,
|
||||
"metrics": collect_system_metrics(),
|
||||
"version": LOGAR_VERSION,
|
||||
"os_type": inventory.get("os_type", node_os),
|
||||
"os_name": inventory.get("os_name", ""),
|
||||
"is_server": inventory.get("is_server", False),
|
||||
"server_roles": inventory.get("server_roles", []),
|
||||
"client_info": inventory
|
||||
}
|
||||
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)
|
||||
@@ -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"¤t_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, timeout=10)
|
||||
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}
|
||||
@@ -0,0 +1,715 @@
|
||||
"""
|
||||
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 time
|
||||
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 sys.platform == "win32":
|
||||
class FILETIME(ctypes.Structure):
|
||||
_fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)]
|
||||
|
||||
idle, kernel, user = FILETIME(), FILETIME(), FILETIME()
|
||||
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
|
||||
i1 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
|
||||
k1 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
|
||||
u1 = (user.dwHighDateTime << 32) + user.dwLowDateTime
|
||||
time.sleep(0.06)
|
||||
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
|
||||
i2 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
|
||||
k2 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
|
||||
u2 = (user.dwHighDateTime << 32) + user.dwLowDateTime
|
||||
idle_delta = i2 - i1
|
||||
total_delta = (k2 - k1) + (u2 - u1)
|
||||
if total_delta > 0:
|
||||
pct = 100.0 * (1.0 - (idle_delta / total_delta))
|
||||
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
|
||||
elif os.path.exists("/proc/stat"):
|
||||
def _read_proc_stat():
|
||||
with open("/proc/stat", "r") as f:
|
||||
fields = [float(col) for col in f.readline().strip().split()[1:]]
|
||||
idle = fields[3] + (fields[4] if len(fields) > 4 else 0.0)
|
||||
total = sum(fields)
|
||||
return idle, total
|
||||
|
||||
i1, t1 = _read_proc_stat()
|
||||
time.sleep(0.06)
|
||||
i2, t2 = _read_proc_stat()
|
||||
total_delta = t2 - t1
|
||||
idle_delta = i2 - i1
|
||||
if total_delta > 0:
|
||||
pct = 100.0 * (1.0 - (idle_delta / total_delta))
|
||||
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
|
||||
elif 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", "--no-pager", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||
result = None
|
||||
|
||||
if last_cursor:
|
||||
cmd_with_cursor = ["journalctl", "--no-pager", "--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, timeout=8)
|
||||
if res.returncode == 0:
|
||||
result = res
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
pass
|
||||
|
||||
if result is None:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=8)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
# journalctl not present or timed out, 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)
|
||||
@@ -0,0 +1,8 @@
|
||||
# LOGAR Common Package
|
||||
from src.common.system_info import (
|
||||
get_os_details,
|
||||
get_installed_server_roles,
|
||||
get_full_system_inventory,
|
||||
get_ip_addresses
|
||||
)
|
||||
from src.common.version import LOGAR_VERSION
|
||||
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
LOGAR System & Server Role Introspection Module
|
||||
Provides cross-platform OS classification (Windows Client vs Windows Server vs Linux),
|
||||
installed Server Role detection, and comprehensive host inventory telemetry.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import shutil
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
def get_ip_addresses() -> List[str]:
|
||||
"""Retrieves all non-loopback IPv4 addresses assigned to the local host."""
|
||||
ips = set()
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = info[4][0]
|
||||
if ip and not ip.startswith("127."):
|
||||
ips.add(ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Fallback via outbound UDP socket probe (doesn't send traffic)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.1)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
if local_ip and not local_ip.startswith("127."):
|
||||
ips.add(local_ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return sorted(list(ips))
|
||||
|
||||
|
||||
def parse_os_release_text(content: str) -> Dict[str, str]:
|
||||
"""Parses key=value lines from standard Linux /etc/os-release files."""
|
||||
data = {}
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"\'')
|
||||
data[k] = v
|
||||
return data
|
||||
|
||||
|
||||
def get_linux_distro_info(os_release_path: Optional[str] = None) -> Dict[str, str]:
|
||||
"""
|
||||
Accurately detects Linux distribution name, version, ID, and codename.
|
||||
Uses platform.freedesktop_os_release() (Python 3.10+), followed by standard
|
||||
/etc/os-release and /usr/lib/os-release parsing, with legacy release file fallbacks.
|
||||
"""
|
||||
res = {
|
||||
"distro": "",
|
||||
"distro_version": "",
|
||||
"distro_id": "",
|
||||
"distro_codename": "",
|
||||
"pretty_name": ""
|
||||
}
|
||||
|
||||
raw: Dict[str, str] = {}
|
||||
|
||||
if os_release_path and os.path.exists(os_release_path):
|
||||
try:
|
||||
with open(os_release_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
raw = parse_os_release_text(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not raw:
|
||||
# 1. Try standard library platform.freedesktop_os_release() (Python 3.10+)
|
||||
if hasattr(platform, "freedesktop_os_release"):
|
||||
try:
|
||||
raw = dict(platform.freedesktop_os_release())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not raw:
|
||||
# 2. Check standard /etc/os-release and /usr/lib/os-release
|
||||
for fpath in ("/etc/os-release", "/usr/lib/os-release"):
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
||||
raw = parse_os_release_text(f.read())
|
||||
if raw:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if raw:
|
||||
distro = raw.get("NAME") or raw.get("ID", "").capitalize() or "Linux"
|
||||
version = raw.get("VERSION_ID") or raw.get("VERSION") or ""
|
||||
pretty = raw.get("PRETTY_NAME") or f"{distro} {version}".strip()
|
||||
distro_id = raw.get("ID", "").lower()
|
||||
codename = raw.get("VERSION_CODENAME") or ""
|
||||
if not codename and "(" in raw.get("VERSION", ""):
|
||||
m = re.search(r"\(([^)]+)\)", raw.get("VERSION", ""))
|
||||
if m:
|
||||
codename = m.group(1).lower()
|
||||
|
||||
res["distro"] = distro
|
||||
res["distro_version"] = version
|
||||
res["distro_id"] = distro_id
|
||||
res["distro_codename"] = codename
|
||||
res["pretty_name"] = pretty
|
||||
return res
|
||||
|
||||
# 3. Legacy file fallbacks for older or non-systemd Linux distributions
|
||||
for rh_file in ("/etc/redhat-release", "/etc/centos-release", "/etc/system-release"):
|
||||
if os.path.exists(rh_file):
|
||||
try:
|
||||
with open(rh_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
res["pretty_name"] = content
|
||||
m = re.search(r"release\s+([\d\.]+)", content, re.IGNORECASE)
|
||||
if m:
|
||||
res["distro_version"] = m.group(1)
|
||||
parts = content.split("release")[0].strip()
|
||||
res["distro"] = parts or "Red Hat / CentOS"
|
||||
res["distro_id"] = "rhel" if "red hat" in content.lower() else ("centos" if "centos" in content.lower() else "linux")
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists("/etc/debian_version"):
|
||||
try:
|
||||
with open("/etc/debian_version", "r", encoding="utf-8", errors="replace") as f:
|
||||
ver = f.read().strip()
|
||||
res["distro"] = "Debian"
|
||||
res["distro_version"] = ver
|
||||
res["distro_id"] = "debian"
|
||||
res["pretty_name"] = f"Debian {ver}".strip()
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists("/etc/alpine-release"):
|
||||
try:
|
||||
with open("/etc/alpine-release", "r", encoding="utf-8", errors="replace") as f:
|
||||
ver = f.read().strip()
|
||||
res["distro"] = "Alpine Linux"
|
||||
res["distro_version"] = ver
|
||||
res["distro_id"] = "alpine"
|
||||
res["pretty_name"] = f"Alpine Linux {ver}".strip()
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists("/etc/arch-release"):
|
||||
res["distro"] = "Arch Linux"
|
||||
res["distro_version"] = "Rolling"
|
||||
res["distro_id"] = "arch"
|
||||
res["pretty_name"] = "Arch Linux"
|
||||
return res
|
||||
|
||||
if os.path.exists("/etc/SuSE-release"):
|
||||
try:
|
||||
with open("/etc/SuSE-release", "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
m = re.search(r"VERSION\s*=\s*([\d\.]+)", content)
|
||||
ver = m.group(1) if m else ""
|
||||
res["distro"] = "openSUSE"
|
||||
res["distro_version"] = ver
|
||||
res["distro_id"] = "opensuse"
|
||||
res["pretty_name"] = f"openSUSE {ver}".strip()
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generic Linux fallback
|
||||
kernel_ver = platform.release()
|
||||
res["distro"] = "Linux"
|
||||
res["distro_version"] = kernel_ver
|
||||
res["distro_id"] = "linux"
|
||||
res["pretty_name"] = f"Linux {kernel_ver}".strip()
|
||||
return res
|
||||
|
||||
|
||||
def get_os_details(os_release_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Accurately classifies the operating system, explicitly differentiating between
|
||||
Windows Client (Workstation) and Windows Server, as well as Linux distributions.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"os_type": "windows_server" | "windows" | "linux" | "darwin" | "unknown",
|
||||
"os_name": str, # e.g. "Windows Server 2022 Datacenter", "Windows 11 Pro", "Ubuntu 22.04 LTS"
|
||||
"is_server": bool, # True for Windows Server or Linux Server; False for desktop clients
|
||||
"distro": str, # e.g. "Ubuntu", "Debian GNU/Linux", "Red Hat Enterprise Linux"
|
||||
"distro_version": str,# e.g. "22.04", "12", "9.3"
|
||||
"distro_id": str, # e.g. "ubuntu", "debian", "rhel", "alpine"
|
||||
"distro_codename": str,# e.g. "jammy", "bookworm"
|
||||
"edition": str, # e.g. "ServerDatacenter", "Professional", "Enterprise"
|
||||
"build": str, # OS build number or kernel release
|
||||
"architecture": str, # "AMD64", "x86_64", "ARM64"
|
||||
"hostname": str,
|
||||
"ip_addresses": List[str]
|
||||
}
|
||||
"""
|
||||
is_server = False
|
||||
os_type = "unknown"
|
||||
os_name = platform.platform()
|
||||
distro = ""
|
||||
distro_version = ""
|
||||
distro_id = ""
|
||||
distro_codename = ""
|
||||
edition = ""
|
||||
build = platform.version()
|
||||
arch = platform.machine() or ("64-bit" if sys.maxsize > 2**32 else "32-bit")
|
||||
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winreg
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") as key:
|
||||
pname = str(winreg.QueryValueEx(key, "ProductName")[0])
|
||||
try:
|
||||
itype = str(winreg.QueryValueEx(key, "InstallationType")[0])
|
||||
except Exception:
|
||||
itype = ""
|
||||
try:
|
||||
edition = str(winreg.QueryValueEx(key, "EditionID")[0])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
build = str(winreg.QueryValueEx(key, "CurrentBuild")[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os_name = pname
|
||||
# Check for Windows Server installation type or product name
|
||||
if itype.strip().lower() == "server" or "server" in pname.lower():
|
||||
is_server = True
|
||||
os_type = "windows_server"
|
||||
else:
|
||||
is_server = False
|
||||
os_type = "windows"
|
||||
except Exception:
|
||||
# Fallback for Windows
|
||||
win_ver = platform.win32_ver()
|
||||
os_name = f"Windows {win_ver[0]}"
|
||||
if "server" in os_name.lower():
|
||||
is_server = True
|
||||
os_type = "windows_server"
|
||||
else:
|
||||
is_server = False
|
||||
os_type = "windows"
|
||||
|
||||
elif sys.platform.startswith("linux"):
|
||||
os_type = "linux"
|
||||
is_server = True # Linux in enterprise log forwarding contexts is typically server/node
|
||||
lin_info = get_linux_distro_info(os_release_path=os_release_path)
|
||||
distro = lin_info.get("distro", "Linux")
|
||||
distro_version = lin_info.get("distro_version", "")
|
||||
distro_id = lin_info.get("distro_id", "")
|
||||
distro_codename = lin_info.get("distro_codename", "")
|
||||
os_name = lin_info.get("pretty_name") or f"{distro} {distro_version}".strip() or "Linux"
|
||||
edition = distro_codename or distro
|
||||
build = platform.release()
|
||||
elif sys.platform == "darwin":
|
||||
os_type = "darwin"
|
||||
is_server = False
|
||||
os_name = f"macOS {platform.mac_ver()[0]}"
|
||||
else:
|
||||
os_type = sys.platform
|
||||
is_server = False
|
||||
|
||||
return {
|
||||
"os_type": os_type,
|
||||
"os_name": os_name,
|
||||
"is_server": is_server,
|
||||
"distro": distro,
|
||||
"distro_version": distro_version,
|
||||
"distro_id": distro_id,
|
||||
"distro_codename": distro_codename,
|
||||
"edition": edition,
|
||||
"build": build,
|
||||
"architecture": arch,
|
||||
"hostname": socket.gethostname(),
|
||||
"ip_addresses": get_ip_addresses()
|
||||
}
|
||||
|
||||
|
||||
def get_installed_windows_server_roles() -> List[str]:
|
||||
"""
|
||||
Detects installed Windows Server roles and major features.
|
||||
Combines authoritative PowerShell ServerManager cmdlets on Windows Server with
|
||||
resilient service & registry checks.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
|
||||
roles = set()
|
||||
|
||||
# 1. Authoritative PowerShell query on Windows Server
|
||||
try:
|
||||
ps_cmd = (
|
||||
"try { "
|
||||
"Get-WindowsFeature | Where-Object { $_.Installed -eq $true -and ($_.FeatureType -eq 'Role' -or ($_.Parent -eq '' -and $_.Installed -eq $true)) } | Select-Object -ExpandProperty DisplayName "
|
||||
"} catch { }"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=6
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout:
|
||||
for line in proc.stdout.splitlines():
|
||||
role_name = line.strip()
|
||||
if role_name and len(role_name) > 2:
|
||||
roles.add(role_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Service and Registry Inspection (Instant fallback and verification)
|
||||
ROLE_SERVICE_MAP = {
|
||||
"NTDS": "Active Directory Domain Services",
|
||||
"DNS": "DNS Server",
|
||||
"DHCPServer": "DHCP Server",
|
||||
"W3SVC": "Web Server (IIS)",
|
||||
"CertSvc": "Active Directory Certificate Services",
|
||||
"vmms": "Hyper-V",
|
||||
"TermService": "Remote Desktop Services",
|
||||
"LanmanServer": "File and Storage Services",
|
||||
"WsusService": "Windows Server Update Services (WSUS)",
|
||||
"ClusSvc": "Failover Clustering",
|
||||
"RemoteAccess": "Remote Access / DirectAccess",
|
||||
"ADAM": "Active Directory Lightweight Directory Services",
|
||||
"adfssrv": "Active Directory Federation Services",
|
||||
"IAS": "Network Policy Server (NPS)",
|
||||
"Spooler": "Print and Document Services",
|
||||
"MSMQ": "Message Queuing (MSMQ)",
|
||||
}
|
||||
|
||||
try:
|
||||
import winreg
|
||||
for svc_name, role_title in ROLE_SERVICE_MAP.items():
|
||||
try:
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, rf"SYSTEM\CurrentControlSet\Services\{svc_name}") as k:
|
||||
# Key exists; check if service is enabled (Start != 4 disabled)
|
||||
try:
|
||||
start_type = winreg.QueryValueEx(k, "Start")[0]
|
||||
if start_type in (2, 3): # 2=Auto, 3=Manual
|
||||
# For LanmanServer / Spooler / TermService, only count as dedicated role if on Server
|
||||
roles.add(role_title)
|
||||
except Exception:
|
||||
roles.add(role_title)
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clean up and normalize role names
|
||||
return sorted(list(roles))
|
||||
|
||||
|
||||
def get_installed_linux_server_roles() -> List[str]:
|
||||
"""Detects installed server roles and major service daemons on Linux hosts."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return []
|
||||
|
||||
roles = set()
|
||||
SERVICE_MAP = {
|
||||
"named": "DNS Server (BIND)",
|
||||
"bind9": "DNS Server (BIND)",
|
||||
"dnsmasq": "DNS & DHCP Server (dnsmasq)",
|
||||
"isc-dhcp-server": "DHCP Server",
|
||||
"nginx": "Web Server (Nginx)",
|
||||
"apache2": "Web Server (Apache)",
|
||||
"httpd": "Web Server (Apache)",
|
||||
"sshd": "SSH Remote Access",
|
||||
"smbd": "File Server (Samba/SMB)",
|
||||
"nfs-server": "File Server (NFS)",
|
||||
"docker": "Container Host (Docker)",
|
||||
"containerd": "Container Runtime (containerd)",
|
||||
"podman": "Container Engine (Podman)",
|
||||
"postgresql": "Database Server (PostgreSQL)",
|
||||
"mariadb": "Database Server (MariaDB)",
|
||||
"mysql": "Database Server (MySQL)",
|
||||
"postfix": "Mail Transfer Agent (Postfix)",
|
||||
"dovecot": "Mail Delivery Agent (Dovecot)",
|
||||
"haproxy": "Load Balancer (HAProxy)",
|
||||
"keepalived": "High Availability (Keepalived)",
|
||||
"ufw": "Host Firewall (UFW)",
|
||||
"firewalld": "Host Firewall (firewalld)",
|
||||
}
|
||||
|
||||
for svc, title in SERVICE_MAP.items():
|
||||
if shutil.which(svc):
|
||||
roles.add(title)
|
||||
|
||||
return sorted(list(roles))
|
||||
|
||||
|
||||
def get_installed_server_roles() -> List[str]:
|
||||
"""Cross-platform dispatcher for installed server roles and features."""
|
||||
if sys.platform == "win32":
|
||||
return get_installed_windows_server_roles()
|
||||
elif sys.platform.startswith("linux"):
|
||||
return get_installed_linux_server_roles()
|
||||
return []
|
||||
|
||||
|
||||
def get_full_system_inventory() -> Dict[str, Any]:
|
||||
"""
|
||||
Gathers comprehensive hardware, OS, network, and server role inventory.
|
||||
"""
|
||||
os_info = get_os_details()
|
||||
roles = get_installed_server_roles() if os_info.get("is_server") else []
|
||||
|
||||
# Disk capacity
|
||||
disk_total_gb = 0.0
|
||||
disk_free_gb = 0.0
|
||||
disk_pct_free = 0.0
|
||||
try:
|
||||
path = "C:\\" if sys.platform == "win32" else "/"
|
||||
total, _, free = shutil.disk_usage(path)
|
||||
disk_total_gb = round(total / (1024 ** 3), 2)
|
||||
disk_free_gb = round(free / (1024 ** 3), 2)
|
||||
disk_pct_free = round((free / total) * 100, 1) if total > 0 else 0.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Memory
|
||||
mem_total_mb = 0
|
||||
mem_percent = 0.0
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
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)):
|
||||
mem_total_mb = int(stat.ullTotalPhys / (1024 * 1024))
|
||||
mem_percent = float(stat.dwMemoryLoad)
|
||||
elif os.path.exists("/proc/meminfo"):
|
||||
tot = 0
|
||||
avail = 0
|
||||
with open("/proc/meminfo", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith("MemTotal:"):
|
||||
tot = int(line.split(":")[1].strip().split()[0])
|
||||
elif line.startswith(("MemAvailable:", "MemFree:")) and avail == 0:
|
||||
avail = int(line.split(":")[1].strip().split()[0])
|
||||
if tot > 0:
|
||||
mem_total_mb = tot // 1024
|
||||
mem_percent = round(((tot - avail) / tot) * 100, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"hostname": os_info.get("hostname"),
|
||||
"os_type": os_info.get("os_type"),
|
||||
"os_name": os_info.get("os_name"),
|
||||
"is_server": os_info.get("is_server"),
|
||||
"distro": os_info.get("distro", ""),
|
||||
"distro_version": os_info.get("distro_version", ""),
|
||||
"distro_id": os_info.get("distro_id", ""),
|
||||
"distro_codename": os_info.get("distro_codename", ""),
|
||||
"edition": os_info.get("edition"),
|
||||
"build": os_info.get("build"),
|
||||
"architecture": os_info.get("architecture"),
|
||||
"cpu_count": os.cpu_count() or 1,
|
||||
"memory_total_mb": mem_total_mb,
|
||||
"memory_percent": mem_percent,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"disk_free_gb": disk_free_gb,
|
||||
"disk_percent_free": disk_pct_free,
|
||||
"ip_addresses": os_info.get("ip_addresses", []),
|
||||
"server_roles": roles
|
||||
}
|
||||
@@ -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.6.0-rc1"
|
||||
__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
|
||||