Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cfc633c398 | ||
|
|
d61e343fbe | ||
|
|
4a446a2e73 | ||
|
|
b24108a788 | ||
|
|
1c985c85c8 | ||
|
|
249b754423 | ||
|
|
16faad063d | ||
|
|
c705be57eb | ||
|
|
3d6a2b86d6 | ||
|
|
1d0845b548 | ||
|
|
11f16ed8e1 | ||
|
|
8cb3089e8e | ||
|
|
5bbb56b4c3 | ||
|
|
a04d9bac9f | ||
|
|
8813d2d865 | ||
|
|
7e9f7a56f8 | ||
|
|
99920ef395 | ||
|
|
ccb23d65e5 | ||
|
|
fb9ef6e6cb | ||
|
|
491d2b1194 | ||
|
|
e83a5b3e0f | ||
|
|
0901ccb3eb | ||
|
|
cb4c763e0e | ||
|
|
84579d8719 | ||
|
|
4e2242e0ae |
@@ -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,33 +6,38 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '*'
|
- '*'
|
||||||
|
- '**'
|
||||||
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Component Tests & Pipeline Verification
|
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:
|
steps:
|
||||||
- name: Checkout Code
|
- 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: |
|
run: |
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
python3 --version
|
||||||
apt-get update -y
|
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
|
||||||
apt-get install -y python3 python3-pip python3-venv curl
|
python3 -c "import pgpy, fastapi, uvicorn, websockets, sqlalchemy; print('[+] All required dependencies are verified.')"
|
||||||
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
|
|
||||||
|
|
||||||
- name: Verify Python Syntax
|
- name: Verify Python Syntax
|
||||||
run: |
|
run: |
|
||||||
python3 -m py_compile src/Server.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
python3 -m py_compile src/Server.py src/server_enrollment.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
||||||
|
|
||||||
- name: Run Component Unit Tests
|
- name: Run Component Unit Tests
|
||||||
run: |
|
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
|
- name: Run End-to-End Pipeline Integration Test
|
||||||
run: |
|
run: |
|
||||||
@@ -47,10 +52,10 @@ jobs:
|
|||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
echo "[*] Server launched in background with PID $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
|
READY=0
|
||||||
for i in $(seq 1 20); do
|
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."
|
echo "[+] LOGAR Server is ready after ${i}s."
|
||||||
READY=1
|
READY=1
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,43 +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,66 +0,0 @@
|
|||||||
name: Release Windows Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published, created]
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (optional)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-windows:
|
|
||||||
name: Build & Release Windows Binaries
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up 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 -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: 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,25 +9,39 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
out/
|
||||||
*.spec
|
*.spec
|
||||||
*.exe
|
*.exe
|
||||||
|
!compilation/nssm.exe
|
||||||
*.bin
|
*.bin
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
*.nsh
|
||||||
|
*.iss.tmp
|
||||||
|
version.nsh
|
||||||
|
version.iss
|
||||||
|
|
||||||
# Database files & state
|
# Database files & state
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
client_state.json
|
|
||||||
*.tmp
|
|
||||||
|
|
||||||
# Live configuration with generated private keys & tokens (samples are tracked)
|
# Live configuration with generated private keys & tokens (samples are tracked)
|
||||||
server_config.json
|
server_config.json
|
||||||
client_config.json
|
client_config.json
|
||||||
|
client_state.json
|
||||||
|
local_collector_state.json
|
||||||
|
certs/
|
||||||
|
*.bak
|
||||||
test_*.json
|
test_*.json
|
||||||
test_*.db
|
test_*.db
|
||||||
|
test_pipeline_master_ca/
|
||||||
|
test_pipeline_certs/
|
||||||
|
master_ca_certs/
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
runner/.env
|
||||||
|
updates/
|
||||||
|
|
||||||
# IDE & OS files
|
# IDE & OS files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "LOGAR-LICENSING"]
|
||||||
|
path = LOGAR-LICENSING
|
||||||
|
url = https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
|
||||||
@@ -1,387 +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 OpenPGP encryption, authenticated TCP streaming, 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
|
## Key Capabilities
|
||||||
1. [Core Philosophy](#core-philosophy)
|
|
||||||
2. [Architecture & Data Flow](#architecture--data-flow)
|
- **Zero-State Edge Forwarders**: Windows (`Win_Client`) and Linux (`Linux_Client`) agents maintain zero local databases, zero cache files, and minimal memory footprint.
|
||||||
3. [Security & Cryptographic Model](#security--cryptographic-model)
|
- **Mutual TLS 1.3 (`:9443`)**: Enforces bidirectional cryptographic identity verification (`ssl.CERT_REQUIRED`) for all streaming log frames.
|
||||||
4. [Cloud-Side Temporal Persistence & 4-Run Rule](#cloud-side-temporal-persistence--4-run-rule)
|
- **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.
|
||||||
5. [Agentic Hermes Integration](#agentic-hermes-integration)
|
- **Fleet Auto-Update & Binary Distribution**: Centralized local binary distribution point with SHA-256 integrity verification, automated client version negotiation, and background pull orchestrator.
|
||||||
6. [Dynamic Machine & Domain Identification](#dynamic-machine--domain-identification)
|
- **Multi-Engine Storage Backend**: Switch seamlessly between lightweight local SQLite (WAL mode) and external enterprise databases (PostgreSQL, MySQL, MariaDB) with automated schema migration.
|
||||||
7. [Repository & Shippables Structure](#repository--shippables-structure)
|
- **High-Throughput Ring Buffer**: In-memory ingestion queue with configurable batch flushing to eliminate disk I/O bottlenecks during peak traffic bursts.
|
||||||
8. [Getting Started & Installation](#getting-started--installation)
|
- **Zero-Knowledge Data Encryption**: End-to-end payload encryption at rest using server X.509 RSA-2048 / OpenPGP keys for external SQL databases.
|
||||||
9. [Running Tests](#running-tests)
|
- **12-Hour Temporal Persistence**: Evaluates anomalies over a rolling 12-hour temporal window, tracking occurrence velocity and first/last seen metrics.
|
||||||
10. [Automated Releases via Gitea Actions](#automated-releases-via-gitea-actions)
|
- **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
|
## System Architecture
|
||||||
|
|
||||||
### 1. Edge Thinness & Zero State
|
|
||||||
Site agents running on Windows and Linux act strictly as lightweight forwarders:
|
|
||||||
- **No Local Database**: Clients maintain zero state and no local SQLite or cache files.
|
|
||||||
- **Source-Level Filtering**: Agents upload candidate entries spanning from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), while stripping verbose debugging noise (`DEBUG`, audit entries) and skipping any entries older than 24 hours.
|
|
||||||
- **End-to-End Encryption**: Logs are encrypted using the server's OpenPGP public key before leaving the edge node.
|
|
||||||
- **Secure TCP Sockets**: Ingestion occurs over low-overhead authenticated TCP sockets rather than bulky HTTP/HTTPS endpoints.
|
|
||||||
|
|
||||||
### 2. Cloud-Side Temporal Persistence & Severity Routing
|
|
||||||
The central Python/TCP hub handles the heavy lifting:
|
|
||||||
- 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` level issues must persist across **at least 4 consecutive runs / cycles** within the 12-hour window to be confirmed as genuine anomalies, automatically filtering out transient 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 human engineers manually diving through noisy 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 team notifications.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture & Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph Edge Nodes [State-Tracking Edge Forwarders]
|
subgraph Authority [Master License Authority - logar.eibl.tech]
|
||||||
W[Win_Client.py / Win_Client.exe / Win_Client.pyz<br/>Windows Event Log Application]
|
M_CA[Master Root CA]
|
||||||
L[Linux_Client.py / Linux_Client.bin<br/>systemd journalctl -p info]
|
AUTH_WS[WebSocket Licensing Hub]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Security Layer [Security & Framing]
|
subgraph ServerHub [LOGAR Server Hub]
|
||||||
E[OpenPGP Payload Encryption<br/>Server Public Key & Fingerprint]
|
SUB_CA[Subordinate CA]
|
||||||
S[Length-Prefixed Framing<br/>4-byte Big-Endian + Auth Envelope]
|
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
|
end
|
||||||
|
|
||||||
subgraph Cloud Hub [LOGAR Central Server Hub]
|
subgraph EdgeNodes [Heterogeneous Edge Forwarders]
|
||||||
TCP[Authenticated TCP Listener<br/>Port 9443]
|
WIN[Win_Client.exe<br/>Windows Event Logs]
|
||||||
DEC[OpenPGP Decryption<br/>Server Private Key]
|
LIN[Linux_Client.bin<br/>systemd journalctl]
|
||||||
DB[(SQLite Persistence<br/>active_issues & ingest_runs)]
|
|
||||||
RULE{12h Window &<br/>4-Run Rule}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Agentic Reporting [Downstream Integration]
|
subgraph Downstream [Hermes AI Layer]
|
||||||
API[FastAPI / Uvicorn Reporting<br/>Port 8443]
|
HERMES[Hermes AI Engine]
|
||||||
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
W --> E
|
M_CA -->|Signs Sub-CA Cert| SUB_CA
|
||||||
L --> E
|
AUTH_WS <==>|Live WebSocket Sync| SUB_CA
|
||||||
E --> S
|
|
||||||
S -->|TCP Stream| TCP
|
WIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
TCP --> DEC
|
LIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
DEC --> RULE
|
WIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
RULE --> DB
|
LIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
DB --> API
|
PROV_API ==>|3. GET /api/hermes/report| HERMES
|
||||||
API --> HERMES
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security & Cryptographic Model
|
## Quick Start & Automated Installers
|
||||||
|
|
||||||
### Pure-Python OpenPGP (RFC 4880)
|
LOGAR provides automated production service installers for both Linux (`systemd`) and Windows (`services.msc` via NSSM):
|
||||||
- **Zero OS Binary Dependency**: Utilizes `pgpy` and `cryptography` in pure Python. **No native GnuPG or `gpg` binary installation is required** on the server, Windows nodes, or Linux nodes.
|
|
||||||
- **First-Run Automatic Key Generation**: On the first launch, if `server_config.json` is missing, `Server.py` automatically generates:
|
### 1. Server Hub Installation
|
||||||
- An OpenPGP RSA 2048 keypair with encryption-only usage flags.
|
|
||||||
- An armored private key (`private_key`) and public key (`public_key`).
|
- **Linux (Automated systemd Installer)**:
|
||||||
- A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
|
||||||
- A cryptographically random authentication secret token (`auth_token`).
|
|
||||||
- **Client Configuration Exporter**:
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
sudo ./compilation/install_linux_server.sh
|
||||||
```
|
```
|
||||||
Produces an anonymous client config containing only the server socket coordinates, authentication token, and the encryption-only public key & fingerprint.
|
*Deploys standalone `Server.bin` to `/opt/logar-server`, initializes PKI, and enables `logar-server.service`.*
|
||||||
- **Socket Protocol Framing**:
|
|
||||||
- `[4 bytes big-endian unsigned int]` : Total envelope length.
|
- **Windows (Inno Setup / NSIS Installer)**:
|
||||||
- `[JSON Envelope]` :
|
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.
|
||||||
```json
|
|
||||||
{
|
#### Default WebUI Management Credentials
|
||||||
"auth_token": "<SECRET_TOKEN>",
|
Once installed or executed, access the web console:
|
||||||
"timestamp": "2026-09-03T...",
|
- **URL**: `http://<server-ip>:8443/webui`
|
||||||
"encrypted_payload": "-----BEGIN PGP MESSAGE-----\n..."
|
- **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>"
|
||||||
```
|
```
|
||||||
- Unauthorized clients or invalid authentication tokens are rejected immediately.
|
*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).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cloud-Side Temporal Persistence & 4-Run Rule
|
## Repository Structure
|
||||||
|
|
||||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
|
||||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
|
||||||
- **12-Hour Evaluation Window**:
|
|
||||||
- When an issue is observed, the hub compares `(now - last_seen)`.
|
|
||||||
- If more than 12 hours have passed since the issue was last recorded, the previous window is expired and the cycle resets to `run_count = 1`.
|
|
||||||
- **4-Run Rule for Warnings**:
|
|
||||||
- The 4-run persistence threshold specifically applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
|
||||||
- For each distinct run batch, `run_count` increments.
|
|
||||||
- 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 Integration
|
|
||||||
|
|
||||||
The server hub serves a REST reporting API (default port `8443`):
|
|
||||||
|
|
||||||
### `GET /api/hermes/report`
|
|
||||||
Returns all **verified anomalies** (immediate critical errors and warnings verified after 4 consecutive runs within the 12-hour evaluation window):
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "corp.internal:web-app-01.corp.internal:NginxWorkerCrash",
|
|
||||||
"site": "corp.internal",
|
|
||||||
"server": "web-app-01.corp.internal",
|
|
||||||
"signature": "NginxWorkerCrash",
|
|
||||||
"severity": "ERROR",
|
|
||||||
"message": "Worker process 4120 terminated with signal 11",
|
|
||||||
"os_type": "linux",
|
|
||||||
"first_seen": "2026-09-03T09:00:00+00:00",
|
|
||||||
"last_seen": "2026-09-03T21:00:00+00:00",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"evaluation_window": "12h",
|
|
||||||
"verified": true,
|
|
||||||
"status": "VERIFIED"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/hermes/all`
|
|
||||||
Diagnostic endpoint listing all active issues (both `TRANSIENT` candidate blips and `VERIFIED` anomalies).
|
|
||||||
|
|
||||||
### `GET /health`
|
|
||||||
Returns hub health, encryption fingerprint, and listener ports.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dynamic Machine & Domain Identification
|
|
||||||
|
|
||||||
Client configurations intentionally contain **no 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 variable `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`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository & Shippables Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
LOGAR/
|
LOGAR/
|
||||||
├── .gitea/
|
├── assets/ # Official vector SVGs, PNGs, ICOs, and branding bitmaps
|
||||||
│ └── workflows/
|
│ ├── logo.svg # Master vector emblem
|
||||||
│ ├── ci.yml # Continuous Integration automated test suite (runs on every push)
|
│ ├── logo_transparent.svg # Transparent WebUI emblem
|
||||||
│ ├── release-linux.yml # Linux release workflow (compiles Server.bin, Linux_Client.bin, checksums)
|
│ ├── logo_banner.svg # Full brand banner
|
||||||
│ └── release-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums)
|
│ ├── logo.ico / favicon.ico # Application & web icons
|
||||||
├── compilation/ # Build, packaging, and release automation tools
|
│ └── wizard_*.bmp # Installer dialog bitmaps
|
||||||
│ ├── package_dist.py # Multi-platform standalone binary packaging script
|
├── .gitea/workflows/ # CI/CD pipelines (ci.yml, release.yml)
|
||||||
│ ├── requirements.txt # Unified project dependencies
|
├── compilation/ # Build scripts, Linux installers & Inno Setup scripts
|
||||||
│ └── upload_release.py # Direct Gitea REST API release asset publisher
|
│ ├── generate_branding_assets.py # Asset & bitmap generator
|
||||||
├── src/ # Core application source modules
|
│ ├── install_linux_client.sh # Linux client systemd auto-installer
|
||||||
│ ├── __init__.py
|
│ ├── install_linux_server.sh # Linux server systemd auto-installer
|
||||||
│ ├── Server.py # Central TCP server and Hermes API
|
│ ├── 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
|
│ ├── Win_Client.py # Windows edge forwarder
|
||||||
│ └── Linux_Client.py # Linux edge forwarder
|
│ └── Linux_Client.py # Linux edge forwarder
|
||||||
├── tests/ # Automated test suites
|
├── tests/ # Automated test suites (42 unit & integration tests)
|
||||||
│ ├── test_linux_client.py # Linux client unit tests
|
├── LOGAR-LICENSING/ # Standalone Master License Authority (Git submodule)
|
||||||
│ ├── test_pipeline.py # End-to-end integration test
|
├── RELEASE_NOTES.md # Version history and security changelog
|
||||||
│ ├── test_server.py # Server unit tests
|
└── README.md # Repository overview
|
||||||
│ └── test_win_client.py # Windows client unit tests
|
|
||||||
├── .gitignore # Ignore venv, caches, DBs, and private keys
|
|
||||||
├── README.md # Comprehensive documentation
|
|
||||||
├── RELEASE_NOTES.md # Release history and changelog
|
|
||||||
├── server_config.sample.json # Central server sample configuration
|
|
||||||
└── out/ # Standalone deployment documentation & sample configs
|
|
||||||
├── linux_server/
|
|
||||||
│ ├── README.md # Linux systemd service installation & hub guide
|
|
||||||
│ └── server_config.sample.json # Reference server configuration
|
|
||||||
├── win_server/
|
|
||||||
│ ├── README.md # Windows service (NSSM/Task Scheduler) installation guide
|
|
||||||
│ └── server_config.sample.json # Reference server configuration
|
|
||||||
├── linux_client/
|
|
||||||
│ ├── README.md # Linux service & timer installation guide
|
|
||||||
│ └── client_config.sample.json # Reference client configuration
|
|
||||||
└── win_client/
|
|
||||||
├── README.md # Windows service installation & configuration guide
|
|
||||||
└── client_config.sample.json # Reference client configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started & Installation
|
## 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**:
|
### Core Endpoints
|
||||||
```bash
|
|
||||||
pip install -r compilation/requirements.txt
|
|
||||||
```
|
|
||||||
2. **Start the server** (generates `server_config.json` and keypair 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Windows Client Deployment
|
- **`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)*.
|
||||||
#### Option A: Precompiled Standalone Executable (Recommended)
|
- **`GET /api/client/update/check`**: Evaluates client version and returns upgrade manifest with SHA-256 integrity digest.
|
||||||
1. Download `Win_Client.exe` (or `Win_Client.pyz`) from the repository releases.
|
- **`GET /api/client/update/download`**: Delivers OS-specific standalone binary packages for forwarder upgrades.
|
||||||
2. Place `client_config.json` (exported from the server) in the same directory.
|
- **`GET /api/hermes/report`**: Structured JSON anomaly stream for Hermes AI reasoning.
|
||||||
3. Run manually or schedule via Task Scheduler (every 3 hours):
|
- **`POST /api/license/sync`**: Triggers immediate WebSocket sync with `logar.eibl.tech` *(Admin Auth)*.
|
||||||
```powershell
|
- **`GET /health`**: Server health, encryption fingerprint, and mTLS status.
|
||||||
.\Win_Client.exe --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Win_Client.py`, `requirements.txt`, and `client_config.json` to the target machine.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```powershell
|
|
||||||
python -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. Run manually or schedule via Task Scheduler:
|
|
||||||
```powershell
|
|
||||||
python Win_Client.py --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Linux Client Deployment
|
|
||||||
|
|
||||||
#### Option A: Precompiled Standalone Binary (Recommended)
|
|
||||||
1. Download `Linux_Client.bin` from the repository releases.
|
|
||||||
2. Place `Linux_Client.bin` and `client_config.json` into `/opt/logar/` and make it executable:
|
|
||||||
```bash
|
|
||||||
chmod +x /opt/logar/Linux_Client.bin
|
|
||||||
```
|
|
||||||
3. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * /opt/logar/Linux_Client.bin --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Linux_Client.py`, `requirements.txt`, and `client_config.json` to `/opt/logar/`.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. (Optional) Run `out/linux_client/build_bin.sh` to compile a standalone ELF binary locally if desired.
|
|
||||||
4. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * python3 /opt/logar/Linux_Client.py --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Enterprise Feature Capabilities
|
||||||
|
|
||||||
### 1. Component-Specific Unit Tests
|
LOGAR uses cryptographic X.509 v3 custom extensions embedded inside activated `.lic` intermediate CA certificates issued by the Master Authority:
|
||||||
The test suite is located in `tests/` and exercises all components:
|
|
||||||
|
| Feature Flag | Capability Name | Description | Default Status |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **`enterprise_db`** | **External DB & Ingestion Buffer** | Unlocks PostgreSQL/MySQL storage backends, high-throughput in-memory RAM buffering with batch flushing, and OpenPGP zero-knowledge at-rest data encryption. | Enabled by default |
|
||||||
|
| **`auto_update`** | **Fleet Auto-Update Orchestration** | Enables central repository binary hosting, client version discovery, background update polling, and automated binary hot-upgrades. | Enabled by default |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fleet Auto-Update & Package Distribution
|
||||||
|
|
||||||
|
LOGAR operates a pull-based fleet package distribution architecture:
|
||||||
|
|
||||||
|
1. **Central Server Repository**: The Server Hub monitors upstream releases and hosts target client binaries (`LOGAR_Client_Setup.exe`, `Linux_Client.bin`) in the local repository (`/opt/logar-server/updates/` or `.\updates\`).
|
||||||
|
2. **Version Negotiation**: Edge clients periodically contact `GET /api/client/update/check?os=<windows|linux>&version=<current>` to compare active versions against the Hub.
|
||||||
|
3. **Integrity Verification**: Client packages are validated using streaming SHA-256 digests prior to installation or hot-swap.
|
||||||
|
4. **Client CLI Flags**:
|
||||||
|
- `--auto-update`: Enables automated background update polling and downloading.
|
||||||
|
- `--update-check-interval <seconds>`: Configures update poll frequency (default: 3600s).
|
||||||
|
- `--check-update-now`: Queries server for available updates and exits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Cross-Compilation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all unit tests
|
# Run full unit test suite (51 tests)
|
||||||
python -m unittest discover -s tests
|
python -m unittest discover -s tests -v
|
||||||
|
|
||||||
# Or run component tests individually:
|
# Run end-to-end mTLS pipeline integration test
|
||||||
python -m unittest tests/test_server.py
|
|
||||||
python -m unittest tests/test_win_client.py
|
|
||||||
python -m unittest tests/test_linux_client.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. End-to-End Pipeline Integration Test
|
|
||||||
The pipeline test exercises invalid token rejection, encrypted socket streaming, database persistence, status promotion upon the 4th run, and the Hermes API report output.
|
|
||||||
|
|
||||||
1. **Start the server** in Shell 1 (creates `server_config.json` on first run):
|
|
||||||
```bash
|
|
||||||
python src/Server.py
|
|
||||||
```
|
|
||||||
2. **Export client configuration** in Shell 2 (required for testing):
|
|
||||||
```bash
|
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
3. **Execute the integration test** in Shell 2:
|
|
||||||
```bash
|
|
||||||
python tests/test_pipeline.py
|
python tests/test_pipeline.py
|
||||||
|
|
||||||
|
# Cross-compile native Linux and Windows binaries on Linux runner
|
||||||
|
python compilation/package_dist.py --target all
|
||||||
```
|
```
|
||||||
|
|
||||||
## Continuous Integration via Gitea Actions
|
|
||||||
|
|
||||||
Continuous integration is automated via [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) and triggers automatically on **every push** and pull request:
|
|
||||||
1. **Syntax Compilation**: Validates all Python scripts (`src/Server.py`, `src/Win_Client.py`, `src/Linux_Client.py`, `compilation/package_dist.py`, `compilation/upload_release.py`, `tests/test_pipeline.py`, and test suites).
|
|
||||||
2. **Component Unit Tests**: Discovers and runs all unit tests in `tests/` (`test_server.py`, `test_win_client.py`, `test_linux_client.py`).
|
|
||||||
3. **End-to-End Pipeline Verification**: Automatically spins up the LOGAR server hub, generates test configs, runs `tests/test_pipeline.py` (testing socket authentication, 4-run rule persistence, Hermes API report, and client integrations), and shuts down the test instance.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automated Releases via Gitea Actions
|
## Documentation & Deep Dives
|
||||||
|
|
||||||
Release builds are automated via two dedicated Gitea Actions workflows running concurrently on native platform runners:
|
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||||
- [`.gitea/workflows/release-linux.yml`](.gitea/workflows/release-linux.yml) (`ubuntu-latest`)
|
|
||||||
- [`.gitea/workflows/release-windows.yml`](.gitea/workflows/release-windows.yml) (`windows-latest`)
|
|
||||||
|
|
||||||
### Publishing a Release
|
* [WebUI Administrator Guide](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Admin-Guide)
|
||||||
Whenever you want to release a new version with compiled standalone binaries:
|
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||||
```bash
|
* [System State Machines & Subsystem Lifecycles](https://gitea.eibl.tech/me0nline/LOGAR/wiki/State-Machines-and-Lifecycles)
|
||||||
git tag v1.0.2
|
* [PKI Architecture & Custom X.509 Seat OIDs](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||||
git push origin v1.0.2
|
* [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)
|
||||||
*(You can also trigger builds manually via the Gitea UI using the **Run workflow** button (`workflow_dispatch`) on either workflow).*
|
* [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)
|
||||||
### Automated Multi-Platform Compilation:
|
|
||||||
1. **Linux Runner** (`release-linux.yml`):
|
|
||||||
- Compiles native Linux ELF executables: `Linux_Client.bin` and `Server.bin`.
|
|
||||||
- Generates dedicated SHA-256 checksum files:
|
|
||||||
- `linux_client_sha256sum` (verification for `Linux_Client.bin`)
|
|
||||||
- `linux_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `linux_server_sha256sum` (verification for `Server.bin`)
|
|
||||||
- `SHA256SUMS_linux.txt` (summary manifest)
|
|
||||||
- Attaches all Linux assets to the Gitea release.
|
|
||||||
|
|
||||||
2. **Windows Runner** (`release-windows.yml`):
|
|
||||||
- Compiles native Windows PE executables: `Win_Client.exe` and `Server.exe`.
|
|
||||||
- Generates dedicated SHA-256 checksum files:
|
|
||||||
- `win_client_sha256sum` (verification for `Win_Client.exe`)
|
|
||||||
- `win_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `win_server_sha256sum` (verification for `Server.exe`)
|
|
||||||
- `SHA256SUMS_windows.txt` (summary manifest)
|
|
||||||
- Attaches all Windows assets to the Gitea release.
|
|
||||||
|
|
||||||
3. **Concurrent Publishing & Conflict Handling**:
|
|
||||||
`upload_release.py` includes automatic retry and conflict resolution so concurrent Windows and Linux runners attach their respective assets to the release without collision.
|
|
||||||
|
|
||||||
### Verifying Checksums
|
|
||||||
- On Linux:
|
|
||||||
```bash
|
|
||||||
sha256sum -c linux_client_sha256sum
|
|
||||||
# or
|
|
||||||
sha256sum -c linux_server_sha256sum
|
|
||||||
```
|
|
||||||
- On Windows (PowerShell):
|
|
||||||
```powershell
|
|
||||||
Get-FileHash .\Win_Client.exe -Algorithm SHA256
|
|
||||||
Get-Content .\win_client_sha256sum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Packaging & Manual Upload
|
|
||||||
You can also compile and package binaries locally anytime:
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
py -3.12 compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
# Linux
|
|
||||||
python3 compilation/package_dist.py --target linux
|
|
||||||
```
|
|
||||||
To upload local builds directly to Gitea:
|
|
||||||
```powershell
|
|
||||||
python compilation/upload_release.py --tag v1.0.2 --token <YOUR_GITEA_TOKEN>
|
|
||||||
```
|
|
||||||
*(Environment variables `GITEA_TOKEN`, `GITEA_SERVER_URL`, `GITEA_REPOSITORY`, and `GITEA_REF_NAME` are also supported automatically).*
|
|
||||||
|
|||||||
@@ -1,13 +1,58 @@
|
|||||||
# LOGAR Release v1.0.2
|
# LOGAR Release Notes
|
||||||
|
|
||||||
### Changes in this Release:
|
## [v0.3.5.2] - 2026-10-03
|
||||||
- **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`.
|
### 🚀 Fleet Hub Client Update Caching & Distribution
|
||||||
- **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/`.
|
- **Local Package Caching**: The Fleet Hub automatically caches edge client binaries and packages locally on startup and upon scheduled refresh, minimizing external upstream traffic.
|
||||||
- **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).
|
- **Edge Pull Mechanism**: Forwarder nodes retrieve validated update payloads directly from the local Fleet Hub.
|
||||||
- **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.
|
### 🛡️ Windows Defender Firewall Automation (Fixes Issue #1)
|
||||||
- **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.
|
- **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`).
|
||||||
- **24-Hour Lookback Window**: Forwarders now scan and upload events from the last 24 hours (default `--hours 24`), skipping older entries.
|
- **Clean Uninstallation**: Uninstallers automatically remove the created firewall rules cleanly upon server decommissioning.
|
||||||
- **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.
|
### 📊 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()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HUB_URL="${1:-http://hub.example.com:8443}"
|
||||||
|
ENROLL_SECRET="${2:-}"
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/logar-client"
|
||||||
|
CONFIG_DIR="/etc/logar"
|
||||||
|
|
||||||
|
# 1. Mutual Exclusion Check: Deny installation if LOGAR Server is present
|
||||||
|
if [ -f "/opt/logar-server/Server" ] || [ -f "/etc/systemd/system/logar-server.service" ]; then
|
||||||
|
echo "[-] Error: LOGAR Server is already installed on this machine." >&2
|
||||||
|
echo "[-] LOGAR Client cannot be installed on a host where LOGAR Server is present (LOGAR Server includes built-in log collection)." >&2
|
||||||
|
echo "[-] Please uninstall LOGAR Server first before installing LOGAR Client." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Existing LOGAR Client Detection for In-Place Update Mode
|
||||||
|
IS_UPDATE=0
|
||||||
|
if [ -f "${INSTALL_DIR}/Linux_Client" ] || [ -f "/etc/systemd/system/logar-client.service" ]; then
|
||||||
|
IS_UPDATE=1
|
||||||
|
echo "[*] Existing LOGAR Client installation detected. Performing in-place upgrade..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${IS_UPDATE}" -eq 1 ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
|
echo "[*] Stopping LOGAR Client service for upgrade..."
|
||||||
|
systemctl stop logar-client.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[+] Installing LOGAR Client..."
|
||||||
|
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||||
|
chmod 700 "${CONFIG_DIR}/certs"
|
||||||
|
|
||||||
|
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."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${INSTALL_DIR}/Linux_Client" ]; then
|
||||||
|
chmod +x "${INSTALL_DIR}/Linux_Client"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bootstrap certificate if missing and enrollment secret is provided
|
||||||
|
if [ ! -f "${CONFIG_DIR}/certs/client.crt" ] && [ -n "${ENROLL_SECRET}" ]; then
|
||||||
|
echo "[+] Enrolling client with LOGAR Hub..."
|
||||||
|
MACHINE_ID=$(cat /etc/machine-id 2>/dev/null || hostname)
|
||||||
|
RESPONSE=$(curl -s -X POST "${HUB_URL}/api/client/enroll" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"client_id\": \"${MACHINE_ID}\", \"hostname\": \"$(hostname)\", \"os\": \"linux\", \"enrollment_secret\": \"${ENROLL_SECRET}\"}")
|
||||||
|
|
||||||
|
echo "${RESPONSE}" | grep -q "client_cert" || {
|
||||||
|
echo "[!] Enrollment failed: ${RESPONSE}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "${RESPONSE}" | jq -r .ca_cert > "${CONFIG_DIR}/certs/ca.crt"
|
||||||
|
echo "${RESPONSE}" | jq -r .client_cert > "${CONFIG_DIR}/certs/client.crt"
|
||||||
|
echo "${RESPONSE}" | jq -r .client_key > "${CONFIG_DIR}/certs/client.key"
|
||||||
|
else
|
||||||
|
python3 -c "import sys, json; data=json.loads(sys.stdin.read()); open('${CONFIG_DIR}/certs/ca.crt','w').write(data['ca_cert']); open('${CONFIG_DIR}/certs/client.crt','w').write(data['client_cert']); open('${CONFIG_DIR}/certs/client.key','w').write(data['client_key'])" <<< "${RESPONSE}"
|
||||||
|
fi
|
||||||
|
chmod 600 "${CONFIG_DIR}/certs/client.key"
|
||||||
|
echo "[+] Certificates written to ${CONFIG_DIR}/certs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF > /etc/systemd/system/logar-client.service
|
||||||
|
[Unit]
|
||||||
|
Description=LOGAR Edge Log Aggregator Client
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${INSTALL_DIR}/Linux_Client --config ${CONFIG_DIR}/config.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now logar-client.service || true
|
||||||
|
echo "[+] LOGAR Client service configured and activated."
|
||||||
|
else
|
||||||
|
echo "[+] Systemd service installed at /etc/systemd/system/logar-client.service"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/logar-server"
|
||||||
|
CONFIG_DIR="/etc/logar"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
cp dist/Server.bin "${INSTALL_DIR}/Server"
|
||||||
|
elif [ -f "dist/Server" ]; then
|
||||||
|
cp dist/Server "${INSTALL_DIR}/Server"
|
||||||
|
elif [ -f "dist/LOGAR_Server" ]; then
|
||||||
|
cp dist/LOGAR_Server "${INSTALL_DIR}/Server"
|
||||||
|
else
|
||||||
|
echo "[!] Warning: dist/Server.bin binary not found in current directory. Continuing with existing binary if present."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${INSTALL_DIR}/Server" ]; then
|
||||||
|
chmod +x "${INSTALL_DIR}/Server"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF > /etc/systemd/system/logar-server.service
|
||||||
|
[Unit]
|
||||||
|
Description=LOGAR Hub and Aggregator Engine
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=${INSTALL_DIR}/Server --config ${CONFIG_DIR}/server_config.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
|
User=root
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now logar-server.service || true
|
||||||
|
echo "[+] LOGAR Server service installed and activated."
|
||||||
|
else
|
||||||
|
echo "[+] Systemd service installed at /etc/systemd/system/logar-server.service"
|
||||||
|
fi
|
||||||
@@ -0,0 +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={#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: "{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 ""{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
|
||||||
@@ -0,0 +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={#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: "{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 ""{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")
|
DIST_DIR = os.path.join(ROOT_DIR, "dist")
|
||||||
BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
|
BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
|
||||||
|
|
||||||
|
|
||||||
def clean_and_prep():
|
def clean_and_prep():
|
||||||
if os.path.exists(DIST_DIR):
|
if os.path.exists(DIST_DIR):
|
||||||
shutil.rmtree(DIST_DIR)
|
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)
|
os.makedirs(DIST_DIR, exist_ok=True)
|
||||||
if os.path.exists(BUILD_TEMP):
|
if os.path.exists(BUILD_TEMP):
|
||||||
|
try:
|
||||||
shutil.rmtree(BUILD_TEMP)
|
shutil.rmtree(BUILD_TEMP)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
os.makedirs(BUILD_TEMP, exist_ok=True)
|
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):
|
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 = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--onefile",
|
"--onefile",
|
||||||
"--clean",
|
"--clean",
|
||||||
|
"--strip",
|
||||||
|
"--optimize", "2",
|
||||||
|
"--paths", ROOT_DIR,
|
||||||
|
"--add-data", f"{SRC_DIR}{sep}src",
|
||||||
|
"--collect-all", "src",
|
||||||
"--distpath", DIST_DIR,
|
"--distpath", DIST_DIR,
|
||||||
"--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"),
|
"--workpath", os.path.join(BUILD_TEMP, f"work_{binary_name}"),
|
||||||
"--specpath", os.path.join(BUILD_TEMP, f"spec_{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}")
|
raise RuntimeError(f"Failed to build {binary_name}")
|
||||||
print(f"[+] Successfully compiled {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():
|
def build_linux_zipapp_fallback():
|
||||||
print("[*] Packaging Linux standalone zipapp fallback binaries...")
|
print("[*] Packaging Linux standalone zipapp fallback binaries...")
|
||||||
# Linux Client zipapp
|
# Linux Client zipapp
|
||||||
app_dir = os.path.join(BUILD_TEMP, "linux_app")
|
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)
|
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")
|
client_out = os.path.join(DIST_DIR, "Linux_Client.bin")
|
||||||
zipapp.create_archive(
|
zipapp.create_archive(
|
||||||
source=app_dir,
|
source=app_dir,
|
||||||
@@ -66,8 +105,14 @@ def build_linux_zipapp_fallback():
|
|||||||
)
|
)
|
||||||
# Server zipapp
|
# Server zipapp
|
||||||
srv_dir = os.path.join(BUILD_TEMP, "linux_srv")
|
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)
|
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")
|
server_out = os.path.join(DIST_DIR, "Server.bin")
|
||||||
zipapp.create_archive(
|
zipapp.create_archive(
|
||||||
source=srv_dir,
|
source=srv_dir,
|
||||||
@@ -76,16 +121,441 @@ def build_linux_zipapp_fallback():
|
|||||||
main="Server:main"
|
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")
|
def prepare_windows_runtime():
|
||||||
build_pyinstaller_binary(server_script, "Server")
|
"""
|
||||||
|
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")
|
client_bin = os.path.join(DIST_DIR, "Win_Client.exe")
|
||||||
server_bin = os.path.join(DIST_DIR, "Server.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 = []
|
sums = []
|
||||||
if os.path.exists(client_bin):
|
if os.path.exists(client_bin):
|
||||||
@@ -94,29 +564,42 @@ def build_windows():
|
|||||||
write_checksum_file("win_agent_sha256sum", client_hash, "Win_Client.exe")
|
write_checksum_file("win_agent_sha256sum", client_hash, "Win_Client.exe")
|
||||||
sums.append(f"{client_hash} Win_Client.exe")
|
sums.append(f"{client_hash} Win_Client.exe")
|
||||||
else:
|
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):
|
if os.path.exists(server_bin):
|
||||||
server_hash = calculate_sha256(server_bin)
|
server_hash = calculate_sha256(server_bin)
|
||||||
write_checksum_file("win_server_sha256sum", server_hash, "Server.exe")
|
write_checksum_file("win_server_sha256sum", server_hash, "Server.exe")
|
||||||
sums.append(f"{server_hash} Server.exe")
|
sums.append(f"{server_hash} Server.exe")
|
||||||
else:
|
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")
|
sums_path = os.path.join(DIST_DIR, "SHA256SUMS_windows.txt")
|
||||||
with open(sums_path, "w", encoding="utf-8") as f:
|
with open(sums_path, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(sums) + "\n")
|
f.write("\n".join(sums) + "\n")
|
||||||
|
|
||||||
|
|
||||||
def build_linux():
|
def build_linux():
|
||||||
print("[*] Compiling Linux standalone binaries...")
|
print("[*] Compiling Linux standalone binaries and packaging installers...")
|
||||||
is_linux_host = platform.system() == "Linux"
|
is_linux_host = platform.system() == "Linux"
|
||||||
|
|
||||||
if is_linux_host:
|
if is_linux_host:
|
||||||
|
ensure_libpython()
|
||||||
|
try:
|
||||||
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
||||||
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
||||||
|
|
||||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
server_script = os.path.join(SRC_DIR, "Server.py")
|
||||||
build_pyinstaller_binary(server_script, "Server.bin")
|
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
|
# Normalize extensions in case PyInstaller dropped .bin
|
||||||
for name in ["Linux_Client", "Server"]:
|
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.")
|
print("[!] Note: Host platform is not Linux. Generating executable zipapps for Linux target.")
|
||||||
build_linux_zipapp_fallback()
|
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")
|
client_bin = os.path.join(DIST_DIR, "Linux_Client.bin")
|
||||||
server_bin = os.path.join(DIST_DIR, "Server.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 = []
|
sums = []
|
||||||
if os.path.exists(client_bin):
|
if os.path.exists(client_bin):
|
||||||
client_hash = calculate_sha256(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")
|
write_checksum_file("linux_agent_sha256sum", client_hash, "Linux_Client.bin")
|
||||||
sums.append(f"{client_hash} Linux_Client.bin")
|
sums.append(f"{client_hash} Linux_Client.bin")
|
||||||
else:
|
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):
|
if os.path.exists(server_bin):
|
||||||
server_hash = calculate_sha256(server_bin)
|
server_hash = calculate_sha256(server_bin)
|
||||||
write_checksum_file("linux_server_sha256sum", server_hash, "Server.bin")
|
write_checksum_file("linux_server_sha256sum", server_hash, "Server.bin")
|
||||||
sums.append(f"{server_hash} Server.bin")
|
sums.append(f"{server_hash} Server.bin")
|
||||||
else:
|
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")
|
sums_path = os.path.join(DIST_DIR, "SHA256SUMS_linux.txt")
|
||||||
with open(sums_path, "w", encoding="utf-8") as f:
|
with open(sums_path, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(sums) + "\n")
|
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):
|
def main(target=None):
|
||||||
if target is None:
|
if target is None:
|
||||||
parser = argparse.ArgumentParser(description="LOGAR Standalone Binary Compiler & Packager")
|
parser = argparse.ArgumentParser(description="LOGAR Standalone Binary Compiler & Packager")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--target", "-t",
|
"--target", "-t",
|
||||||
choices=["windows", "win", "linux", "auto"],
|
choices=["all", "windows", "win", "linux", "auto"],
|
||||||
default="auto",
|
default="auto",
|
||||||
help="Target platform to compile binaries for (default: auto-detect)"
|
help="Target platform to compile binaries for (default: auto-detect)"
|
||||||
)
|
)
|
||||||
@@ -173,19 +707,21 @@ def main(target=None):
|
|||||||
target = args.target
|
target = args.target
|
||||||
|
|
||||||
if target == "auto":
|
if target == "auto":
|
||||||
target = "windows" if platform.system() == "Windows" else "linux"
|
target = "all"
|
||||||
elif target == "win":
|
elif target == "win":
|
||||||
target = "windows"
|
target = "windows"
|
||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(" LOGAR Binary Packaging")
|
print(" LOGAR Binary Packaging & Cross-Compilation")
|
||||||
print(f" Host Platform: {platform.system()} ({platform.machine()})")
|
print(f" Host Platform: {platform.system()} ({platform.machine()})")
|
||||||
print(f" Target Platform: {target.upper()}")
|
print(f" Target Platform: {target.upper()}")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
clean_and_prep()
|
clean_and_prep()
|
||||||
|
|
||||||
if target == "windows":
|
if target == "all":
|
||||||
|
build_all()
|
||||||
|
elif target == "windows":
|
||||||
build_windows()
|
build_windows()
|
||||||
elif target == "linux":
|
elif target == "linux":
|
||||||
build_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(f" - {f} ({sz / (1024*1024):.2f} MB)" if sz > 1024*1024 else f" - {f} ({sz} bytes)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ standard-imghdr>=3.13.0; python_version >= "3.13"
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
fastapi>=0.110.0
|
fastapi>=0.110.0
|
||||||
uvicorn>=0.28.0
|
uvicorn>=0.28.0
|
||||||
|
websockets>=11.0.0
|
||||||
pydantic>=2.6.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"
|
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_GITEA_URL = os.environ.get("GITEA_SERVER_URL", "https://gitea.eibl.tech")
|
||||||
DEFAULT_REPO = os.environ.get("GITEA_REPOSITORY", "me0nline/LOGAR")
|
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):
|
def get_existing_assets(base_url, repo, release_id, token):
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
|
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:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
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):
|
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}"
|
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:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req) as resp:
|
||||||
pass
|
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):
|
for attempt in range(1, max_retries + 1):
|
||||||
req = urllib.request.Request(url, data=file_bytes, method="POST")
|
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("Content-Type", "application/octet-stream")
|
||||||
req.add_header("Accept", "application/json")
|
req.add_header("Accept", "application/json")
|
||||||
|
|
||||||
@@ -73,14 +85,14 @@ def upload_file_to_release(base_url, repo, release_id, token, file_path, max_ret
|
|||||||
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):
|
||||||
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
url = f"{base_url}/api/v1/repos/{repo}/releases"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"token {token}",
|
"Authorization": get_auth_header(token),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json"
|
"Accept": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if release exists
|
# Check if release exists
|
||||||
check_url = f"{base_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}"
|
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:
|
try:
|
||||||
with urllib.request.urlopen(check_req) as resp:
|
with urllib.request.urlopen(check_req) as resp:
|
||||||
existing = json.loads(resp.read().decode("utf-8"))
|
existing = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
# LOGAR Linux Edge Forwarder
|
|
||||||
|
|
||||||
Standalone compiled binary distribution for Linux edge servers running systemd.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, encrypts the payload using OpenPGP, and streams candidate events over an authenticated TCP socket to the central LOGAR hub.
|
|
||||||
|
|
||||||
### Key Capabilities
|
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone executable binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p info`). Drops debug noise (priority 7) 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.
|
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
|
||||||
|
|
||||||
```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 TCP port is `9443`.
|
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
|
||||||
The generated `client_config.json` contains:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_host": "192.168.1.100",
|
|
||||||
"server_port": 9443,
|
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file 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 (recommended: `/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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Running Manually
|
|
||||||
|
|
||||||
Test the forwarder interactively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/logar
|
|
||||||
./Linux_Client.bin --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Installing as a Systemd Service & Timer (Recommended)
|
|
||||||
|
|
||||||
Running `Linux_Client.bin` via a systemd timer ensures reliable periodic execution, automatic restart, and native log integration with `journalctl`.
|
|
||||||
|
|
||||||
### Step 1: Create the Systemd Service Unit
|
|
||||||
Create `/etc/systemd/system/logar-forwarder.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=LOGAR Edge Log Forwarder
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
WorkingDirectory=/opt/logar
|
|
||||||
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
|
||||||
User=root
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create the Systemd Timer Unit
|
|
||||||
Create `/etc/systemd/system/logar-forwarder.timer` to execute the forwarder every 3 hours (with a 5-minute initial delay upon boot):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Run LOGAR Edge Forwarder periodically
|
|
||||||
Requires=logar-forwarder.service
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=5min
|
|
||||||
OnUnitActiveSec=3h
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable and Start the Timer
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now logar-forwarder.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Timer & Service Status
|
|
||||||
```bash
|
|
||||||
# Check timer schedule
|
|
||||||
sudo systemctl list-timers --all | grep logar
|
|
||||||
|
|
||||||
# Trigger an immediate manual execution
|
|
||||||
sudo systemctl start logar-forwarder.service
|
|
||||||
|
|
||||||
# View execution logs
|
|
||||||
sudo journalctl -u logar-forwarder.service -n 50
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Alternative: Cron Job Deployment
|
|
||||||
|
|
||||||
If systemd timers are not preferred, configure a periodic cron job running every 3 hours:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Open root crontab
|
|
||||||
sudo crontab -e
|
|
||||||
|
|
||||||
# Add the following entry:
|
|
||||||
0 */3 * * * cd /opt/logar && ./Linux_Client.bin --hours 24 >> /var/log/logar_forwarder.log 2>&1
|
|
||||||
```
|
|
||||||
@@ -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,208 +0,0 @@
|
|||||||
# LOGAR Linux Server Hub
|
|
||||||
|
|
||||||
Standalone compiled executable binary 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 and log analysis 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.
|
|
||||||
- **Authenticated TCP Ingestion Socket (Port 9443)**: Accepts framed OpenPGP encrypted log batches streamed by edge forwarders (`Linux_Client.bin` and `Win_Client.exe`).
|
|
||||||
- **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 are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a sliding 12-hour evaluation window before promotion from transient noise to `VERIFIED`.
|
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external scrapers, SIEM collectors, and alerting dashboards.
|
|
||||||
- **Pure-Python OpenPGP Cryptography**: Zero dependency on external `gpg` binaries. Automatically generates RSA-2048 encryption keys and SHA-256 fingerprints on first launch.
|
|
||||||
- **State Database**: Tracks anomaly lifecycles, run counters, and machine telemetry in a local SQLite state database (`logar_state.db`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Initializing & Generating Server Configuration
|
|
||||||
|
|
||||||
### Step 1: Automatic First-Run Generation
|
|
||||||
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
|
|
||||||
1. A fresh OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
|
|
||||||
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
|
||||||
3. A cryptographically random secret authentication token (`auth_token`).
|
|
||||||
4. Default network socket coordinates (TCP 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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...",
|
|
||||||
"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..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `server_name` | `"LOGAR-Linux-Hub"` | Human-readable identifier for this hub instance |
|
|
||||||
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for edge client TCP ingestion |
|
|
||||||
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
|
|
||||||
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
|
|
||||||
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
|
|
||||||
| `auth_token` | *(auto-generated)* | Pre-shared secret required in edge client envelopes |
|
|
||||||
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
|
|
||||||
| `evaluation_window_hours` | `12` | Sliding temporal window for warning persistence |
|
|
||||||
| `min_persistence_runs` | `4` | Number of distinct runs required to promote warnings to `VERIFIED` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Generating Client Configuration Bundles
|
|
||||||
|
|
||||||
Edge forwarders (`Linux_Client.bin` and `Win_Client.exe`) require a minimal, anonymous configuration bundle containing socket coordinates, the authentication token, and the server's public key (without sensitive server names or private keys).
|
|
||||||
|
|
||||||
Run the following command on the server:
|
|
||||||
```bash
|
|
||||||
./Server.bin --create-client-config --server-host <SERVER_PUBLIC_OR_INTERNAL_IP> --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
- Replace `<SERVER_PUBLIC_OR_INTERNAL_IP>` with the reachable IP or FQDN of your LOGAR server.
|
|
||||||
- The output `client_config.json` can be distributed directly to Linux and Windows edge forwarder nodes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Running Interactively
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./Server.bin --config /path/to/server_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command-Line Arguments
|
|
||||||
| Argument | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
|
|
||||||
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
|
|
||||||
| `--server-host` | Hostname/IP to embed in the exported client configuration |
|
|
||||||
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
|
|
||||||
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Installing as a Systemd Service (Recommended)
|
|
||||||
|
|
||||||
Running `Server.bin` as a native systemd background service ensures continuous execution, automatic restart upon reboot or crash, and centralized log management via `journalctl`.
|
|
||||||
|
|
||||||
### Step 1: Create Deployment Directory and User
|
|
||||||
```bash
|
|
||||||
# Create dedicated system group and user
|
|
||||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin logar
|
|
||||||
|
|
||||||
# Prepare deployment folder
|
|
||||||
sudo mkdir -p /opt/logar-server
|
|
||||||
sudo cp Server.bin server_config.json /opt/logar-server/
|
|
||||||
sudo chmod +x /opt/logar-server/Server.bin
|
|
||||||
sudo chown -R logar:logar /opt/logar-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create Systemd Service File
|
|
||||||
Create `/etc/systemd/system/logar-server.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=LOGAR Central Server Hub Service
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=logar
|
|
||||||
Group=logar
|
|
||||||
WorkingDirectory=/opt/logar-server
|
|
||||||
ExecStart=/opt/logar-server/Server.bin --config /opt/logar-server/server_config.json
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
LimitNOFILE=65536
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable and Start Service
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now logar-server.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Status and Inspect Logs
|
|
||||||
```bash
|
|
||||||
# Check service status
|
|
||||||
sudo systemctl status logar-server.service
|
|
||||||
|
|
||||||
# Stream live server logs
|
|
||||||
sudo journalctl -u logar-server.service -f
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Hermes Reporting API & Integration
|
|
||||||
|
|
||||||
The server embeds a high-performance HTTP service on port `8443` providing real-time intelligence on promoted anomalies:
|
|
||||||
|
|
||||||
### Fetching Promoted Anomalies
|
|
||||||
```bash
|
|
||||||
curl -s http://127.0.0.1:8443/api/hermes/report | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Schema:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "prod-web-01.corp.internal:Out_Of_Memory",
|
|
||||||
"server": "prod-web-01.corp.internal",
|
|
||||||
"signature": "Out_Of_Memory",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"first_seen": "2026-09-04T08:00:00Z",
|
|
||||||
"last_seen": "2026-09-04T14:30:00Z",
|
|
||||||
"status": "VERIFIED",
|
|
||||||
"verified": true,
|
|
||||||
"os_type": "linux",
|
|
||||||
"sample_message": "kernel: Out of memory: Kill process 1824"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Firewall Configuration
|
|
||||||
|
|
||||||
Ensure the following inbound ports are open on your host firewall:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# UFW (Ubuntu / Debian)
|
|
||||||
sudo ufw allow 9443/tcp comment "LOGAR TCP Log Ingestion"
|
|
||||||
sudo ufw allow 8443/tcp comment "LOGAR Hermes Reporting API"
|
|
||||||
sudo ufw reload
|
|
||||||
|
|
||||||
# Firewalld (RHEL / CentOS / Rocky / Alma)
|
|
||||||
sudo firewall-cmd --permanent --add-port=9443/tcp
|
|
||||||
sudo firewall-cmd --permanent --add-port=8443/tcp
|
|
||||||
sudo firewall-cmd --reload
|
|
||||||
```
|
|
||||||
@@ -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,127 +0,0 @@
|
|||||||
# LOGAR Windows Edge Forwarder
|
|
||||||
|
|
||||||
Standalone compiled executable distribution for Windows Server and workstation environments.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
`Win_Client.exe` is a self-contained, pre-compiled executable that queries the Windows Application Event Log, filters candidate events at the source, encrypts the payload using OpenPGP, and streams records over an authenticated TCP socket to the central LOGAR hub.
|
|
||||||
|
|
||||||
### Key Capabilities
|
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit success/failure 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.
|
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
|
||||||
|
|
||||||
```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 TCP port is `9443`.
|
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
|
||||||
The generated `client_config.json` contains:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_host": "192.168.1.100",
|
|
||||||
"server_port": 9443,
|
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
|
||||||
|
|
||||||
### Step 3: Copy to Edge Node
|
|
||||||
Place `Win_Client.exe` and `client_config.json` in the target directory (recommended: `C:\LOGAR\`):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
|
||||||
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Running Manually
|
|
||||||
|
|
||||||
Test the forwarder interactively from PowerShell or Command Prompt:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\LOGAR
|
|
||||||
.\Win_Client.exe --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Installing as a Background Service / Scheduled Task
|
|
||||||
|
|
||||||
Edge forwarders run as episodic background processes (run, forward unsent candidate records, commit state, and terminate). On Windows, this is natively managed via Windows Task Scheduler running as a background service under `SYSTEM`.
|
|
||||||
|
|
||||||
### Method A: Windows Scheduled Task via PowerShell (Recommended)
|
|
||||||
Open an **Elevated PowerShell (Run as Administrator)** window and execute:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Define action and periodic trigger (every 3 hours indefinitely)
|
|
||||||
$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)
|
|
||||||
|
|
||||||
# Configure task settings (wake on sleep, start when ready, run hidden)
|
|
||||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
|
||||||
|
|
||||||
# Register task running under the local SYSTEM account with highest privileges
|
|
||||||
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
|
||||||
-Action $Action `
|
|
||||||
-Trigger $Trigger `
|
|
||||||
-Settings $Settings `
|
|
||||||
-User "NT AUTHORITY\SYSTEM" `
|
|
||||||
-RunLevel Highest `
|
|
||||||
-Description "LOGAR Windows Edge Log Forwarder Service"
|
|
||||||
|
|
||||||
# Verify task creation and trigger immediate execution
|
|
||||||
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
|
||||||
Get-ScheduledTask -TaskName "LOGAR_Forwarder"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method B: Continuous Windows Service via NSSM
|
|
||||||
If your organizational policy requires a formal Windows Service listed under `services.msc`:
|
|
||||||
|
|
||||||
1. Download [NSSM (Non-Sucking Service Manager)](https://nssm.cc/).
|
|
||||||
2. Install the service using NSSM:
|
|
||||||
```cmd
|
|
||||||
nssm.exe install LOGAR_Forwarder "C:\LOGAR\Win_Client.exe" "--hours 24"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppDirectory "C:\LOGAR"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppRestartDelay 10800000
|
|
||||||
nssm.exe start LOGAR_Forwarder
|
|
||||||
```
|
|
||||||
*(Note: `AppRestartDelay 10800000` pauses 3 hours between execution cycles).*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Uninstallation & Removal
|
|
||||||
|
|
||||||
To remove the scheduled task:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Unregister-ScheduledTask -TaskName "LOGAR_Forwarder" -Confirm:$false
|
|
||||||
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,237 +0,0 @@
|
|||||||
# LOGAR Windows Server Hub
|
|
||||||
|
|
||||||
Standalone compiled executable 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, and reporting hub of the LOGAR infrastructure.
|
|
||||||
|
|
||||||
### Key Architecture & Capabilities
|
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
|
||||||
- **Authenticated TCP Ingestion Socket (Port 9443)**: Ingests framed OpenPGP encrypted log batches streamed from edge forwarder nodes (`Win_Client.exe` and `Linux_Client.bin`).
|
|
||||||
- **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 are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window before promotion to `VERIFIED`.
|
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external dashboards, monitoring agents, and scrapers.
|
|
||||||
- **Pure-Python OpenPGP Cryptography**: Automatically generates RSA-2048 encryption keys and a SHA-256 fingerprint on first launch without external dependencies.
|
|
||||||
- **State Database**: Stores issue lifecycle records, run counters, and machine telemetry in a local SQLite database (`logar_state.db`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Initializing & Generating Server Configuration
|
|
||||||
|
|
||||||
### Step 1: Automatic First-Run Generation
|
|
||||||
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
|
|
||||||
1. An OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
|
|
||||||
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
|
||||||
3. A cryptographically random secret authentication token (`auth_token`).
|
|
||||||
4. Default network socket coordinates (TCP 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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...",
|
|
||||||
"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..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `server_name` | `"LOGAR-Windows-Hub"` | Identifier for this hub instance |
|
|
||||||
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for incoming client socket traffic |
|
|
||||||
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
|
|
||||||
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
|
|
||||||
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
|
|
||||||
| `auth_token` | *(auto-generated)* | Pre-shared authentication secret required in client envelopes |
|
|
||||||
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
|
|
||||||
| `evaluation_window_hours` | `12` | Rolling evaluation window in hours for warning persistence |
|
|
||||||
| `min_persistence_runs` | `4` | Consecutive runs required to promote warning issues to `VERIFIED` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Generating Client Configuration Bundles
|
|
||||||
|
|
||||||
Edge forwarders (`Win_Client.exe` and `Linux_Client.bin`) require an anonymous client configuration bundle that includes the server socket target, authentication token, and encryption public key, without exposing sensitive server names or private keys.
|
|
||||||
|
|
||||||
Run the following command on the server:
|
|
||||||
```powershell
|
|
||||||
.\Server.exe --create-client-config --server-host <SERVER_IP_OR_FQDN> --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_FQDN>` with the reachable IP or DNS name of your LOGAR server.
|
|
||||||
- Distribute `client_config.json` to client forwarder nodes along with `Win_Client.exe` or `Linux_Client.bin`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Running Interactively
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\Server.exe --config C:\LOGAR-Server\server_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command-Line Arguments
|
|
||||||
| Argument | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
|
|
||||||
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
|
|
||||||
| `--server-host` | Hostname/IP to embed in the exported client configuration |
|
|
||||||
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
|
|
||||||
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Installing as a Continuous Windows Service
|
|
||||||
|
|
||||||
Because `Server.exe` acts as a continuous server hub (listening for TCP connections and HTTP API queries), it should run persistently in the background.
|
|
||||||
|
|
||||||
### Method A: Native Windows Service via NSSM (Recommended)
|
|
||||||
[NSSM (Non-Sucking Service Manager)](https://nssm.cc/) is the industry standard for wrapping standalone executables into formal Windows services managed by `services.msc`.
|
|
||||||
|
|
||||||
1. Place `Server.exe` and `server_config.json` in `C:\LOGAR-Server\`.
|
|
||||||
2. Open **Elevated PowerShell (Run as Administrator)**:
|
|
||||||
```powershell
|
|
||||||
# Create deployment folder
|
|
||||||
New-Item -ItemType Directory -Path "C:\LOGAR-Server" -Force
|
|
||||||
Copy-Item "Server.exe", "server_config.json" -Destination "C:\LOGAR-Server\"
|
|
||||||
|
|
||||||
# Install Windows Service via NSSM
|
|
||||||
nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe" "--config C:\LOGAR-Server\server_config.json"
|
|
||||||
nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
|
||||||
nssm.exe set LOGAR_Server Description "LOGAR Central Aggregation Hub Service"
|
|
||||||
nssm.exe set LOGAR_Server Start SERVICE_AUTO_START
|
|
||||||
nssm.exe set LOGAR_Server AppStdout "C:\LOGAR-Server\server_out.log"
|
|
||||||
nssm.exe set LOGAR_Server AppStderr "C:\LOGAR-Server\server_err.log"
|
|
||||||
|
|
||||||
# Start the service
|
|
||||||
nssm.exe start LOGAR_Server
|
|
||||||
```
|
|
||||||
3. Verify status in PowerShell:
|
|
||||||
```powershell
|
|
||||||
Get-Service -Name "LOGAR_Server"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method B: Windows Task Scheduler (Startup Daemon)
|
|
||||||
If third-party service wrappers are restricted by organizational policy, configure a Task Scheduler job triggered at boot under the `SYSTEM` account:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Action: Launch Server.exe
|
|
||||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR-Server\Server.exe" `
|
|
||||||
-Argument "--config C:\LOGAR-Server\server_config.json" `
|
|
||||||
-WorkingDirectory "C:\LOGAR-Server"
|
|
||||||
|
|
||||||
# Trigger: At system startup
|
|
||||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
|
||||||
|
|
||||||
# Settings: Restart on failure, no execution time limit
|
|
||||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
|
||||||
-DontStopIfGoingOnBatteries `
|
|
||||||
-StartWhenAvailable `
|
|
||||||
-RestartCount 3 `
|
|
||||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
|
||||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
|
||||||
|
|
||||||
# Register task under SYSTEM with highest privileges
|
|
||||||
Register-ScheduledTask -TaskName "LOGAR_Server_Daemon" `
|
|
||||||
-Action $Action `
|
|
||||||
-Trigger $Trigger `
|
|
||||||
-Settings $Settings `
|
|
||||||
-User "NT AUTHORITY\SYSTEM" `
|
|
||||||
-RunLevel Highest `
|
|
||||||
-Description "LOGAR Central Hub Daemon"
|
|
||||||
|
|
||||||
# Start the task immediately
|
|
||||||
Start-ScheduledTask -TaskName "LOGAR_Server_Daemon"
|
|
||||||
Get-ScheduledTask -TaskName "LOGAR_Server_Daemon"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Hermes Reporting API & Health Checks
|
|
||||||
|
|
||||||
Test the embedded Hermes REST endpoint locally using PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$report = Invoke-RestMethod -Uri "http://127.0.0.1:8443/api/hermes/report" -Method GET
|
|
||||||
$report | Format-Table fingerprint, status, consecutive_runs, first_seen, last_seen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Format:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "win-dc-01.corp.internal:DiskCorruptionDetected",
|
|
||||||
"server": "win-dc-01.corp.internal",
|
|
||||||
"signature": "DiskCorruptionDetected",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"first_seen": "2026-09-04T08:15:00Z",
|
|
||||||
"last_seen": "2026-09-04T15:00:00Z",
|
|
||||||
"status": "VERIFIED",
|
|
||||||
"verified": true,
|
|
||||||
"os_type": "windows",
|
|
||||||
"sample_message": "An error was detected on device \\Device\\Harddisk0\\DR0 during a paging operation."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Windows Defender Firewall Configuration
|
|
||||||
|
|
||||||
Open the necessary inbound firewall ports to allow incoming edge forwarder socket streams and HTTP API queries:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Allow TCP 9443 for edge log forwarding
|
|
||||||
New-NetFirewallRule -DisplayName "LOGAR TCP Log Ingestion" `
|
|
||||||
-Direction Inbound `
|
|
||||||
-LocalPort 9443 `
|
|
||||||
-Protocol TCP `
|
|
||||||
-Action Allow
|
|
||||||
|
|
||||||
# Allow TCP 8443 for Hermes Reporting REST API
|
|
||||||
New-NetFirewallRule -DisplayName "LOGAR Hermes Reporting API" `
|
|
||||||
-Direction Inbound `
|
|
||||||
-LocalPort 8443 `
|
|
||||||
-Protocol TCP `
|
|
||||||
-Action Allow
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Uninstallation & Removal
|
|
||||||
|
|
||||||
To remove the server service:
|
|
||||||
```powershell
|
|
||||||
# If installed via NSSM:
|
|
||||||
nssm.exe stop LOGAR_Server
|
|
||||||
nssm.exe remove LOGAR_Server confirm
|
|
||||||
|
|
||||||
# If installed via Task Scheduler:
|
|
||||||
Unregister-ScheduledTask -TaskName "LOGAR_Server_Daemon" -Confirm:$false
|
|
||||||
|
|
||||||
# Clean files
|
|
||||||
Remove-Item -Recurse -Force "C:\LOGAR-Server"
|
|
||||||
```
|
|
||||||
@@ -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,343 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
|
||||||
import warnings
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
|
|
||||||
# Suppress cryptography / pgpy deprecation notices
|
_CUR = os.path.dirname(__file__)
|
||||||
warnings.filterwarnings("ignore")
|
_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"
|
from src.clients.Linux_Client import *
|
||||||
STATE_FILE_NAME = "client_state.json"
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
|
||||||
over an authenticated TCP socket.
|
|
||||||
"""
|
|
||||||
server_host = config["server_host"]
|
|
||||||
server_port = int(config["server_port"])
|
|
||||||
auth_token = config["auth_token"]
|
|
||||||
pub_key_armored = config["server_public_key"]
|
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
|
||||||
|
|
||||||
# Load and verify server public key
|
|
||||||
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}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare batch
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_json = json.dumps(payload)
|
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
|
||||||
encrypted_armored = str(encrypted_msg)
|
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
|
||||||
"auth_token": auth_token,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"encrypted_payload": encrypted_armored
|
|
||||||
}
|
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
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))
|
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
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__":
|
|
||||||
main()
|
main()
|
||||||
@@ -1,470 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
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
|
_CUR = os.path.dirname(__file__)
|
||||||
warnings.filterwarnings("ignore")
|
_ROOT = os.path.abspath(os.path.join(_CUR, ".."))
|
||||||
|
|
||||||
import pgpy
|
for _p in [_CUR, _ROOT]:
|
||||||
from pgpy.constants import (
|
if _p and _p not in sys.path:
|
||||||
PubKeyAlgorithm,
|
sys.path.insert(0, _p)
|
||||||
KeyFlags,
|
|
||||||
HashAlgorithm,
|
|
||||||
SymmetricKeyAlgorithm,
|
|
||||||
CompressionAlgorithm
|
|
||||||
)
|
|
||||||
from fastapi import FastAPI, HTTPException
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
CONFIG_FILE_NAME = "server_config.json"
|
from src.hub.Server import *
|
||||||
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")
|
if __name__ == '__main__':
|
||||||
|
|
||||||
# Global context holding server state
|
|
||||||
SERVER_STATE: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
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,
|
|
||||||
"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
|
|
||||||
) -> 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,
|
|
||||||
"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):
|
|
||||||
"""Initializes the SQLite schema for multi-run temporal tracking."""
|
|
||||||
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.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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
||||||
"""
|
|
||||||
Authenticated TCP socket handler.
|
|
||||||
Protocol:
|
|
||||||
- 4-byte big-endian prefix: payload length
|
|
||||||
- Payload: JSON with auth_token and encrypted_payload (OpenPGP ASCII armored)
|
|
||||||
- Response: 4-byte length + JSON confirmation
|
|
||||||
"""
|
|
||||||
addr = writer.get_extra_info("peername")
|
|
||||||
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)
|
|
||||||
envelope = json.loads(payload_bytes.decode("utf-8"))
|
|
||||||
|
|
||||||
# Authenticate socket client
|
|
||||||
expected_token = SERVER_STATE["config"]["auth_token"]
|
|
||||||
provided_token = envelope.get("auth_token")
|
|
||||||
if not secrets.compare_digest(str(provided_token), str(expected_token)):
|
|
||||||
err_msg = json.dumps({"status": "error", "message": "Authentication failed"}).encode("utf-8")
|
|
||||||
writer.write(struct.pack(">I", len(err_msg)) + err_msg)
|
|
||||||
await writer.drain()
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Decrypt payload using server's OpenPGP private key
|
|
||||||
encrypted_armored = envelope.get("encrypted_payload", "")
|
|
||||||
pgp_msg = pgpy.PGPMessage.from_blob(encrypted_armored)
|
|
||||||
priv_key = SERVER_STATE["private_key_obj"]
|
|
||||||
decrypted_obj = priv_key.decrypt(pgp_msg)
|
|
||||||
decrypted_json_str = decrypted_obj.message
|
|
||||||
log_payload = json.loads(decrypted_json_str)
|
|
||||||
|
|
||||||
# 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.get("/api/hermes/report")
|
|
||||||
def get_hermes_report():
|
|
||||||
"""
|
|
||||||
Agentic Integration endpoint: Consumed by Hermes to fetch anomalies that have persisted
|
|
||||||
across the 12-hour evaluation window and satisfied the 4-run rule.
|
|
||||||
"""
|
|
||||||
db_path = SERVER_STATE["config"]["db_path"]
|
|
||||||
window_hours = SERVER_STATE["config"]["evaluation_window_hours"]
|
|
||||||
min_runs = SERVER_STATE["config"]["min_persistence_runs"]
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
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'
|
|
||||||
""")
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
report = []
|
|
||||||
for r in rows:
|
|
||||||
last_seen_dt = datetime.fromisoformat(r[8])
|
|
||||||
# Only return anomalies active within the evaluation window
|
|
||||||
if (now - last_seen_dt) <= timedelta(hours=window_hours):
|
|
||||||
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"{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"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def run_server():
|
|
||||||
"""Runs the 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"])
|
|
||||||
|
|
||||||
# Start TCP Socket Server
|
|
||||||
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port)
|
|
||||||
print(f"[*] LOGAR TCP Socket Server listening on {tcp_host}:{tcp_port}")
|
|
||||||
|
|
||||||
# Start FastAPI / Uvicorn server for Hermes
|
|
||||||
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")
|
|
||||||
|
|
||||||
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"])
|
|
||||||
|
|
||||||
# Load OpenPGP private key into memory
|
|
||||||
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
|
||||||
SERVER_STATE["config"] = config
|
|
||||||
SERVER_STATE["private_key_obj"] = priv_key_obj
|
|
||||||
|
|
||||||
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" 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__":
|
|
||||||
main()
|
main()
|
||||||
@@ -1,310 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import argparse
|
|
||||||
import warnings
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
|
|
||||||
# Suppress cryptography / pgpy deprecation notices
|
_CUR = os.path.dirname(__file__)
|
||||||
warnings.filterwarnings("ignore")
|
_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:
|
from src.clients.Win_Client import *
|
||||||
import win32evtlog
|
|
||||||
except ImportError:
|
|
||||||
win32evtlog = None
|
|
||||||
|
|
||||||
CONFIG_FILE_NAME = "client_config.json"
|
if __name__ == '__main__':
|
||||||
STATE_FILE_NAME = "client_state.json"
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
|
||||||
over an authenticated TCP socket. Zero local state is maintained on the client.
|
|
||||||
"""
|
|
||||||
server_host = config["server_host"]
|
|
||||||
server_port = int(config["server_port"])
|
|
||||||
auth_token = config["auth_token"]
|
|
||||||
pub_key_armored = config["server_public_key"]
|
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
|
||||||
|
|
||||||
# Load and verify server public key
|
|
||||||
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}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare zero-state candidate batch
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_json = json.dumps(payload)
|
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
|
||||||
encrypted_armored = str(encrypted_msg)
|
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
|
||||||
"auth_token": auth_token,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"encrypted_payload": encrypted_armored
|
|
||||||
}
|
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
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))
|
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
|
||||||
if not resp_len_bytes:
|
|
||||||
raise ConnectionError("Server closed connection without response.")
|
|
||||||
|
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
|
||||||
resp_bytes = bytearray()
|
|
||||||
while len(resp_bytes) < resp_len:
|
|
||||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
resp_bytes.extend(chunk)
|
|
||||||
|
|
||||||
response = json.loads(resp_bytes.decode("utf-8"))
|
|
||||||
print(f"[+] Server response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
|
||||||
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
|
||||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
|
||||||
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
|
||||||
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = load_config(args.config)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Configuration error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
state_path = get_state_path(args.config, args.state_file)
|
|
||||||
state = None if args.no_state else load_state(state_path)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
|
||||||
if state and "last_record_number" in state:
|
|
||||||
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
|
|
||||||
elif not args.no_state:
|
|
||||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
|
||||||
|
|
||||||
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
|
||||||
candidate_logs = get_recent_windows_logs(hours=args.hours, state=state)
|
|
||||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
|
||||||
|
|
||||||
if not candidate_logs:
|
|
||||||
print("[*] No new unsent events to transmit.")
|
|
||||||
if state is not None:
|
|
||||||
commit_state(state, state_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
|
||||||
if state is not None and resp and resp.get("status") == "success":
|
|
||||||
commit_state(state, state_path)
|
|
||||||
print(f"[+] State successfully committed to {state_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Failed to stream logs to server: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
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,174 @@
|
|||||||
|
"""
|
||||||
|
LOGAR Windows Edge Log Forwarder
|
||||||
|
Collects Windows Event Logs (Application/System) and securely streams them
|
||||||
|
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
import warnings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Ensure script directory and repo root are in sys.path
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if SCRIPT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SCRIPT_DIR)
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
|
||||||
|
if REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, REPO_ROOT)
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import pgpy
|
||||||
|
|
||||||
|
try:
|
||||||
|
import win32evtlog
|
||||||
|
except ImportError:
|
||||||
|
win32evtlog = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.clients.log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
get_state_path,
|
||||||
|
load_state,
|
||||||
|
save_state,
|
||||||
|
commit_state,
|
||||||
|
WindowsLogCollector,
|
||||||
|
BaseLogCollector
|
||||||
|
)
|
||||||
|
from src.clients.client_transport import (
|
||||||
|
is_cert_expiring_soon,
|
||||||
|
enroll_client_if_needed,
|
||||||
|
get_tls_socket,
|
||||||
|
load_config,
|
||||||
|
ensure_enrolled as transport_ensure_enrolled,
|
||||||
|
send_encrypted_logs_over_socket as transport_send_encrypted_logs,
|
||||||
|
check_and_apply_auto_update
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
from log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
get_state_path,
|
||||||
|
load_state,
|
||||||
|
save_state,
|
||||||
|
commit_state,
|
||||||
|
WindowsLogCollector,
|
||||||
|
BaseLogCollector
|
||||||
|
)
|
||||||
|
from client_transport import (
|
||||||
|
is_cert_expiring_soon,
|
||||||
|
enroll_client_if_needed,
|
||||||
|
get_tls_socket,
|
||||||
|
load_config,
|
||||||
|
ensure_enrolled as transport_ensure_enrolled,
|
||||||
|
send_encrypted_logs_over_socket as transport_send_encrypted_logs,
|
||||||
|
check_and_apply_auto_update
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
||||||
|
"""
|
||||||
|
Scans the Windows Application Event Log backwards for events within the window.
|
||||||
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||||
|
State Tracking: Skips events older than lookback window and already sent records.
|
||||||
|
"""
|
||||||
|
collector = WindowsLogCollector(state=state, channels=["Application"])
|
||||||
|
return collector.collect_logs(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
|
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||||
|
return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||||
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
|
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
||||||
|
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
||||||
|
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
||||||
|
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
||||||
|
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
||||||
|
parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config(args.config)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Configuration error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
state_path = get_state_path(args.config, args.state_file)
|
||||||
|
state = None if args.no_state else load_state(state_path)
|
||||||
|
|
||||||
|
machine_id = config.get("client_id") or get_machine_identifier()
|
||||||
|
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||||
|
if state and "last_record_number" in state:
|
||||||
|
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
|
||||||
|
elif not args.no_state:
|
||||||
|
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||||
|
|
||||||
|
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
|
||||||
|
ensure_enrolled(config, machine_id)
|
||||||
|
|
||||||
|
collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"])
|
||||||
|
|
||||||
|
def run_forwarder_cycle():
|
||||||
|
# Continuously verify/renew PKI enrollment if server is licensed & online
|
||||||
|
ensure_enrolled(config, machine_id)
|
||||||
|
|
||||||
|
# Periodically check server for client auto-updates
|
||||||
|
try:
|
||||||
|
check_and_apply_auto_update(config=config, machine_id=machine_id, os_type="windows")
|
||||||
|
except Exception as update_err:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
||||||
|
candidate_logs = collector.collect_logs(hours=args.hours)
|
||||||
|
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||||
|
|
||||||
|
if not candidate_logs:
|
||||||
|
print("[*] No new unsent events to transmit.")
|
||||||
|
if state is not None:
|
||||||
|
commit_state(state, state_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
||||||
|
if state is not None and resp and resp.get("status") == "success":
|
||||||
|
commit_state(state, state_path)
|
||||||
|
print(f"[+] State successfully committed to {state_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Failed to stream logs to server: {e}")
|
||||||
|
if not args.interval:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.interval and args.interval > 0:
|
||||||
|
print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
run_forwarder_cycle()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error in forwarder cycle: {e}")
|
||||||
|
time.sleep(args.interval)
|
||||||
|
else:
|
||||||
|
run_forwarder_cycle()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# LOGAR Clients Package
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
LOGAR - Unified Client Transport & PKI Enrollment Module
|
||||||
|
Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing,
|
||||||
|
and dynamic certificate enrollment with the LOGAR Server Hub.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import urllib.request
|
||||||
|
import warnings
|
||||||
|
import pgpy
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.clients.log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
collect_system_metrics
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
from log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
collect_system_metrics
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.common.version import LOGAR_VERSION
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from src.version import LOGAR_VERSION
|
||||||
|
except ImportError:
|
||||||
|
from version import LOGAR_VERSION
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.clients.client_updater import ClientUpdater
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from src.client_updater import ClientUpdater
|
||||||
|
except ImportError:
|
||||||
|
from client_updater import ClientUpdater
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||||
|
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||||
|
if not os.path.exists(cert_path):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as f:
|
||||||
|
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||||
|
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||||
|
if expiry is None:
|
||||||
|
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return expiry <= (now + timedelta(days=threshold_days))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_client_if_needed(
|
||||||
|
hub_url: str,
|
||||||
|
enrollment_secret: str,
|
||||||
|
cert_dir: str,
|
||||||
|
client_id: str,
|
||||||
|
hostname: str,
|
||||||
|
os_type: str = "windows",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> bool:
|
||||||
|
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||||
|
return True
|
||||||
|
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||||
|
|
||||||
|
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||||
|
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||||
|
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||||
|
payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os": os_type,
|
||||||
|
"enrollment_secret": enrollment_secret,
|
||||||
|
"version": LOGAR_VERSION
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
enroll_endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_key"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||||
|
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||||
|
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||||
|
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.check_hostname = False
|
||||||
|
|
||||||
|
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||||
|
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = CONFIG_FILE_NAME) -> dict:
|
||||||
|
"""Loads and normalizes client configuration JSON."""
|
||||||
|
actual_config_path = config_path
|
||||||
|
if not os.path.exists(actual_config_path):
|
||||||
|
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
|
||||||
|
if os.path.exists(data_dir_cfg):
|
||||||
|
actual_config_path = data_dir_cfg
|
||||||
|
|
||||||
|
if not os.path.exists(actual_config_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Client configuration file not found at: {config_path}\n"
|
||||||
|
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
||||||
|
)
|
||||||
|
with open(actual_config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Resolve cert_dir to a writable directory
|
||||||
|
cfg_dir = os.path.dirname(os.path.abspath(actual_config_path))
|
||||||
|
raw_cert_dir = config.get("cert_dir", "certs")
|
||||||
|
if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir):
|
||||||
|
config["cert_dir"] = raw_cert_dir
|
||||||
|
else:
|
||||||
|
candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir))
|
||||||
|
if is_path_writable(candidate) or is_path_writable(cfg_dir):
|
||||||
|
config["cert_dir"] = candidate
|
||||||
|
else:
|
||||||
|
config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs")
|
||||||
|
|
||||||
|
# If ca_cert is present in config and ca.crt doesn't exist yet, save it
|
||||||
|
ca_cert_pem = config.get("ca_cert")
|
||||||
|
if ca_cert_pem and isinstance(ca_cert_pem, str):
|
||||||
|
ca_file = os.path.join(config["cert_dir"], "ca.crt")
|
||||||
|
if not os.path.exists(ca_file):
|
||||||
|
try:
|
||||||
|
os.makedirs(config["cert_dir"], exist_ok=True)
|
||||||
|
with open(ca_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(ca_cert_pem)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool:
|
||||||
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
|
if not enrollment_secret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_host = config.get("server_host", "127.0.0.1")
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None, ""):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_encrypted_logs_over_socket(
|
||||||
|
config: dict,
|
||||||
|
logs: list,
|
||||||
|
os_type: Optional[str] = None,
|
||||||
|
machine_id: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Streams logs to the LOGAR hub.
|
||||||
|
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||||
|
or falls back to OpenPGP encrypted envelope over TCP socket.
|
||||||
|
"""
|
||||||
|
node_os = os_type or ("windows" if sys.platform == "win32" else "linux")
|
||||||
|
server_host = config["server_host"]
|
||||||
|
server_port = int(config["server_port"])
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
|
actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier()
|
||||||
|
|
||||||
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if not has_mtls_certs and enrollment_secret and hub_url:
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment before TLS connection failed: {e}")
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": actual_machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs,
|
||||||
|
"metrics": collect_system_metrics(),
|
||||||
|
"version": LOGAR_VERSION
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
|
if expected_fp and actual_fp != expected_fp:
|
||||||
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"server": actual_machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs,
|
||||||
|
"metrics": collect_system_metrics(),
|
||||||
|
"version": LOGAR_VERSION
|
||||||
|
}
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"auth_token": auth_token,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"encrypted_payload": encrypted_armored
|
||||||
|
}
|
||||||
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(15.0)
|
||||||
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]:
|
||||||
|
"""Helper to check and apply auto updates for edge forwarders."""
|
||||||
|
updater = ClientUpdater(config=config, os_type=os_type)
|
||||||
|
return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply)
|
||||||
@@ -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,679 @@
|
|||||||
|
"""
|
||||||
|
LOGAR - Unified Cross-Platform Log Collector & State Management
|
||||||
|
Provides modular log collection classes and state management for Windows and Linux,
|
||||||
|
reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import shutil
|
||||||
|
import ctypes
|
||||||
|
import subprocess
|
||||||
|
import warnings
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any, Set
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import win32evtlog
|
||||||
|
except ImportError:
|
||||||
|
win32evtlog = None
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_system_metrics() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Collects cross-platform host health and resource telemetry without requiring external wheels.
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"cpu_percent": float,
|
||||||
|
"memory_percent": float,
|
||||||
|
"memory_total_mb": int,
|
||||||
|
"memory_free_mb": int,
|
||||||
|
"disk_percent_free": float,
|
||||||
|
"disk_total_gb": float,
|
||||||
|
"disk_free_gb": float
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
metrics = {
|
||||||
|
"cpu_percent": 0.0,
|
||||||
|
"memory_percent": 0.0,
|
||||||
|
"memory_total_mb": 0,
|
||||||
|
"memory_free_mb": 0,
|
||||||
|
"disk_percent_free": 0.0,
|
||||||
|
"disk_total_gb": 0.0,
|
||||||
|
"disk_free_gb": 0.0
|
||||||
|
}
|
||||||
|
# 1. Disk metrics
|
||||||
|
try:
|
||||||
|
path = "C:\\" if sys.platform == "win32" else "/"
|
||||||
|
total, used, free = shutil.disk_usage(path)
|
||||||
|
metrics["disk_total_gb"] = round(total / (1024 ** 3), 2)
|
||||||
|
metrics["disk_free_gb"] = round(free / (1024 ** 3), 2)
|
||||||
|
if total > 0:
|
||||||
|
metrics["disk_percent_free"] = round((free / total) * 100, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Memory metrics
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
class MEMORYSTATUSEX(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("dwLength", ctypes.c_ulong),
|
||||||
|
("dwMemoryLoad", ctypes.c_ulong),
|
||||||
|
("ullTotalPhys", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPhys", ctypes.c_ulonglong),
|
||||||
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||||
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||||
|
("sullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||||
|
]
|
||||||
|
stat = MEMORYSTATUSEX()
|
||||||
|
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
||||||
|
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
|
||||||
|
metrics["memory_percent"] = float(stat.dwMemoryLoad)
|
||||||
|
metrics["memory_total_mb"] = int(stat.ullTotalPhys / (1024 * 1024))
|
||||||
|
metrics["memory_free_mb"] = int(stat.ullAvailPhys / (1024 * 1024))
|
||||||
|
else:
|
||||||
|
# Linux: read /proc/meminfo
|
||||||
|
if os.path.exists("/proc/meminfo"):
|
||||||
|
mem_total = 0
|
||||||
|
mem_avail = 0
|
||||||
|
with open("/proc/meminfo", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
k, v = parts[0].strip(), parts[1].strip().split()[0]
|
||||||
|
if k == "MemTotal":
|
||||||
|
mem_total = int(v)
|
||||||
|
elif k in ("MemAvailable", "MemFree") and mem_avail == 0:
|
||||||
|
mem_avail = int(v)
|
||||||
|
if mem_total > 0:
|
||||||
|
metrics["memory_total_mb"] = mem_total // 1024
|
||||||
|
metrics["memory_free_mb"] = mem_avail // 1024
|
||||||
|
metrics["memory_percent"] = round(((mem_total - mem_avail) / mem_total) * 100, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. CPU metrics
|
||||||
|
try:
|
||||||
|
if hasattr(os, "getloadavg"):
|
||||||
|
load1, _, _ = os.getloadavg()
|
||||||
|
cpu_count = os.cpu_count() or 1
|
||||||
|
metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
metrics["cpu_percent"] = float(psutil.cpu_percent(interval=None))
|
||||||
|
except ImportError:
|
||||||
|
metrics["cpu_percent"] = 0.0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def is_routine_service_noise(signature: str, message: str, severity: str) -> bool:
|
||||||
|
"""
|
||||||
|
Identifies high-frequency benign background service start/stop cycles
|
||||||
|
(e.g., sppsvc, edgeupdate, trustedinstaller, wuauserv, backgroundTaskHost)
|
||||||
|
to prevent false-positive anomaly fatigue and consecutive_run metric bloat.
|
||||||
|
"""
|
||||||
|
if str(severity).upper() != "INFO":
|
||||||
|
return False
|
||||||
|
sig_low = (signature or "").lower()
|
||||||
|
msg_low = (message or "").lower()
|
||||||
|
routine_services = [
|
||||||
|
"sppsvc",
|
||||||
|
"edgeupdate",
|
||||||
|
"trustedinstaller",
|
||||||
|
"wuauserv",
|
||||||
|
"backgroundtaskhost",
|
||||||
|
"service control manager",
|
||||||
|
"systemd",
|
||||||
|
"cron"
|
||||||
|
]
|
||||||
|
is_routine_svc = any(s in sig_low or s in msg_low for s in routine_services)
|
||||||
|
if is_routine_svc:
|
||||||
|
lifecycle_phrases = [
|
||||||
|
"entered the running state",
|
||||||
|
"entered the stopped state",
|
||||||
|
"entered the start pending state",
|
||||||
|
"entered the stop pending state",
|
||||||
|
"started successfully",
|
||||||
|
"stopped successfully",
|
||||||
|
"service stopped",
|
||||||
|
"service started",
|
||||||
|
"session opened",
|
||||||
|
"session closed",
|
||||||
|
"clean",
|
||||||
|
"reloaded"
|
||||||
|
]
|
||||||
|
if any(p in msg_low for p in lifecycle_phrases):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_identifier() -> str:
|
||||||
|
"""
|
||||||
|
Returns the fully qualified hostname of the machine sending/collecting logs.
|
||||||
|
Cross-platform resolution:
|
||||||
|
1. FQDN (if non-local)
|
||||||
|
2. Windows USERDNSDOMAIN (on Windows)
|
||||||
|
3. Linux /etc/resolv.conf domain/search directive (on Linux)
|
||||||
|
4. Reverse DNS lookup
|
||||||
|
5. socket.gethostname() fallback
|
||||||
|
"""
|
||||||
|
fqdn = socket.getfqdn()
|
||||||
|
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
||||||
|
return fqdn
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
|
||||||
|
# Windows DNS domain
|
||||||
|
if sys.platform == "win32":
|
||||||
|
user_dns_domain = os.environ.get("USERDNSDOMAIN")
|
||||||
|
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
|
||||||
|
return f"{hostname}.{user_dns_domain.lower()}"
|
||||||
|
|
||||||
|
# Linux resolv.conf search / domain
|
||||||
|
if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"):
|
||||||
|
try:
|
||||||
|
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
||||||
|
domain = parts[1]
|
||||||
|
if domain and not domain.startswith("."):
|
||||||
|
return f"{hostname}.{domain}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reverse DNS lookup
|
||||||
|
try:
|
||||||
|
host_ip = socket.gethostbyname(hostname)
|
||||||
|
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
||||||
|
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
||||||
|
return canonical_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_writable(filepath: str) -> bool:
|
||||||
|
"""Checks if a file or directory path is actually writable by the current process."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
if os.path.isdir(filepath):
|
||||||
|
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
with open(filepath, "a") as f:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_data_dir(app_name: str = "LOGAR") -> str:
|
||||||
|
"""Returns a suitable writable application data directory."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# 1. ProgramData (C:\ProgramData\LOGAR)
|
||||||
|
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
||||||
|
target_prog = os.path.join(prog_data, app_name)
|
||||||
|
if is_path_writable(target_prog):
|
||||||
|
return target_prog
|
||||||
|
|
||||||
|
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
|
||||||
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
||||||
|
target_local = os.path.join(local_app, app_name)
|
||||||
|
if is_path_writable(target_local):
|
||||||
|
return target_local
|
||||||
|
|
||||||
|
# 3. Temp directory (%TEMP%\LOGAR)
|
||||||
|
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
||||||
|
if is_path_writable(temp_dir):
|
||||||
|
return temp_dir
|
||||||
|
else:
|
||||||
|
# Linux / Unix candidates: /var/lib/<app_name>, /tmp/<app_name>, ~/.local/share/<app_name>
|
||||||
|
var_lib = f"/var/lib/{app_name.lower()}"
|
||||||
|
if is_path_writable(var_lib):
|
||||||
|
return var_lib
|
||||||
|
user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}")
|
||||||
|
if is_path_writable(user_share):
|
||||||
|
return user_share
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
if is_path_writable(cwd):
|
||||||
|
return cwd
|
||||||
|
return os.path.expanduser("~")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
|
||||||
|
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
||||||
|
if not path:
|
||||||
|
path = default_filename
|
||||||
|
|
||||||
|
if os.path.isabs(path) and is_path_writable(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
||||||
|
return os.path.abspath(path)
|
||||||
|
|
||||||
|
data_dir = get_default_data_dir(app_name)
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
return os.path.join(data_dir, fname)
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
|
if custom_state_path:
|
||||||
|
return custom_state_path
|
||||||
|
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||||
|
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
|
||||||
|
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
|
||||||
|
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(state_path: str) -> dict:
|
||||||
|
"""Loads state tracking dictionary from disk."""
|
||||||
|
if os.path.exists(state_path):
|
||||||
|
try:
|
||||||
|
with open(state_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state_path: str, state: dict):
|
||||||
|
"""Atomically saves state dictionary to disk."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
|
||||||
|
temp_path = f"{state_path}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
os.replace(temp_path, state_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def commit_state(state: dict, state_path: str):
|
||||||
|
"""
|
||||||
|
Commits staged cursor/record state keys to persistent fields and writes to state_path.
|
||||||
|
Supports both Windows record numbers and Linux cursors/timestamps.
|
||||||
|
"""
|
||||||
|
if "new_last_record_number" in state:
|
||||||
|
val = state.pop("new_last_record_number")
|
||||||
|
if val:
|
||||||
|
state["last_record_number"] = val
|
||||||
|
if "new_sent_record_ids" in state:
|
||||||
|
state["sent_record_ids"] = state.pop("new_sent_record_ids")
|
||||||
|
if "new_last_cursor" in state:
|
||||||
|
val = state.pop("new_last_cursor")
|
||||||
|
if val:
|
||||||
|
state["last_cursor"] = val
|
||||||
|
if "new_last_timestamp_us" in state:
|
||||||
|
val = state.pop("new_last_timestamp_us")
|
||||||
|
if val:
|
||||||
|
state["last_timestamp_us"] = val
|
||||||
|
if "new_sent_cursors" in state:
|
||||||
|
state["sent_cursors"] = state.pop("new_sent_cursors")
|
||||||
|
save_state(state_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseLogCollector(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for platform-specific edge and hub log collectors.
|
||||||
|
Manages state persistence, machine identifiers, lookback windows, and normalization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||||
|
self.state = state if state is not None else {}
|
||||||
|
self.machine_id = machine_id or get_machine_identifier()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Collects normalized logs within the given lookback window.
|
||||||
|
Returns a list of dicts:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"server": str,
|
||||||
|
"os_type": "windows"|"linux",
|
||||||
|
"signature": str,
|
||||||
|
"severity": "ERROR"|"WARNING"|"INFO",
|
||||||
|
"message": str
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self, state_path: Optional[str] = None):
|
||||||
|
"""Commits staged state keys and optionally writes to disk."""
|
||||||
|
if state_path:
|
||||||
|
commit_state(self.state, state_path)
|
||||||
|
else:
|
||||||
|
if "new_last_record_number" in self.state:
|
||||||
|
val = self.state.pop("new_last_record_number")
|
||||||
|
if val:
|
||||||
|
self.state["last_record_number"] = val
|
||||||
|
if "new_sent_record_ids" in self.state:
|
||||||
|
self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids")
|
||||||
|
if "new_last_cursor" in self.state:
|
||||||
|
val = self.state.pop("new_last_cursor")
|
||||||
|
if val:
|
||||||
|
self.state["last_cursor"] = val
|
||||||
|
if "new_last_timestamp_us" in self.state:
|
||||||
|
val = self.state.pop("new_last_timestamp_us")
|
||||||
|
if val:
|
||||||
|
self.state["last_timestamp_us"] = val
|
||||||
|
if "new_sent_cursors" in self.state:
|
||||||
|
self.state["sent_cursors"] = self.state.pop("new_sent_cursors")
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsLogCollector(BaseLogCollector):
|
||||||
|
"""
|
||||||
|
Windows Event Log Collector.
|
||||||
|
Reads Application and System (or custom channels) backwards.
|
||||||
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||||
|
State Tracking: Skips events older than lookback window and already sent records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state: Optional[dict] = None,
|
||||||
|
machine_id: Optional[str] = None,
|
||||||
|
channels: Optional[List[str]] = None
|
||||||
|
):
|
||||||
|
super().__init__(state=state, machine_id=machine_id)
|
||||||
|
self.channels = channels or ["Application"]
|
||||||
|
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
if win32evtlog is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
last_record_number = 0
|
||||||
|
sent_record_ids: Set[str] = set()
|
||||||
|
if self.state:
|
||||||
|
last_record_number = int(self.state.get("last_record_number", 0))
|
||||||
|
sent_record_ids = set(self.state.get("sent_record_ids", []))
|
||||||
|
|
||||||
|
# Windows Event Log EventTypes:
|
||||||
|
# 1: EVENTLOG_ERROR_TYPE -> ERROR
|
||||||
|
# 2: EVENTLOG_WARNING_TYPE -> WARNING
|
||||||
|
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
|
||||||
|
sev_map = {
|
||||||
|
1: "ERROR",
|
||||||
|
2: "WARNING",
|
||||||
|
4: "INFO"
|
||||||
|
}
|
||||||
|
|
||||||
|
newest_record_number = 0
|
||||||
|
collected_record_ids = []
|
||||||
|
|
||||||
|
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||||
|
|
||||||
|
for log_type in self.channels:
|
||||||
|
try:
|
||||||
|
hand = win32evtlog.OpenEventLog("localhost", log_type)
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not events:
|
||||||
|
break
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
rec_num = int(event.RecordNumber)
|
||||||
|
if newest_record_number == 0:
|
||||||
|
newest_record_number = rec_num
|
||||||
|
|
||||||
|
# 1. Skip entries older than lookback window
|
||||||
|
if event.TimeGenerated < cutoff_time:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. Skip already sent events
|
||||||
|
if last_record_number > 0 and newest_record_number >= last_record_number:
|
||||||
|
if rec_num <= last_record_number:
|
||||||
|
break
|
||||||
|
|
||||||
|
rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}"
|
||||||
|
if rec_id in sent_record_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter: upload everything from INFO to ERROR
|
||||||
|
if event.EventType in sev_map:
|
||||||
|
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
|
||||||
|
sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event")
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "windows",
|
||||||
|
"signature": sig,
|
||||||
|
"severity": sev_map[event.EventType],
|
||||||
|
"message": msg[:2048]
|
||||||
|
})
|
||||||
|
collected_record_ids.append(rec_id)
|
||||||
|
|
||||||
|
if events[-1].TimeGenerated < cutoff_time:
|
||||||
|
break
|
||||||
|
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
win32evtlog.CloseEventLog(hand)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
target_rec = max(newest_record_number, last_record_number)
|
||||||
|
self.state["new_last_record_number"] = target_rec
|
||||||
|
self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
|
||||||
|
# Also update direct keys for immediate server-side ingestion
|
||||||
|
self.state["last_record_number"] = target_rec
|
||||||
|
self.state["sent_record_ids"] = self.state["new_sent_record_ids"]
|
||||||
|
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxLogCollector(BaseLogCollector):
|
||||||
|
"""
|
||||||
|
Linux Systemd Journal / Syslog Log Collector.
|
||||||
|
Collects info, warnings, and errors from journalctl (or syslog fallback).
|
||||||
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise.
|
||||||
|
State Tracking: Skips events older than lookback window and already sent cursors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||||
|
super().__init__(state=state, machine_id=machine_id)
|
||||||
|
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
last_cursor = None
|
||||||
|
last_timestamp_us = 0.0
|
||||||
|
sent_cursors: Set[str] = set()
|
||||||
|
if self.state:
|
||||||
|
last_cursor = self.state.get("last_cursor")
|
||||||
|
try:
|
||||||
|
last_timestamp_us = float(self.state.get("last_timestamp_us", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
last_timestamp_us = 0.0
|
||||||
|
sent_cursors = set(self.state.get("sent_cursors", []))
|
||||||
|
|
||||||
|
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||||
|
result = None
|
||||||
|
|
||||||
|
if last_cursor:
|
||||||
|
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
|
||||||
|
if res.returncode == 0:
|
||||||
|
result = res
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# journalctl not present, attempt fallback to /var/log/syslog or messages
|
||||||
|
return self._collect_syslog_fallback(hours=hours)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error running journalctl: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result or result.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
|
||||||
|
|
||||||
|
newest_cursor = None
|
||||||
|
newest_timestamp_us = last_timestamp_us
|
||||||
|
collected_cursors = []
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line_str = line.strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line_str)
|
||||||
|
entry_cursor = entry.get("__CURSOR")
|
||||||
|
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
|
||||||
|
|
||||||
|
entry_ts_us = 0.0
|
||||||
|
if entry_ts_us_raw:
|
||||||
|
try:
|
||||||
|
entry_ts_us = float(entry_ts_us_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 1. Skip entries older than lookback window
|
||||||
|
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Skip already sent events
|
||||||
|
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
|
||||||
|
continue
|
||||||
|
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Advance tracking
|
||||||
|
if entry_cursor:
|
||||||
|
newest_cursor = entry_cursor
|
||||||
|
collected_cursors.append(entry_cursor)
|
||||||
|
if entry_ts_us > newest_timestamp_us:
|
||||||
|
newest_timestamp_us = entry_ts_us
|
||||||
|
|
||||||
|
priority = int(entry.get("PRIORITY", "6"))
|
||||||
|
if priority > 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if priority <= 3:
|
||||||
|
sev = "ERROR"
|
||||||
|
elif priority in (4, 5):
|
||||||
|
sev = "WARNING"
|
||||||
|
else:
|
||||||
|
sev = "INFO"
|
||||||
|
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "linux",
|
||||||
|
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
||||||
|
"severity": sev,
|
||||||
|
"message": entry.get("MESSAGE", "")[:2048]
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
self.state["new_last_cursor"] = newest_cursor or last_cursor
|
||||||
|
self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
|
||||||
|
self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
|
||||||
|
# Also update direct keys for immediate server-side ingestion
|
||||||
|
self.state["last_cursor"] = self.state["new_last_cursor"]
|
||||||
|
self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"]
|
||||||
|
self.state["sent_cursors"] = self.state["new_sent_cursors"]
|
||||||
|
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
"""Fallback log reader for non-systemd Linux environments."""
|
||||||
|
candidates = ["/var/log/syslog", "/var/log/messages"]
|
||||||
|
target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None)
|
||||||
|
if not target:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
try:
|
||||||
|
with open(target, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
lines = f.readlines()[-500:] # Read recent 500 lines
|
||||||
|
for line in lines:
|
||||||
|
line_str = line.strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
sev = "INFO"
|
||||||
|
lower_l = line_str.lower()
|
||||||
|
if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]):
|
||||||
|
sev = "ERROR"
|
||||||
|
elif any(w in lower_l for w in ["warn", "notice"]):
|
||||||
|
sev = "WARNING"
|
||||||
|
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "linux",
|
||||||
|
"signature": "syslog",
|
||||||
|
"severity": sev,
|
||||||
|
"message": line_str[:2048]
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_collector(
|
||||||
|
os_type: Optional[str] = None,
|
||||||
|
state: Optional[dict] = None,
|
||||||
|
machine_id: Optional[str] = None,
|
||||||
|
channels: Optional[List[str]] = None
|
||||||
|
) -> BaseLogCollector:
|
||||||
|
"""
|
||||||
|
Factory helper that returns the appropriate BaseLogCollector instance.
|
||||||
|
If os_type is omitted, auto-detects based on sys.platform.
|
||||||
|
"""
|
||||||
|
target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux")
|
||||||
|
if target_os == "windows":
|
||||||
|
chans = channels or (["Application", "System"] if channels is None else ["Application"])
|
||||||
|
return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans)
|
||||||
|
else:
|
||||||
|
return LinuxLogCollector(state=state, machine_id=machine_id)
|
||||||