Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8f21df03b4 | ||
|
|
4943f5be62 | ||
|
|
5a303808a3 | ||
|
|
dc1617a8f1 | ||
|
|
d331da0b13 | ||
|
|
378127c25b | ||
|
|
4970ef7c25 | ||
|
|
79a78ba1c1 | ||
|
|
14533e0427 | ||
|
|
d192b43aaf | ||
|
|
4a352337a5 | ||
|
|
2fba971aeb | ||
|
|
f605375639 | ||
|
|
f5ff8ab6cc | ||
|
|
fd6da560ed | ||
|
|
956dfc5d93 | ||
|
|
35e6a8df3e | ||
|
|
c121291dda | ||
|
|
d19bbb2ec1 | ||
|
|
e6dc82ff55 | ||
|
|
56d8516427 | ||
|
|
94ad3f9461 | ||
|
|
26a4509429 | ||
|
|
80ae42088f | ||
|
|
42f01addca | ||
|
|
a9f096ef1b | ||
|
|
26d3d59812 | ||
|
|
5fec32327f | ||
|
|
478807d873 | ||
|
|
149ba6dfec | ||
|
|
a11b05f0a9 | ||
|
|
722d2a1fec | ||
|
|
5883b78822 | ||
|
|
d79de301bf | ||
|
|
184fdc6bc6 | ||
|
|
4625b650e5 | ||
|
|
f38bbfb540 | ||
|
|
18f0286692 | ||
|
|
c9f769ef2b | ||
|
|
c01c09ecbd | ||
|
|
2afe94fb36 | ||
|
|
4650cbcafc | ||
|
|
0d613b3d22 | ||
|
|
916d3764a2 | ||
|
|
0e7d299594 | ||
|
|
2cff629e23 |
@@ -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."
|
||||
@@ -6,25 +6,30 @@ on:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '*'
|
||||
- '**'
|
||||
- 'v*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Component Tests & Pipeline Verification
|
||||
runs-on: ubuntu-latest
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
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: |
|
||||
@@ -32,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: |
|
||||
@@ -47,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,44 +0,0 @@
|
||||
name: Release Linux Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, created]
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
- '**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (optional)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
release-linux:
|
||||
name: Build & Release Linux Binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Python and Build 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
|
||||
|
||||
- name: Compile Standalone Linux Binaries
|
||||
run: |
|
||||
python3 compilation/package_dist.py --target linux
|
||||
|
||||
- name: Publish Linux Release Assets
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
python3 compilation/upload_release.py --skip-build
|
||||
@@ -1,84 +0,0 @@
|
||||
name: Release Windows Binaries & Installers
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (optional)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
release-windows:
|
||||
name: Build & Release Windows Binaries & Installers
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
continue-on-error: true
|
||||
|
||||
- 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: ${{ github.event.release.tag_name || inputs.tag || 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
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release Binaries & Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to publish assets to (default: v0.3.1)'
|
||||
required: false
|
||||
default: 'v0.3.1'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release Standalone Binaries (Linux & Windows Cross-Compilation)
|
||||
runs-on: logar
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify Builder Runner Environment & Dependencies
|
||||
run: |
|
||||
echo "=== LOGAR BUILD RUNNER ENVIRONMENT ==="
|
||||
uname -a
|
||||
python3 --version
|
||||
which makensis || echo "[!] Warning: makensis not found in PATH"
|
||||
python3 -m pip install -r compilation/requirements.txt --no-warn-script-location --break-system-packages 2>/dev/null || python3 -m pip install -r compilation/requirements.txt --no-warn-script-location || true
|
||||
python3 -c "import PyInstaller, distlib, pgpy, fastapi, uvicorn, websockets, sqlalchemy; print('[+] All core build & packaging dependencies verified.')"
|
||||
|
||||
- name: Compile Standalone Binaries (Linux & Windows)
|
||||
run: |
|
||||
python3 compilation/package_dist.py --target all
|
||||
|
||||
- name: Publish Unified Release Assets
|
||||
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: |
|
||||
python3 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/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "LOGAR-LICENSING"]
|
||||
path = LOGAR-LICENSING
|
||||
url = https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
|
||||
@@ -1,368 +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
|
||||
M_CA -->|Signs Sub-CA Cert| SUB_CA
|
||||
AUTH_WS <==>|Live WebSocket Sync| SUB_CA
|
||||
|
||||
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
|
||||
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 & 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 Root CA.
|
||||
- **Client CN Identification**: 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. Dynamic PKI Hub Engine (`src/server_enrollment.py`)
|
||||
- **Root CA**: On first run, `Server.py` creates a self-signed Root CA (`ca.crt` / `ca.key`) valid for 10 years.
|
||||
- **Server TLS Certificate**: Generated automatically with Subject Alternative Names (SANs) for `localhost`, `127.0.0.1`, server IP, and hostnames.
|
||||
- **Authority Key Identifiers**: Full compliance with OpenSSL 3.x and Python 3.12–3.14 via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` extensions.
|
||||
- **Dynamic Client Certificates**: RSA-2048 keys and X.509 client certificates are issued on the fly via the enrollment API.
|
||||
### 1. Server Hub Installation
|
||||
|
||||
### 3. Seat Accounting & Licensing
|
||||
- Stored in SQLite table `license_config`:
|
||||
- `max_seats`: Maximum concurrent active client licenses (default: 10).
|
||||
- `enrollment_secret`: Cryptographic secret required for initial client enrollment.
|
||||
- Stored in SQLite table `clients`:
|
||||
- `client_id`: Unique client identifier (machine GUID or hardware hash).
|
||||
- `hostname`, `os_type`, `cert_fingerprint`, `status` (`active` / `revoked`), `first_seen`, `last_seen`.
|
||||
- When a new client enrolls:
|
||||
- If `active_seats >= max_seats`, the hub rejects registration with `HTTP 403 (License seat limit reached)`.
|
||||
- Existing registered clients can re-enroll / renew seamlessly without consuming additional seats.
|
||||
|
||||
---
|
||||
|
||||
## 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, 22 unit tests & mTLS pipeline test
|
||||
│ ├── release-linux.yml # Linux release workflow (compiles binaries & checksums)
|
||||
│ └── release-windows.yml # Windows release workflow (compiles .exe & Inno Setup 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 **22 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Automated Releases via Gitea Actions
|
||||
## Documentation & Deep Dives
|
||||
|
||||
Releases are triggered automatically on tag push (`v*`):
|
||||
```bash
|
||||
git tag v1.0.4
|
||||
git push origin v1.0.4
|
||||
```
|
||||
Two dedicated workflows run in parallel:
|
||||
- **`release-linux.yml`** (`ubuntu-latest`): Compiles `Linux_Client.bin` and `Server.bin`, generating checksums.
|
||||
- **`release-windows.yml`** (`windows-latest`): Compiles `Win_Client.exe` and `Server.exe`, builds Inno Setup installers (`LOGAR-Client-Setup.exe`, `LOGAR-Server-Setup.exe`), and uploads all artifacts.
|
||||
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||
|
||||
* [WebUI Administrator Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||
* [System State Machines & Subsystem Lifecycles](https://gitea.eibl.tech/me0nline/LOGAR/wiki/State-Machines-and-Lifecycles)
|
||||
* [PKI Architecture & Custom X.509 Seat OIDs](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||
* [Complete REST API Specification](https://gitea.eibl.tech/me0nline/LOGAR/wiki/API-Reference)
|
||||
* [Production Deployment & Service Management](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Deployment-and-Installation)
|
||||
* [CI/CD Pipeline & Cross-Compilation Workflow](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Testing-and-Releases)
|
||||
* [Branding & Design System Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Branding-Guide)
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
# LOGAR Release v1.0.2
|
||||
# LOGAR Release Notes
|
||||
|
||||
### Changes in this Release:
|
||||
- **Warning Persistence & Immediate Error Routing**: Restructured temporal verification on the central hub so the 4-run persistence rule across the 12-hour evaluation window strictly governs `WARNING` and `INFO` events to suppress transient blips. High-severity `ERROR`, `CRITICAL`, and `FATAL` events are now promoted to `VERIFIED` immediately on their first occurrence and reported to Hermes without waiting for consecutive runs.
|
||||
- **Server Deployment Packages in `out/`**: Added comprehensive deployment guides and configuration templates for both Linux Server hub (systemd service) and Windows Server hub (NSSM service / Task Scheduler) under `out/linux_server` and `out/win_server`.
|
||||
- **Refactored Repository Layout**: Reorganized codebase by moving runtime forwarders and server hub into `src/`, compilation/release packaging utilities into `compilation/`, and all unit and pipeline verification tests into `tests/`.
|
||||
- **Dual Platform Gitea Release Automation**: Dedicated Windows (`release-windows.yml`) and Linux (`release-linux.yml`) Gitea Actions to compile native platform binaries (`Win_Client.exe` and `Server.exe` on Windows; `Linux_Client.bin` and `Server.bin` on Linux).
|
||||
- **Dedicated SHA-256 Checksums**: Release assets now include dedicated checksum files matching `[win/linux]_[client/agent]_sha256sum` (`win_client_sha256sum`, `win_agent_sha256sum`, `win_server_sha256sum`, `linux_client_sha256sum`, `linux_agent_sha256sum`, `linux_server_sha256sum`).
|
||||
- **Removed Client Filter Logic**: Removed restrictive source-level noise filtering on edge forwarders. Clients now collect and stream all candidate events from `INFO` up to `ERROR` over the lookback window instead of discarding them at the source.
|
||||
- **State Tracking & Deduplication**: Added persistent client state tracking (`client_state.json`) with journalctl cursors and Windows Event Log record numbers to guarantee that previously transmitted events are never resent.
|
||||
- **24-Hour Lookback Window**: Forwarders now scan and upload events from the last 24 hours (default `--hours 24`), skipping older entries.
|
||||
- **Lightweight Distribution Structure**: Cleaned `out/` to strictly contain deployment documentation and sample configurations.
|
||||
- **Automated Gitea CI/CD**: Integrated push testing workflow (`ci.yml`) and multi-platform release asset packaging.
|
||||
## [v0.3.5.2] - 2026-10-03
|
||||
|
||||
### 🚀 Fleet Hub Client Update Caching & Distribution
|
||||
- **Local Package Caching**: The Fleet Hub automatically caches edge client binaries and packages locally on startup and upon scheduled refresh, minimizing external upstream traffic.
|
||||
- **Edge Pull Mechanism**: Forwarder nodes retrieve validated update payloads directly from the local Fleet Hub.
|
||||
|
||||
### 🛡️ Windows Defender Firewall Automation (Fixes Issue #1)
|
||||
- **Automated Port Exemptions**: Inno Setup (`installer_server.iss`) and NSIS (`installer_server.nsi`) server installers automatically configure inbound Windows Defender Firewall rules for WebUI (`TCP 8443`) and mTLS log ingestion (`TCP 9443`).
|
||||
- **Clean Uninstallation**: Uninstallers automatically remove the created firewall rules cleanly upon server decommissioning.
|
||||
|
||||
### 📊 Forwarder Liveness, Silent Server Detection & System Telemetry (Fixes Issue #2)
|
||||
- **Silent Node & Crash Detection**: Resolved the "Silent Server Down Bug" by tracking forwarder heartbeats (`last_seen`). Hermes reporting (`GET /api/hermes/report`) automatically flags edge nodes that have gone silent for >15 minutes as `CRITICAL` `NODE_OFFLINE` alerts.
|
||||
- **Troubleshooting System Telemetry**: Cross-platform system telemetry metrics (CPU utilization %, Memory load %, and Free Disk space %) are collected and attached to client heartbeats and Hermes anomaly records without external dependencies.
|
||||
- **Benign Service Noise Suppression**: Filtered routine repetitive Windows and Linux background service lifecycle events (e.g., `sppsvc`, `edgeupdate`, `trustedinstaller`, `wuauserv`, `backgroundTaskHost`) from false-positive anomaly alert fatigue.
|
||||
|
||||
### 🔑 Dynamic Enrollment Key Rotation & WebUI Key Management
|
||||
- **Automatic Key Invalidation**: Clicking "Download Client Config" (`GET /api/client/export-profile`) automatically generates a fresh enrollment secret and invalidates previous secrets, preventing reuse of leaked keys.
|
||||
- **WebUI Reset Shared Key**: Added "🔄 Reset Shared Key" controls in the WebUI header, Fleet tab, and Export Modal, backed by `POST /api/server/reset-enrollment-secret` to rotate keys and update quick-deploy command snippets on demand.
|
||||
|
||||
### 🔒 Security Hardening & Modular Architecture
|
||||
- **Decoupled CI & Test Execution**: Clean modularization across server, client, and authority services with fully autonomous unit and integration test suites.
|
||||
- **API Robustness & Validation**: Added cryptographic CSR validation endpoints and request sanitization.
|
||||
|
||||
---
|
||||
|
||||
## [v0.3.5.1] - 2026-09-30
|
||||
|
||||
### 🗄️ Multi-Engine Storage Architecture
|
||||
- **Dynamic Backend Switching**: LOGAR Server Hub supports runtime switching between lightweight Local SQLite (WAL Mode) and Enterprise SQL servers (PostgreSQL and MySQL / MariaDB) via SQLAlchemy.
|
||||
- **Automated Schema Provisioning**: Database switching automatically verifies connectivity, provisions normalized tables (`telemetry_events`, `client_seats`, `license_config`, `admin_users`), and generates optimized query indices.
|
||||
- **Config Isolation**: Runtime backend parameters are stored persistently in `server_config.json` under `db_engine` and safely isolated from ephemeral test runs.
|
||||
|
||||
### ⚡ Ingestion Ring Buffer & Burst Protection
|
||||
- **In-Memory Queue Buffering**: Built-in ring buffer with an asynchronous background flusher decoupled from mTLS socket I/O.
|
||||
- **Cache Mode**: High-velocity log spikes are absorbed in memory without blocking incoming mTLS 1.3 frame streams.
|
||||
- **Configurable Batching**: Configurable flush interval (`buffer_flush_interval_sec`, default 2.0s) and batch size (`buffer_batch_size`, default 100).
|
||||
- **Graceful Shutdown**: Unflushed queue items are flushed and committed cleanly during server shutdown.
|
||||
- **Opt-In Security Policy**: Buffering remains an explicit administrative choice and is not auto-enabled upon license activation.
|
||||
|
||||
### 🔐 Zero-Knowledge OpenPGP Data Encryption at Rest
|
||||
- **Public-Key Envelope Encryption**: External SQL payloads (`event_data`) can be encrypted at rest using the server's X.509 RSA-2048 / OpenPGP public key with AES-256-GCM symmetric session keys.
|
||||
- **Zero-Knowledge on Database Host**: External DBAs or compromised database hosts cannot read plaintext telemetry without the server's private key.
|
||||
- **Opt-In Security Policy**: Payload encryption is strictly opt-in and configurable per compliance requirements.
|
||||
|
||||
### 📜 Dynamic Licensing Introspection & Clean Dashboard WebUI
|
||||
- **Modular Capability Introspection**: PKI X.509 extension OID `1.3.6.1.4.1.58888.1.3` and persistent license records are dynamically parsed to display active modules (`enterprise_db`, etc.) in a dedicated card on the **📜 Sub-CA & Licensing** tab.
|
||||
- **Distraction-Free Tab Navigation**: Cleaned all dashboard tab headers to remove redundant item counts and visual badges for a streamlined administrative interface.
|
||||
- **Graceful Feature Locking**: Unlicensed enterprise controls remain visible but gracefully disabled in the WebUI and rejected by backend API validation.
|
||||
|
||||
### 🛠️ Dynamic Installer Context Versioning & Streamlined CI/CD
|
||||
- **Context-Aware Installer Builds**: NSIS and Inno Setup compiler scripts dynamically derive version information directly from build context/tags (`v0.3.5.1`) without hardcoded version strings.
|
||||
- **Pre-Integrated Builder Runner**: Streamlined CI/CD workflows to rely directly on pre-installed container tools and libraries without runtime `apt`/`pip` overhead.
|
||||
|
||||
### 📖 WebUI Administrator Guide & 51 Automated Tests
|
||||
- **WebUI Administrator Guide**: Published comprehensive WebUI management guide on the [Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide) covering console access, telemetry filtering, seat management, licensing, database switching, queue tuning, zero-knowledge crypto, Hermes AI, and 2FA.
|
||||
- **Expanded Test Suite**: 51 automated unit and integration tests passing with 100% test coverage.
|
||||
|
||||
|
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=1.0.3
|
||||
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=1.0.3
|
||||
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,136 +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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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,122 +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 Root CA and verifies active licensing in SQLite.
|
||||
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
||||
- **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 & Management 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. 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 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,131 +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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 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,121 +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 Root CA and verifies active licensing in SQLite.
|
||||
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
||||
- **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 & Management 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. 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 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,445 +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 enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "linux"):
|
||||
"""Bootstraps client enrollment if certificates are missing."""
|
||||
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 os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
return True
|
||||
|
||||
print(f"[*] Bootstrapping client enrollment 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 enrolled successfully! Certificates saved to {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
|
||||
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)...")
|
||||
with get_tls_socket(server_host, server_port, cert_dir) as 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,705 +1,14 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import struct
|
||||
import socket
|
||||
import sqlite3
|
||||
import argparse
|
||||
import asyncio
|
||||
import secrets
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
# Suppress cryptography / pgpy deprecation notices for a clean terminal output
|
||||
warnings.filterwarnings("ignore")
|
||||
_CUR = os.path.dirname(__file__)
|
||||
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
|
||||
|
||||
import pgpy
|
||||
from pgpy.constants import (
|
||||
PubKeyAlgorithm,
|
||||
KeyFlags,
|
||||
HashAlgorithm,
|
||||
SymmetricKeyAlgorithm,
|
||||
CompressionAlgorithm
|
||||
)
|
||||
import ssl
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import uvicorn
|
||||
for _p in [_CUR, _ROOT]:
|
||||
if _p and _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
try:
|
||||
from src import server_enrollment as enrollment
|
||||
except ImportError:
|
||||
import server_enrollment as enrollment
|
||||
from src.hub.Server import *
|
||||
|
||||
CONFIG_FILE_NAME = "server_config.json"
|
||||
DEFAULT_DB_FILE = "logar_state.db"
|
||||
EVALUATION_WINDOW_HOURS = 12
|
||||
RUN_THRESHOLD = 4
|
||||
|
||||
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="2.0.0")
|
||||
|
||||
# Global context holding server state
|
||||
SERVER_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class ClientEnrollRequest(BaseModel):
|
||||
client_id: str
|
||||
hostname: str
|
||||
os: str
|
||||
enrollment_secret: str
|
||||
|
||||
|
||||
def generate_server_keypair(server_name: str):
|
||||
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
||||
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
||||
uid = pgpy.PGPUID.new(server_name)
|
||||
key.add_uid(
|
||||
uid,
|
||||
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
||||
hashes=[HashAlgorithm.SHA256],
|
||||
ciphers=[SymmetricKeyAlgorithm.AES256],
|
||||
compression=[CompressionAlgorithm.Uncompressed]
|
||||
)
|
||||
private_key_armored = str(key)
|
||||
public_key_armored = str(key.pubkey)
|
||||
fingerprint = str(key.pubkey.fingerprint)
|
||||
return private_key_armored, public_key_armored, fingerprint
|
||||
|
||||
|
||||
def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
||||
"""Loads existing server_config.json or creates a new one on first run."""
|
||||
if os.path.exists(config_path):
|
||||
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if "enrollment_secret" not in config:
|
||||
config["enrollment_secret"] = secrets.token_hex(24)
|
||||
if "max_seats" not in config:
|
||||
config["max_seats"] = 10
|
||||
if "cert_dir" not in config:
|
||||
config["cert_dir"] = "certs"
|
||||
if "tls_enabled" not in config:
|
||||
config["tls_enabled"] = True
|
||||
return config
|
||||
|
||||
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
||||
server_name = "LOGAR-Cloud-Hub"
|
||||
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
||||
auth_token = secrets.token_hex(24)
|
||||
enrollment_secret = secrets.token_hex(24)
|
||||
|
||||
config = {
|
||||
"server_name": server_name,
|
||||
"tcp_host": "0.0.0.0",
|
||||
"tcp_port": 9443,
|
||||
"hermes_host": "0.0.0.0",
|
||||
"hermes_port": 8443,
|
||||
"auth_token": auth_token,
|
||||
"enrollment_secret": enrollment_secret,
|
||||
"max_seats": 10,
|
||||
"cert_dir": "certs",
|
||||
"tls_enabled": True,
|
||||
"db_path": DEFAULT_DB_FILE,
|
||||
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
||||
"min_persistence_runs": RUN_THRESHOLD,
|
||||
"server_fingerprint": fingerprint,
|
||||
"public_key": public_key,
|
||||
"private_key": private_key
|
||||
}
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"[+] Successfully generated new server config and OpenPGP keypair.")
|
||||
print(f"[+] Server Encryption Fingerprint: {fingerprint}")
|
||||
print(f"[+] Saved to: {os.path.abspath(config_path)}")
|
||||
return config
|
||||
|
||||
|
||||
def create_client_config(
|
||||
server_host: str,
|
||||
server_port: int,
|
||||
output_path: str,
|
||||
config_path: str = CONFIG_FILE_NAME,
|
||||
hermes_host: Optional[str] = None,
|
||||
hermes_port: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
|
||||
server_conf = load_or_init_config(config_path)
|
||||
|
||||
client_conf = {
|
||||
"server_host": server_host,
|
||||
"server_port": server_port,
|
||||
"hermes_host": hermes_host or server_conf.get("hermes_host", "127.0.0.1"),
|
||||
"hermes_port": hermes_port or server_conf.get("hermes_port", 8443),
|
||||
"enrollment_secret": server_conf.get("enrollment_secret"),
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": server_conf["server_fingerprint"],
|
||||
"server_public_key": server_conf["public_key"],
|
||||
"auth_token": server_conf["auth_token"]
|
||||
}
|
||||
|
||||
out_dir = os.path.dirname(os.path.abspath(output_path))
|
||||
if out_dir and not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(client_conf, f, indent=2)
|
||||
|
||||
print(f"[+] Client configuration successfully written to: {os.path.abspath(output_path)}")
|
||||
print(f" - Server Target: {server_host}:{server_port}")
|
||||
print(f" - Encryption Fingerprint: {server_conf['server_fingerprint']}")
|
||||
return client_conf
|
||||
|
||||
|
||||
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10):
|
||||
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, and license quota."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS active_issues (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
site_name TEXT,
|
||||
server TEXT,
|
||||
signature TEXT,
|
||||
severity TEXT,
|
||||
message TEXT,
|
||||
os_type TEXT,
|
||||
first_seen TEXT,
|
||||
last_seen TEXT,
|
||||
run_count INTEGER,
|
||||
status TEXT,
|
||||
last_run_id TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ingest_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
site_name TEXT,
|
||||
server TEXT,
|
||||
timestamp TEXT,
|
||||
log_count INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS license_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_seats INTEGER NOT NULL DEFAULT 10,
|
||||
enrollment_secret TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
os_type TEXT NOT NULL,
|
||||
cert_fingerprint TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
if enrollment_secret:
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
|
||||
VALUES (1, ?, ?)
|
||||
""", (max_seats, enrollment_secret))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: int, min_runs: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Evaluates candidate issues against the 12-hour evaluation window and 4-run rule.
|
||||
Zero-state clients send raw candidate entries; this engine handles temporal state.
|
||||
"""
|
||||
client_server = payload.get("server", "unknown-host")
|
||||
site_name = payload.get("site_name") or (client_server.split(".", 1)[1] if "." in client_server else "default")
|
||||
logs = payload.get("logs", [])
|
||||
run_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = now.isoformat()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Record the batch run
|
||||
cursor.execute(
|
||||
"INSERT INTO ingest_runs (run_id, site_name, server, timestamp, log_count) VALUES (?, ?, ?, ?, ?)",
|
||||
(run_id, site_name, client_server, now_iso, len(logs))
|
||||
)
|
||||
|
||||
processed_count = 0
|
||||
promoted_to_verified = 0
|
||||
|
||||
for log in logs:
|
||||
severity = str(log.get("severity", "WARNING")).upper()
|
||||
# Edge forwarder filter safeguard: retain INFO to ERROR / CRITICAL; strip verbose debug noise
|
||||
if severity in ["DEBUG", "TRACE"]:
|
||||
continue
|
||||
|
||||
# Errors are always passed immediately; the 4-run rule only concerns warnings
|
||||
is_error = severity in ["ERROR", "CRITICAL", "FATAL"]
|
||||
|
||||
signature = log.get("signature", "unknown")
|
||||
server = log.get("server", client_server)
|
||||
message = log.get("message", "")
|
||||
os_type = log.get("os_type", "unknown")
|
||||
fp = f"{site_name}:{server}:{signature}"
|
||||
|
||||
cursor.execute(
|
||||
"SELECT run_count, first_seen, last_seen, status, last_run_id FROM active_issues WHERE fingerprint = ?",
|
||||
(fp,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
run_count, first_seen_str, last_seen_str, current_status, last_run_id = row
|
||||
try:
|
||||
last_seen_dt = datetime.fromisoformat(last_seen_str)
|
||||
except Exception:
|
||||
last_seen_dt = now
|
||||
|
||||
# 12-hour evaluation window expiry check
|
||||
if (now - last_seen_dt) > timedelta(hours=window_hours):
|
||||
# Window elapsed: reset to new cycle
|
||||
new_runs = 1
|
||||
new_first_seen = now_iso
|
||||
new_status = "VERIFIED" if is_error else "TRANSIENT"
|
||||
else:
|
||||
# Same run guard: only increment count once per distinct run batch
|
||||
if last_run_id != run_id:
|
||||
new_runs = run_count + 1
|
||||
else:
|
||||
new_runs = run_count
|
||||
new_first_seen = first_seen_str
|
||||
# 4-run rule applies to warnings; errors are always passed immediately as VERIFIED
|
||||
new_status = "VERIFIED" if (is_error or new_runs >= min_runs) else "TRANSIENT"
|
||||
|
||||
if new_status == "VERIFIED" and current_status != "VERIFIED":
|
||||
promoted_to_verified += 1
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE active_issues
|
||||
SET run_count = ?, last_seen = ?, first_seen = ?, status = ?, last_run_id = ?, message = ?, severity = ?
|
||||
WHERE fingerprint = ?
|
||||
""", (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp))
|
||||
else:
|
||||
initial_status = "VERIFIED" if (is_error or 1 >= min_runs) else "TRANSIENT"
|
||||
if initial_status == "VERIFIED":
|
||||
promoted_to_verified += 1
|
||||
cursor.execute("""
|
||||
INSERT INTO active_issues
|
||||
(fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (fp, site_name, server, signature, severity, message, os_type, now_iso, now_iso, 1, initial_status, run_id))
|
||||
|
||||
processed_count += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"run_id": run_id,
|
||||
"processed": processed_count,
|
||||
"promoted_verified": promoted_to_verified
|
||||
}
|
||||
|
||||
|
||||
def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext:
|
||||
"""Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS)."""
|
||||
ca_file = os.path.join(cert_dir, "ca.crt")
|
||||
srv_cert = os.path.join(cert_dir, "server.crt")
|
||||
srv_key = os.path.join(cert_dir, "server.key")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
|
||||
ctx.load_verify_locations(cafile=ca_file)
|
||||
ctx.verify_mode = ssl.CERT_REQUIRED
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
return ctx
|
||||
|
||||
|
||||
async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||
"""
|
||||
mTLS TCP socket handler.
|
||||
Extracts client CN (client_id) from the TLS handshake,
|
||||
validates active license status in SQLite, updates last_seen,
|
||||
reads 4-byte big-endian length-prefixed JSON payload,
|
||||
and ingests candidate logs into the temporal evaluation engine.
|
||||
"""
|
||||
client_id = None
|
||||
ssl_obj = writer.get_extra_info("ssl_object")
|
||||
if ssl_obj:
|
||||
peercert = ssl_obj.getpeercert()
|
||||
if peercert and "subject" in peercert:
|
||||
for rdn in peercert["subject"]:
|
||||
for key, val in rdn:
|
||||
if key == "commonName":
|
||||
client_id = val
|
||||
break
|
||||
|
||||
# If mTLS is enforced, verify client in accounting database
|
||||
if SERVER_STATE.get("tls_enabled", False):
|
||||
if not client_id:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,))
|
||||
row = c.fetchone()
|
||||
if not row or row[0] != "active":
|
||||
conn.close()
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
c.execute("UPDATE clients SET last_seen = CURRENT_TIMESTAMP WHERE client_id = ?", (client_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
# Read 4-byte length prefix
|
||||
length_bytes = await reader.readexactly(4)
|
||||
length = struct.unpack(">I", length_bytes)[0]
|
||||
if length <= 0 or length > 10 * 1024 * 1024: # 10MB limit
|
||||
raise ValueError(f"Invalid frame size: {length}")
|
||||
|
||||
payload_bytes = await reader.readexactly(length)
|
||||
raw_payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
|
||||
# Support both direct JSON payload over mTLS and legacy OpenPGP envelope
|
||||
if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"):
|
||||
pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"])
|
||||
priv_key = SERVER_STATE["private_key_obj"]
|
||||
decrypted_obj = priv_key.decrypt(pgp_msg)
|
||||
log_payload = json.loads(decrypted_obj.message)
|
||||
else:
|
||||
log_payload = raw_payload
|
||||
|
||||
# Attach authenticated client_id if not present
|
||||
if client_id and "server" not in log_payload:
|
||||
log_payload["server"] = client_id
|
||||
|
||||
# Ingest and apply 12h window / 4-run rule
|
||||
res = process_ingested_logs(
|
||||
log_payload,
|
||||
db_path=SERVER_STATE["config"]["db_path"],
|
||||
window_hours=SERVER_STATE["config"]["evaluation_window_hours"],
|
||||
min_runs=SERVER_STATE["config"]["min_persistence_runs"]
|
||||
)
|
||||
|
||||
resp_bytes = json.dumps(res).encode("utf-8")
|
||||
writer.write(struct.pack(">I", len(resp_bytes)) + resp_bytes)
|
||||
await writer.drain()
|
||||
|
||||
except Exception as e:
|
||||
err = json.dumps({"status": "error", "message": str(e)}).encode("utf-8")
|
||||
try:
|
||||
writer.write(struct.pack(">I", len(err)) + err)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/api/client/enroll")
|
||||
def enroll_client(req: ClientEnrollRequest):
|
||||
"""
|
||||
Enrolls an edge client by validating the enrollment secret,
|
||||
checking license seat limits, issuing a signed client certificate + key,
|
||||
and recording the client in the SQLite accounting database.
|
||||
"""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# 1. Validate enrollment secret against license_config
|
||||
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
||||
|
||||
expected_secret, max_seats = row
|
||||
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
||||
|
||||
ca_cert = SERVER_STATE.get("ca_cert")
|
||||
ca_key = SERVER_STATE.get("ca_key")
|
||||
ca_cert_pem = SERVER_STATE.get("ca_cert_pem")
|
||||
|
||||
if not ca_cert or not ca_key:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=500, detail="Root CA not loaded on server")
|
||||
|
||||
# 2. Check if client_id already registered
|
||||
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
|
||||
client_row = c.fetchone()
|
||||
if client_row:
|
||||
if client_row[0] == "revoked":
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
|
||||
|
||||
# Re-issue for existing active client
|
||||
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||
c.execute("""
|
||||
UPDATE clients
|
||||
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = CURRENT_TIMESTAMP
|
||||
WHERE client_id = ?
|
||||
""", (req.hostname, req.os, fp, req.client_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})")
|
||||
return {
|
||||
"ca_cert": ca_cert_pem,
|
||||
"client_cert": client_cert_pem,
|
||||
"client_key": client_key_pem
|
||||
}
|
||||
|
||||
# 3. New client: check seat limits
|
||||
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
|
||||
active_count = c.fetchone()[0]
|
||||
if active_count >= max_seats:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="License seat limit reached")
|
||||
|
||||
# 4. Issue signed cert + key
|
||||
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||
c.execute("""
|
||||
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status)
|
||||
VALUES (?, ?, ?, ?, 'active')
|
||||
""", (req.client_id, req.hostname, req.os, fp))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
|
||||
|
||||
return {
|
||||
"ca_cert": ca_cert_pem,
|
||||
"client_cert": client_cert_pem,
|
||||
"client_key": client_key_pem
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/clients")
|
||||
def list_clients():
|
||||
"""Returns all registered clients and license seat usage."""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
|
||||
lic_row = c.fetchone()
|
||||
max_seats = lic_row[0] if lic_row else 10
|
||||
|
||||
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
clients = [
|
||||
{
|
||||
"client_id": r[0],
|
||||
"hostname": r[1],
|
||||
"os_type": r[2],
|
||||
"cert_fingerprint": r[3],
|
||||
"status": r[4],
|
||||
"first_seen": r[5],
|
||||
"last_seen": r[6]
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
active_count = sum(1 for cl in clients if cl["status"] == "active")
|
||||
return {
|
||||
"active_seats": active_count,
|
||||
"max_seats": max_seats,
|
||||
"clients": clients
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/hermes/report")
|
||||
def get_verified_anomalies_for_hermes():
|
||||
"""
|
||||
Ingestion endpoint for Hermes agentic workflows.
|
||||
Returns only verified anomalies that have satisfied the 4-run persistence rule
|
||||
within the active 12-hour evaluation window. Transient blips (< 4 runs) are excluded.
|
||||
"""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
||||
FROM active_issues
|
||||
WHERE status = 'VERIFIED'
|
||||
ORDER BY last_seen DESC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
report = []
|
||||
for r in rows:
|
||||
report.append({
|
||||
"fingerprint": r[0],
|
||||
"site": r[1],
|
||||
"server": r[2],
|
||||
"signature": r[3],
|
||||
"severity": r[4],
|
||||
"message": r[5],
|
||||
"os_type": r[6],
|
||||
"first_seen": r[7],
|
||||
"last_seen": r[8],
|
||||
"consecutive_runs": r[9],
|
||||
"evaluation_window": f"{SERVER_STATE['config']['evaluation_window_hours']}h",
|
||||
"verified": True,
|
||||
"status": r[10]
|
||||
})
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@app.get("/api/hermes/all")
|
||||
def get_all_issues():
|
||||
"""Diagnostic endpoint to inspect both transient candidate blips and verified anomalies."""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
||||
FROM active_issues
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"fingerprint": r[0],
|
||||
"site": r[1],
|
||||
"server": r[2],
|
||||
"signature": r[3],
|
||||
"severity": r[4],
|
||||
"message": r[5],
|
||||
"os_type": r[6],
|
||||
"first_seen": r[7],
|
||||
"last_seen": r[8],
|
||||
"run_count": r[9],
|
||||
"status": r[10]
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"server_name": SERVER_STATE["config"]["server_name"],
|
||||
"fingerprint": SERVER_STATE["config"]["server_fingerprint"],
|
||||
"tcp_port": SERVER_STATE["config"]["tcp_port"],
|
||||
"hermes_port": SERVER_STATE["config"]["hermes_port"],
|
||||
"tls_enabled": SERVER_STATE.get("tls_enabled", False)
|
||||
}
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Runs the mTLS TCP socket listener and the Hermes REST API concurrently."""
|
||||
config = SERVER_STATE["config"]
|
||||
tcp_host = config["tcp_host"]
|
||||
tcp_port = int(config["tcp_port"])
|
||||
hermes_host = config["hermes_host"]
|
||||
hermes_port = int(config["hermes_port"])
|
||||
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
||||
|
||||
# Start mTLS / TCP Socket Server
|
||||
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx)
|
||||
mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP"
|
||||
print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}")
|
||||
|
||||
# Start FastAPI / Uvicorn server for Hermes & Enrollment
|
||||
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
|
||||
uv_server = uvicorn.Server(uv_config)
|
||||
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
|
||||
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
|
||||
|
||||
await asyncio.gather(
|
||||
tcp_server.serve_forever(),
|
||||
uv_server.serve()
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="LOGAR Cloud Hub & TCP Socket Ingestion Server")
|
||||
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to server_config.json")
|
||||
parser.add_argument("--create-client-config", action="store_true", help="Generate a client config with encryption-only fingerprint and server address")
|
||||
parser.add_argument("--client-out", default="client_config.json", help="Output file path for generated client config")
|
||||
parser.add_argument("--server-host", default="127.0.0.1", help="Server address to embed in client config")
|
||||
parser.add_argument("--server-port", type=int, default=None, help="TCP port to embed in client config")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_or_init_config(args.config)
|
||||
init_db(
|
||||
config["db_path"],
|
||||
enrollment_secret=config.get("enrollment_secret"),
|
||||
max_seats=config.get("max_seats", 10)
|
||||
)
|
||||
|
||||
# Initialize dynamic PKI (Root CA and Server TLS Certificate)
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
||||
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
|
||||
ca_cert, ca_key,
|
||||
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
||||
cert_dir=cert_dir
|
||||
)
|
||||
|
||||
# Initialize mTLS SSLContext if enabled
|
||||
ssl_ctx = None
|
||||
if config.get("tls_enabled", True):
|
||||
ssl_ctx = init_mtls_server_context(cert_dir=cert_dir)
|
||||
|
||||
# Load OpenPGP private key into memory (legacy fallback)
|
||||
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
||||
SERVER_STATE["config"] = config
|
||||
SERVER_STATE["private_key_obj"] = priv_key_obj
|
||||
SERVER_STATE["ca_cert"] = ca_cert
|
||||
SERVER_STATE["ca_key"] = ca_key
|
||||
SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||
SERVER_STATE["ssl_ctx"] = ssl_ctx
|
||||
SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True)
|
||||
|
||||
if args.create_client_config:
|
||||
port = args.server_port or config["tcp_port"]
|
||||
create_client_config(
|
||||
server_host=args.server_host,
|
||||
server_port=port,
|
||||
output_path=args.client_out,
|
||||
config_path=args.config
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
print("=" * 60)
|
||||
print(f" LOGAR Server Hub: {config['server_name']}")
|
||||
print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}")
|
||||
print(f" License Quota: {config.get('max_seats', 10)} Active Seats")
|
||||
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
|
||||
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | 4-Run Rule: Warnings | Immediate Pass: Errors")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
asyncio.run(run_server())
|
||||
except KeyboardInterrupt:
|
||||
print("\n[!] Server shutting down.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,412 +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")
|
||||
_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)
|
||||
|
||||
try:
|
||||
import win32evtlog
|
||||
except ImportError:
|
||||
win32evtlog = None
|
||||
from src.clients.Win_Client import *
|
||||
|
||||
CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "windows"):
|
||||
"""Bootstraps client enrollment if certificates are missing."""
|
||||
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 os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
return True
|
||||
|
||||
print(f"[*] Bootstrapping client enrollment 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 enrolled successfully! Certificates saved to {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
|
||||
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)...")
|
||||
with get_tls_socket(server_host, server_port, cert_dir) as 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__":
|
||||
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)
|
||||
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", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||
result = None
|
||||
|
||||
if last_cursor:
|
||||
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
|
||||
try:
|
||||
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
|
||||
if res.returncode == 0:
|
||||
result = res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result is None:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError:
|
||||
# journalctl not present, attempt fallback to /var/log/syslog or messages
|
||||
return self._collect_syslog_fallback(hours=hours)
|
||||
except Exception as e:
|
||||
print(f"[!] Error running journalctl: {e}")
|
||||
return []
|
||||
|
||||
if not result or result.returncode != 0:
|
||||
return []
|
||||
|
||||
logs = []
|
||||
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
|
||||
|
||||
newest_cursor = None
|
||||
newest_timestamp_us = last_timestamp_us
|
||||
collected_cursors = []
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line_str)
|
||||
entry_cursor = entry.get("__CURSOR")
|
||||
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
|
||||
|
||||
entry_ts_us = 0.0
|
||||
if entry_ts_us_raw:
|
||||
try:
|
||||
entry_ts_us = float(entry_ts_us_raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 1. Skip entries older than lookback window
|
||||
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
|
||||
continue
|
||||
|
||||
# 2. Skip already sent events
|
||||
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
|
||||
continue
|
||||
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
|
||||
continue
|
||||
|
||||
# Advance tracking
|
||||
if entry_cursor:
|
||||
newest_cursor = entry_cursor
|
||||
collected_cursors.append(entry_cursor)
|
||||
if entry_ts_us > newest_timestamp_us:
|
||||
newest_timestamp_us = entry_ts_us
|
||||
|
||||
priority = int(entry.get("PRIORITY", "6"))
|
||||
if priority > 6:
|
||||
continue
|
||||
|
||||
if priority <= 3:
|
||||
sev = "ERROR"
|
||||
elif priority in (4, 5):
|
||||
sev = "WARNING"
|
||||
else:
|
||||
sev = "INFO"
|
||||
|
||||
logs.append({
|
||||
"server": self.machine_id,
|
||||
"os_type": "linux",
|
||||
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
||||
"severity": sev,
|
||||
"message": entry.get("MESSAGE", "")[:2048]
|
||||
})
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if self.state is not None:
|
||||
self.state["new_last_cursor"] = newest_cursor or last_cursor
|
||||
self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
|
||||
self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
|
||||
# Also update direct keys for immediate server-side ingestion
|
||||
self.state["last_cursor"] = self.state["new_last_cursor"]
|
||||
self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"]
|
||||
self.state["sent_cursors"] = self.state["new_sent_cursors"]
|
||||
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return logs
|
||||
|
||||
def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||
"""Fallback log reader for non-systemd Linux environments."""
|
||||
candidates = ["/var/log/syslog", "/var/log/messages"]
|
||||
target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None)
|
||||
if not target:
|
||||
return []
|
||||
|
||||
logs = []
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()[-500:] # Read recent 500 lines
|
||||
for line in lines:
|
||||
line_str = line.strip()
|
||||
if not line_str:
|
||||
continue
|
||||
sev = "INFO"
|
||||
lower_l = line_str.lower()
|
||||
if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]):
|
||||
sev = "ERROR"
|
||||
elif any(w in lower_l for w in ["warn", "notice"]):
|
||||
sev = "WARNING"
|
||||
|
||||
logs.append({
|
||||
"server": self.machine_id,
|
||||
"os_type": "linux",
|
||||
"signature": "syslog",
|
||||
"severity": sev,
|
||||
"message": line_str[:2048]
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return logs
|
||||
|
||||
|
||||
def get_log_collector(
|
||||
os_type: Optional[str] = None,
|
||||
state: Optional[dict] = None,
|
||||
machine_id: Optional[str] = None,
|
||||
channels: Optional[List[str]] = None
|
||||
) -> BaseLogCollector:
|
||||
"""
|
||||
Factory helper that returns the appropriate BaseLogCollector instance.
|
||||
If os_type is omitted, auto-detects based on sys.platform.
|
||||
"""
|
||||
target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux")
|
||||
if target_os == "windows":
|
||||
chans = channels or (["Application", "System"] if channels is None else ["Application"])
|
||||
return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans)
|
||||
else:
|
||||
return LinuxLogCollector(state=state, machine_id=machine_id)
|
||||
@@ -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
|
||||