Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900d03b7f7 | ||
|
|
a8d59b2f14 | ||
|
|
b79022bc41 | ||
|
|
fcba813edd | ||
|
|
72e3527c79 | ||
|
|
4a40b4d395 | ||
|
|
399cf469c5 | ||
|
|
ce7d2b97d2 | ||
|
|
2e256baf4a | ||
|
|
1a984caaf7 | ||
|
|
96756ca5db | ||
|
|
157229a84e | ||
|
|
8287af3129 | ||
|
|
a133050cb5 | ||
|
|
37f959e402 | ||
|
|
f1e5e8f986 | ||
|
|
5a0fdb4374 | ||
|
|
fdfa8dfe7f | ||
|
|
b40966ac4c | ||
|
|
e47fe609a6 | ||
|
|
48bb279601 | ||
|
|
fd34fe9911 | ||
|
|
a7ff1c845d | ||
|
|
b0066c9ea6 | ||
|
|
079c6cb70f | ||
|
|
49525594a3 | ||
|
|
04ccc999a8 | ||
|
|
267aa54076 | ||
|
|
2c9f8d9b91 | ||
|
|
125870b135 | ||
|
|
f0df5a6e4d | ||
|
|
5b59b18c2c | ||
|
|
ec80908d5b | ||
|
|
92d858841d | ||
|
|
0e616d614c | ||
|
|
38b36c28a0 | ||
|
|
c51194e699 | ||
|
|
3a3d251817 | ||
|
|
25adb46d48 | ||
|
|
b33294f36c | ||
|
|
b8069ee66b | ||
|
|
d025689193 | ||
|
|
96126cdf84 | ||
|
|
17c1791b7d | ||
|
|
911014fae7 | ||
|
|
de95a3283d | ||
|
|
b0bf322275 | ||
|
|
a496b7e822 | ||
|
|
29784e166f | ||
|
|
56844983e2 | ||
|
|
07b8f48631 | ||
|
|
fa8b9e0af5 | ||
|
|
93d9f0e463 | ||
|
|
41cb956416 | ||
|
|
42916dc8ee | ||
|
|
a34934959d | ||
|
|
fe296b42e7 | ||
|
|
e04082d4b8 | ||
|
|
ad022cc447 | ||
|
|
344d57ff49 | ||
|
|
19c60616b1 | ||
|
|
14b3586b10 | ||
|
|
a9d5d641cd | ||
|
|
1d8449515e | ||
|
|
668f6e66cd | ||
|
|
6ac2c89aac | ||
|
|
cce439f0e2 | ||
|
|
2c6265b6cc | ||
|
|
4396052ed3 | ||
|
|
c2fed0379c | ||
|
|
e01421ea8c | ||
|
|
2e10aa4819 | ||
|
|
777d6300a8 | ||
|
|
c3bb440eff | ||
|
|
ea3a2cd541 | ||
|
|
8c6d3f57f9 | ||
|
|
f7584320f9 | ||
|
|
d9d895e468 | ||
|
|
4c8db1ec94 | ||
|
|
5fe74325ec | ||
|
|
871d3c15ab | ||
|
|
7c57cf0645 | ||
|
|
2d75b595e6 | ||
|
|
d152b65f6e | ||
|
|
eced9bc5a1 | ||
|
|
3292702dd3 | ||
|
|
fac6ae8847 | ||
|
|
f4996a53cc | ||
|
|
05f703b024 | ||
|
|
eb628ff40b | ||
|
|
79a2720363 | ||
|
|
27de5e9e06 | ||
|
|
dacf01d5b5 | ||
|
|
432a488019 | ||
|
|
384d096de2 | ||
|
|
85bae117b6 | ||
|
|
c8b309d0af | ||
|
|
3b2c3e0fd7 | ||
|
|
b7da54feb2 | ||
|
|
2cd7a990b5 | ||
|
|
2980ad2268 | ||
|
|
95c33c1bf3 | ||
|
|
561ce2e037 | ||
|
|
e89ff9fa27 | ||
|
|
494fef07ba | ||
|
|
4be0107329 | ||
|
|
914db938c3 | ||
|
|
506d3fc234 | ||
|
|
ff5caaf8c7 | ||
|
|
63e8ff5492 | ||
|
|
18e28e2797 | ||
|
|
0e9e3f0f64 | ||
|
|
ea8e98218c | ||
|
|
d9cfb06fad | ||
|
|
76812a1711 | ||
|
|
9584b2c229 | ||
|
|
c9e9a5d267 | ||
|
|
91aee0e591 | ||
|
|
86ae3a11bc | ||
|
|
76d53fbd92 | ||
|
|
39790d371b | ||
|
|
6300481349 | ||
|
|
59029aa3d9 | ||
|
|
431352ddce | ||
|
|
52be1f23a6 | ||
|
|
353001b40f | ||
|
|
99743d9074 | ||
|
|
cbda93517b | ||
|
|
38b634b68f | ||
|
|
16539837c0 | ||
|
|
7b9160af9c | ||
|
|
f72bb06115 | ||
|
|
840daf0688 | ||
|
|
947a786868 | ||
|
|
b457c7575f | ||
|
|
ed9c4aa9d6 | ||
|
|
e4ea8f8deb | ||
|
|
6399912995 | ||
|
|
e77437a042 | ||
|
|
1c2d46cef8 | ||
|
|
9ff116fc4d | ||
|
|
983312573d | ||
|
|
bc2cc65571 | ||
|
|
4b74ea23af | ||
|
|
82eb10889d | ||
|
|
efa9ea7720 | ||
|
|
25a36c8092 | ||
|
|
4171649bd3 | ||
|
|
5670965e9d | ||
|
|
032a5a61b4 | ||
|
|
b1327a4336 | ||
|
|
6332dff670 |
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build and Push LOGAR Runner
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -15,19 +15,18 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Run Component Tests & Pipeline Verification
|
name: Run Component Tests & Pipeline Verification
|
||||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: logar
|
||||||
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: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
python3 compilation/ensure_pip.py
|
||||||
apt-get update -y
|
python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true
|
||||||
apt-get install -y python3 python3-pip python3-venv curl
|
python3 -m pip install -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install -r compilation/requirements.txt
|
||||||
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: |
|
||||||
@@ -35,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
- 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: |
|
||||||
@@ -50,10 +49,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,38 +1,72 @@
|
|||||||
name: Release Binaries & Installers
|
name: Release Binaries & Packages
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
description: 'Release tag to publish assets to (default: v2.0.1)'
|
description: 'Release tag to publish assets to (default: v0.3.1)'
|
||||||
required: false
|
required: false
|
||||||
default: 'v2.0.1'
|
default: 'v0.3.1'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-linux:
|
release:
|
||||||
name: Build & Release Linux Binaries
|
name: Build & Release Standalone Binaries (Linux & Windows Cross-Compilation)
|
||||||
runs-on: ubuntu-latest
|
runs-on: logar
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Python and Build Dependencies
|
- name: Install System and Cross-Compilation Build Dependencies
|
||||||
run: |
|
run: |
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
echo "=== SYSTEM DIAGNOSTICS ==="
|
||||||
apt-get update -y
|
uname -a || true
|
||||||
apt-get install -y python3 python3-pip python3-venv binutils zip
|
python3 --version || true
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
fi
|
fi
|
||||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -r compilation/requirements.txt
|
echo "[*] Installing Python shared libraries, headers, NSIS via apt-get..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
$SUDO apt-get clean || true
|
||||||
|
$SUDO apt-get update --allow-releaseinfo-change -o Acquire::Check-Valid-Until=false -y || $SUDO apt-get update -y || true
|
||||||
|
$SUDO apt-get -f install -y || true
|
||||||
|
PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3.9")
|
||||||
|
echo "[*] Detected Python version: ${PY_VER}"
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends \
|
||||||
|
nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" \
|
||||||
|
python3-dev libpython3-dev build-essential gcc make curl tar gzip zip unzip || true
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends nsis nsis-common || true
|
||||||
|
for pkg in nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" python3-dev libpython3-dev build-essential; do
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends "$pkg" || true
|
||||||
|
done
|
||||||
|
echo "[*] Verifying makensis:"
|
||||||
|
which makensis || echo "makensis not in PATH"
|
||||||
|
echo "[*] Verifying libpython shared library:"
|
||||||
|
find /usr/lib /usr/local/lib -name "libpython*.so*" 2>/dev/null || true
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
echo "[*] Installing dependencies via apk..."
|
||||||
|
$SUDO apk add --no-cache nsis python3-dev build-base gcc make curl tar gzip zip unzip || true
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo "[*] Installing dependencies via dnf..."
|
||||||
|
$SUDO dnf install -y mingw32-nsis nsis python3-devel gcc make curl tar gzip zip unzip || true
|
||||||
|
fi
|
||||||
|
python3 compilation/ensure_pip.py
|
||||||
|
python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true
|
||||||
|
python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt
|
||||||
|
|
||||||
- name: Compile Standalone Linux Binaries
|
- name: Compile Standalone Binaries (Linux & Windows)
|
||||||
run: |
|
run: |
|
||||||
python3 compilation/package_dist.py --target linux
|
python3 compilation/package_dist.py --target all
|
||||||
|
|
||||||
- name: Publish Linux Release Assets
|
- name: Publish Unified Release Assets
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
@@ -40,85 +74,3 @@ jobs:
|
|||||||
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
python3 compilation/upload_release.py --skip-build
|
python3 compilation/upload_release.py --skip-build
|
||||||
|
|
||||||
release-windows:
|
|
||||||
name: Build & Release Windows Binaries & Installers
|
|
||||||
# Note: Requires a registered Gitea Act Runner with label 'windows-latest'
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repository
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$server = "${{ github.server_url }}"
|
|
||||||
$token = "${{ secrets.TAG_TOKEN || github.token }}"
|
|
||||||
$repo = "${{ github.repository }}"
|
|
||||||
$cleanUrl = $server -replace "^https?://", ""
|
|
||||||
$authUrl = "https://${token}@${cleanUrl}/${repo}.git"
|
|
||||||
|
|
||||||
if (-not (Test-Path ".git")) {
|
|
||||||
git init
|
|
||||||
git remote add origin $authUrl
|
|
||||||
} else {
|
|
||||||
git remote set-url origin $authUrl
|
|
||||||
}
|
|
||||||
git fetch --depth 1 origin "${{ github.sha }}"
|
|
||||||
git checkout -f FETCH_HEAD
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py -m pip install --upgrade pip
|
|
||||||
& $py -m pip install pyinstaller cryptography -r compilation/requirements.txt
|
|
||||||
|
|
||||||
- name: Compile Standalone Windows Binaries
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
- name: Compile Inno Setup Installers
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$iscc = $null
|
|
||||||
if (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") {
|
|
||||||
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
|
||||||
} elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") {
|
|
||||||
$iscc = "C:\Program Files\Inno Setup 6\ISCC.exe"
|
|
||||||
} elseif (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue) {
|
|
||||||
$iscc = "ISCC.exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($iscc) {
|
|
||||||
Write-Host "[*] Compiling Windows Inno Setup installers using $iscc..."
|
|
||||||
& $iscc compilation/installer_client.iss
|
|
||||||
& $iscc compilation/installer_server.iss
|
|
||||||
} else {
|
|
||||||
Write-Host "[!] Inno Setup compiler (ISCC.exe) not found on runner host. Skipping installer compilation."
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Publish Windows Release Assets
|
|
||||||
shell: powershell
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/upload_release.py --skip-build
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ server_config.json
|
|||||||
client_config.json
|
client_config.json
|
||||||
test_*.json
|
test_*.json
|
||||||
test_*.db
|
test_*.db
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
runner/.env
|
||||||
|
|
||||||
# IDE & OS files
|
# IDE & OS files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -1,405 +1,170 @@
|
|||||||
# LOGAR: Edge-Thin Log Analysis & Temporal Verification System
|
# LOGAR: Fleet Log Aggregation & Centralized Provisioning System
|
||||||
|
### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis*
|
||||||
|
|
||||||
**LOGAR** is an enterprise log aggregation, verification, and anomaly detection architecture designed for heterogeneous server fleets (Windows & Linux). It combines lightweight zero-state edge forwarders with a centralized cloud hub that applies mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI licensing and quota management, temporal persistence tracking across 12-hour evaluation windows, an automated 4-run rule to filter transient warnings, and immediate pass-through for critical errors before reporting verified anomalies to **Hermes**.
|
[](RELEASE_NOTES.md)
|
||||||
|
[](tests/)
|
||||||
|
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||||
|
|
||||||
|
**LOGAR** is an enterprise-grade log aggregation, normalization, and centralized provisioning system built for heterogeneous server fleets (Windows & Linux). It pairs ultra-lightweight, zero-state edge forwarders with a secure centralized server hub that enforces mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI fleet licensing with cryptographic seat leasing, 12-hour temporal persistence tracking, and direct anomaly pass-through—delivering clean, structured, verified log telemetry directly to **Hermes AI** for autonomous deep reasoning, contextual root-cause analysis, and operational decision-making.
|
||||||
|
|
||||||
|
> 📚 **Comprehensive Technical Documentation**: Full architectural specifications, state machines, PKI X.509 OID definitions, and complete REST API references are maintained on the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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. [mTLS Security, Dynamic PKI & Licensing](#mtls-security-dynamic-pki--licensing)
|
- **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.
|
||||||
5. [Agentic Hermes & Client Management API](#agentic-hermes--client-management-api)
|
- **12-Hour Temporal Persistence**: Evaluates anomalies over a rolling 12-hour temporal window, tracking occurrence velocity and first/last seen metrics.
|
||||||
6. [Dynamic Machine & Domain Identification](#dynamic-machine--domain-identification)
|
- **Direct Hermes AI Provisioning (`:8443`)**: Exposes verified anomalous telemetry directly to Hermes AI via `/api/hermes/report` without heuristic bottlenecks.
|
||||||
7. [Automated Service Installers (Linux & Windows)](#automated-service-installers-linux--windows)
|
- **WebUI Dashboard**: Authenticated, 2-tab management console for live anomaly telemetry, client seat leasing, and Sub-CA lifecycle management.
|
||||||
8. [Repository & Shippables Structure](#repository--shippables-structure)
|
|
||||||
9. [Getting Started & Deployment](#getting-started--deployment)
|
|
||||||
10. [Running Tests](#running-tests)
|
|
||||||
11. [Automated Releases via Gitea Actions](#automated-releases-via-gitea-actions)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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 local SQLite or heavy cache files.
|
|
||||||
- **Source-Level Filtering**: Agents stream candidate entries from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), stripping verbose debugging noise (`DEBUG`, trace entries) and skipping events older than 24 hours.
|
|
||||||
- **Transport Security (mTLS 1.3)**: Logs are streamed directly over mutual TLS 1.3 sockets with hardware-bound / machine-unique client certificates.
|
|
||||||
- **Zero Configuration Overhead**: Clients auto-bootstrap certificate enrollment on first run if configured with an enrollment secret.
|
|
||||||
|
|
||||||
### 2. Cloud-Side Temporal Persistence & Severity Routing
|
|
||||||
The central Python hub handles state and verification:
|
|
||||||
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
|
||||||
- Candidate issues are evaluated over a **12-hour temporal evaluation window**.
|
|
||||||
- **Warning Persistence (4-Run Rule)**: `WARNING` issues must persist across **at least 4 consecutive runs / cycles** within the 12-hour window to be confirmed as genuine anomalies, automatically filtering transient infrastructure blips.
|
|
||||||
- **Immediate Error Pass**: Critical errors (`ERROR`, `CRITICAL`, `FATAL`) bypass the 4-run threshold and are promoted immediately to `VERIFIED` on their first occurrence.
|
|
||||||
|
|
||||||
### 3. Agentic Integration with Hermes
|
|
||||||
Instead of engineers manually sifting through logs, **Hermes** ingests pre-filtered anomalies directly from the cloud hub (`GET /api/hermes/report`), treating verified errors and 4-run validated warnings as actionable system artifacts to trigger precise notifications and remediations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture & Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph Edge Nodes [Lightweight Edge Forwarders]
|
subgraph Authority [Master License Authority - logar.eibl.tech]
|
||||||
W[Win_Client.exe / Win_Client.py<br/>Windows Event Log Ingestion]
|
M_CA[Master Root CA]
|
||||||
L[Linux_Client.bin / Linux_Client.py<br/>systemd journalctl -p warning]
|
AUTH_WS[WebSocket Licensing Hub]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Enrollment [Dynamic PKI & Licensing]
|
subgraph ServerHub [LOGAR Server Hub]
|
||||||
ENR[POST /api/client/enroll<br/>License Quota & Secret Validation]
|
SUB_CA[Subordinate CA]
|
||||||
CA[Internal Root CA<br/>Signs RSA-2048 Client Cert]
|
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 Transport [mTLS 1.3 Security Layer]
|
subgraph EdgeNodes [Heterogeneous Edge Forwarders]
|
||||||
MTLS[Mutual TLS 1.3 Handshake<br/>Port 9443 - Client Cert Required]
|
WIN[Win_Client.exe<br/>Windows Event Logs]
|
||||||
AUTH[Extract Client CN & Fingerprint<br/>Validate Active License in SQLite]
|
LIN[Linux_Client.bin<br/>systemd journalctl]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Hub [LOGAR Server Hub]
|
subgraph Downstream [Hermes AI Layer]
|
||||||
INGEST[Length-Prefixed Frame Ingestion]
|
HERMES[Hermes AI Engine]
|
||||||
DB[(SQLite Persistence<br/>active_issues, clients, license_config)]
|
|
||||||
RULE{12h Window &<br/>4-Run Rule}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Downstream [Hermes Agent & Monitoring]
|
M_CA -->|Signs Sub-CA Cert| SUB_CA
|
||||||
API[FastAPI Reporting & Management<br/>Port 8443]
|
AUTH_WS <==>|Live WebSocket Sync| SUB_CA
|
||||||
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
|
||||||
end
|
WIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
|
LIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
W -->|Auto-Enrollment| ENR
|
WIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
L -->|Auto-Enrollment| ENR
|
LIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
ENR --> CA
|
PROV_API ==>|3. GET /api/hermes/report| HERMES
|
||||||
CA -->|ca.crt, client.crt, client.key| W
|
|
||||||
CA -->|ca.crt, client.crt, client.key| L
|
|
||||||
|
|
||||||
W -->|mTLS Stream| MTLS
|
|
||||||
L -->|mTLS Stream| MTLS
|
|
||||||
MTLS --> AUTH
|
|
||||||
AUTH --> INGEST
|
|
||||||
INGEST --> RULE
|
|
||||||
RULE --> DB
|
|
||||||
DB --> API
|
|
||||||
API --> HERMES
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## mTLS Security, Dynamic PKI & Fleet Licensing
|
## Quick Start & Automated Installers
|
||||||
|
|
||||||
### 1. TLS 1.3 Mutual Authentication (mTLS)
|
LOGAR provides automated production service installers for both Linux (`systemd`) and Windows (`services.msc` via NSSM):
|
||||||
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
|
|
||||||
- Both the hub and edge clients verify each other's certificates:
|
|
||||||
- Client verifies server certificate against `ca.crt`.
|
|
||||||
- Server verifies client certificate against the Subordinate/Root CA.
|
|
||||||
- **Client CN Identification & Revocation**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
|
|
||||||
|
|
||||||
### 2. Master License Authority (`logar.eibl.tech`) & Seat-Bearing Subordinate CA
|
### 1. Server Hub Installation
|
||||||
- **Master Root Authority**: Standalone PKI repository & submodule ([`LOGAR-LICENSING/`](file:///u:/max/Desktop/LOGAR/LOGAR-LICENSING/README.md), upstream at [`https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git)) deployed at `logar.eibl.tech`.
|
|
||||||
|
|
||||||
- **Custom Enterprise X.509 OIDs**:
|
- **Linux (Automated systemd Installer)**:
|
||||||
- `OID_LOGAR_SEATS (1.3.6.1.4.1.59999.1.1)`: Embeds the exact licensed seat quota directly inside the signed Subordinate CA certificate.
|
|
||||||
- `OID_LOGAR_LICENSE_ID (1.3.6.1.4.1.59999.1.2)`: Uniquely tracks enterprise license entitlement.
|
|
||||||
- `OID_LOGAR_ISSUER_URL (1.3.6.1.4.1.59999.1.3)`: Embedded endpoint coordinate (`https://logar.eibl.tech`).
|
|
||||||
- **Exportable `.lic` Request Files**:
|
|
||||||
- The LOGAR server generates its Subordinate CA private key (`certs/ca.key`), Certificate Signing Request (`certs/ca.csr`), and packages them into an exportable `logar_server.lic` file containing the CSR, server fingerprint, and license authority coordinate.
|
|
||||||
- Exportable directly from the WebUI or via CLI (`python src/Server.py --export-lic server.lic`).
|
|
||||||
- **One-Time Pass & Zero-Touch mTLS Renewals**:
|
|
||||||
- The signed CA certificate only needs to be imported **once** into the LOGAR Server (via WebUI or CLI `--import-ca`).
|
|
||||||
- Once imported, the server hub's background watchdog monitors `ca.crt` expiration (< 30 days).
|
|
||||||
- Renewals are performed **automatically over mTLS**: the LOGAR Server authenticates directly against `https://logar.eibl.tech/api/license/renew` using its existing active certificate and key, receives the renewed Subordinate CA certificate preserving its seat quota, and reloads its in-flight `ssl.SSLContext` without dropping client connections or restarting services.
|
|
||||||
|
|
||||||
### 3. Integrated Fleet Licensing & WebUI Dashboard
|
|
||||||
- **WebUI URL**: Accessible at `http://<server_host>:8443/webui` (or `/`).
|
|
||||||
- **Visual Seat Quota Meter**: Real-time progress bar displaying active forwarders vs. allocated seat limit.
|
|
||||||
- **Licensing Actions**:
|
|
||||||
- 1-click **Export .lic Request** file download.
|
|
||||||
- Interactive **View & Copy CA CSR** modal.
|
|
||||||
- Drag-and-drop or paste **Import Signed CA Certificate** modal.
|
|
||||||
- **Renew via mTLS Now** button for immediate upstream renewal.
|
|
||||||
- **Seat Management & Revocation Table**:
|
|
||||||
- Lists all connected Windows and Linux edge forwarders.
|
|
||||||
- **Revoke Seat**: Instantly drops and blocks the client at the TLS socket handshake layer.
|
|
||||||
- **Reactivate Seat**: Restores the client to active status if within seat quota limits.
|
|
||||||
|
|
||||||
### 4. Cloud-Side In-Flight Certificate Watchdog
|
|
||||||
- **Continuous Hub PKI Watchdog**:
|
|
||||||
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours).
|
|
||||||
- Automatically renews Subordinate CA certificates over mTLS with `logar.eibl.tech` and Server TLS certificates in-flight, dynamically reloading the active `ssl.SSLContext`.
|
|
||||||
- **Client Proactive Check & Reactive Self-Healing**:
|
|
||||||
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If expiring within 30 days, they contact `/api/client/enroll` to renew.
|
|
||||||
- **Reactive Self-Healing**: If the server CA rotates or a handshake error occurs, edge clients catch the exception, re-bootstrap enrollment against the hub, and re-establish streaming cleanly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cloud-Side Temporal Persistence & 4-Run Rule
|
|
||||||
|
|
||||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
|
||||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
|
||||||
- **12-Hour Evaluation Window**:
|
|
||||||
- The hub compares `(now - last_seen)`.
|
|
||||||
- If more than 12 hours have elapsed since the issue was last recorded, the previous window expires and the cycle resets to `run_count = 1`.
|
|
||||||
- **4-Run Rule for Warnings**:
|
|
||||||
- The 4-run persistence threshold applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
|
||||||
- Each distinct run batch increments `run_count`.
|
|
||||||
- Warnings with `run_count < 4` are marked as `TRANSIENT` and excluded from Hermes reports.
|
|
||||||
- When `run_count >= 4` within the active 12-hour window, the warning transitions to `VERIFIED`.
|
|
||||||
- **Immediate Verification for Errors**:
|
|
||||||
- High-severity events (`ERROR`, `CRITICAL`, `FATAL`) **always pass immediately**.
|
|
||||||
- On their very first ingestion (`run_count = 1`), errors are promoted directly to `VERIFIED` and surfaced to Hermes without waiting for 4 runs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agentic Hermes & Client Management API
|
|
||||||
|
|
||||||
The server hub exposes a management and reporting REST API (default port `8443`):
|
|
||||||
|
|
||||||
### `POST /api/client/enroll`
|
|
||||||
Client enrollment endpoint:
|
|
||||||
- **Request**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"client_id": "web-worker-01.corp.internal",
|
|
||||||
"hostname": "web-worker-01",
|
|
||||||
"os": "linux",
|
|
||||||
"enrollment_secret": "<SECRET>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Response**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ca_cert": "-----BEGIN CERTIFICATE-----\n...",
|
|
||||||
"client_cert": "-----BEGIN CERTIFICATE-----\n...",
|
|
||||||
"client_key": "-----BEGIN RSA PRIVATE KEY-----\n..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/clients`
|
|
||||||
Returns seat quota status and registered client telemetry:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"active_seats": 2,
|
|
||||||
"max_seats": 10,
|
|
||||||
"clients": [
|
|
||||||
{
|
|
||||||
"client_id": "web-worker-01.corp.internal",
|
|
||||||
"hostname": "web-worker-01",
|
|
||||||
"os_type": "linux",
|
|
||||||
"cert_fingerprint": "7D5B660B...",
|
|
||||||
"status": "active",
|
|
||||||
"first_seen": "2026-09-04 18:00:00",
|
|
||||||
"last_seen": "2026-09-04 19:15:00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/hermes/report`
|
|
||||||
Returns all **verified anomalies** (immediate critical errors and warnings verified after 4 consecutive runs within the 12-hour window):
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "corp.internal:web-worker-01.corp.internal:PostgresPoolExhausted",
|
|
||||||
"site": "corp.internal",
|
|
||||||
"server": "web-worker-01.corp.internal",
|
|
||||||
"signature": "PostgresPoolExhausted",
|
|
||||||
"severity": "WARNING",
|
|
||||||
"message": "Connection pool saturated (>95%) across 4 runs",
|
|
||||||
"os_type": "linux",
|
|
||||||
"first_seen": "2026-09-04T07:00:00+00:00",
|
|
||||||
"last_seen": "2026-09-04T19:00:00+00:00",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"evaluation_window": "12h",
|
|
||||||
"verified": true,
|
|
||||||
"status": "VERIFIED"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/hermes/all`
|
|
||||||
Diagnostic endpoint listing all candidate issues (`TRANSIENT` and `VERIFIED`).
|
|
||||||
|
|
||||||
### `GET /health`
|
|
||||||
Returns hub health, encryption fingerprint, listener ports, and mTLS status.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dynamic Machine & Domain Identification
|
|
||||||
|
|
||||||
Client configurations intentionally contain **no hardcoded machine name or site name**. Both forwarders dynamically identify their host and domain at runtime via `get_machine_identifier()`:
|
|
||||||
1. **Fully Qualified Domain Name (FQDN)**: Checked via `socket.getfqdn()`.
|
|
||||||
2. **OS-Specific Domain Discovery**:
|
|
||||||
- **Windows**: Checks Active Directory environment variables (`USERDNSDOMAIN`, `USERDOMAIN`).
|
|
||||||
- **Linux**: Parses `/etc/resolv.conf` `domain` and `search` directives.
|
|
||||||
3. **Reverse DNS Lookup**: Resolves canonical hostname via `socket.gethostbyaddr`.
|
|
||||||
4. **Fallback**: Local hostname `socket.gethostname()`.
|
|
||||||
|
|
||||||
The server automatically infers site attribution from domain qualifiers (e.g. `node01.corp.internal` $\rightarrow$ site `corp.internal`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automated Service Installers (Linux & Windows)
|
|
||||||
|
|
||||||
LOGAR provides production-grade installation scripts and installer builders for automated service deployment:
|
|
||||||
|
|
||||||
### 1. Linux Service Installers
|
|
||||||
- **Client Installer (`compilation/install_linux_client.sh`)**:
|
|
||||||
- Non-interactive script deploying to `/opt/logar-client`.
|
|
||||||
- Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
|
||||||
- Installs and enables `logar-client.service` systemd unit.
|
|
||||||
```bash
|
|
||||||
sudo ./compilation/install_linux_client.sh "http://hub.example.com:8443" "<ENROLLMENT_SECRET>"
|
|
||||||
```
|
|
||||||
- **Server Installer (`compilation/install_linux_server.sh`)**:
|
|
||||||
- Deploys server to `/opt/logar-server`.
|
|
||||||
- Configures logging and installs `logar-server.service` with `LimitNOFILE=65536`.
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./compilation/install_linux_server.sh
|
sudo ./compilation/install_linux_server.sh
|
||||||
```
|
```
|
||||||
|
*Deploys standalone `Server.bin` to `/opt/logar-server`, initializes PKI, and enables `logar-server.service`.*
|
||||||
|
|
||||||
### 2. Windows Inno Setup Installers
|
- **Windows (Inno Setup / NSIS Installer)**:
|
||||||
- Built using **Inno Setup 6** and bundled with **NSSM** (`compilation/nssm.exe`):
|
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.
|
||||||
- **Client Setup (`compilation/installer_client.iss`)**: Compiles `LOGAR-Client-Setup.exe`. Installs `Win_Client.exe` into `{autopf}\LOGAR`, sets up `LOGAR_Client` service via NSSM with stdout/stderr redirection to `{commonappdata}\LOGAR\client.log`, and starts the service. Clean uninstallation stops and removes the service.
|
|
||||||
- **Server Setup (`compilation/installer_server.iss`)**: Compiles `LOGAR-Server-Setup.exe`. Installs `Server.exe` and sets up `LOGAR_Server` Windows service via NSSM.
|
#### Default WebUI Management Credentials
|
||||||
|
Once installed or executed, access the web console:
|
||||||
|
- **URL**: `http://<server-ip>:8443/webui`
|
||||||
|
- **Default Username**: `admin`
|
||||||
|
- **Default Password**: `admin_logar_2026!` *(Configurable via `LOGAR_ADMIN_USER` / `LOGAR_ADMIN_PASS` or in the WebUI user settings)*
|
||||||
|
|
||||||
|
### 2. Edge Forwarder Installation
|
||||||
|
|
||||||
|
- **Linux Client (1-Line Non-Interactive Installer)**:
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||||
|
```
|
||||||
|
*Auto-enrolls mTLS certificates to `/etc/logar/certs` (`chmod 700`), generates configuration, and activates `logar-client.service`.*
|
||||||
|
|
||||||
|
- **Windows Client (Inno Setup Installer)**:
|
||||||
|
1. Download and run `LOGAR-Client-Setup.exe` (supports silent install `/VERYSILENT`).
|
||||||
|
2. Place `client_config.json` (exported from Server WebUI) into `C:\Program Files\LOGAR\client_config.json`.
|
||||||
|
3. The `LOGAR_Client` service starts and auto-enrolls immediately.
|
||||||
|
|
||||||
|
*(Alternatively, standalone binaries `./Server.bin` / `./Linux_Client.bin` / `.\Server.exe` / `.\Win_Client.exe` can be executed directly without service installation).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repository & Shippables Structure
|
## Repository Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
LOGAR/
|
LOGAR/
|
||||||
├── .gitea/
|
├── .gitea/workflows/ # CI/CD pipelines (ci.yml, release.yml)
|
||||||
│ └── workflows/
|
├── compilation/ # Build scripts, Linux installers & Inno Setup scripts
|
||||||
│ ├── ci.yml # CI pipeline: syntax, 25 unit tests & mTLS pipeline test
|
│ ├── install_linux_client.sh # Linux client systemd auto-installer
|
||||||
│ └── release.yml # Consolidated release workflow (Linux binaries & Windows installers)
|
│ ├── install_linux_server.sh # Linux server systemd auto-installer
|
||||||
├── compilation/ # Packaging, installers, and release automation
|
│ ├── package_dist.py # Standalone binary cross-compiler
|
||||||
│ ├── install_linux_client.sh # Automated Linux client systemd installation script
|
│ └── upload_release.py # Gitea release asset publisher
|
||||||
│ ├── install_linux_server.sh # Automated Linux server systemd installation script
|
├── src/ # Application source code
|
||||||
│ ├── installer_client.iss # Inno Setup Windows Client installer script
|
│ ├── Server.py # Central mTLS hub, temporal engine & REST API
|
||||||
│ ├── installer_server.iss # Inno Setup Windows Server installer script
|
│ ├── server_enrollment.py # Sub-CA PKI and certificate generator
|
||||||
│ ├── nssm.exe # Official 64-bit NSSM service manager binary
|
│ ├── Win_Client.py # Windows edge forwarder
|
||||||
│ ├── package_dist.py # Standalone binary compiler & packager
|
│ └── Linux_Client.py # Linux edge forwarder
|
||||||
│ ├── requirements.txt # Unified project dependencies
|
├── tests/ # Automated test suites (35 unit tests + pipeline test)
|
||||||
│ └── upload_release.py # Gitea REST API release asset publisher
|
├── LOGAR-LICENSING/ # Standalone Master License Authority (Git submodule)
|
||||||
├── src/ # Core application source modules
|
├── RELEASE_NOTES.md # Version history and security changelog
|
||||||
│ ├── __init__.py
|
└── README.md # Repository overview
|
||||||
│ ├── Server.py # Central mTLS server, temporal engine, and Hermes REST API
|
|
||||||
│ ├── server_enrollment.py # Dynamic PKI, Root CA, and client certificate generator
|
|
||||||
│ ├── Win_Client.py # Windows edge forwarder with auto-enrollment
|
|
||||||
│ └── Linux_Client.py # Linux edge forwarder with auto-enrollment
|
|
||||||
├── tests/ # Automated test suites
|
|
||||||
│ ├── test_linux_client.py # Linux client unit tests & mTLS certificate validation
|
|
||||||
│ ├── test_pipeline.py # End-to-end mTLS integration & 4-run verification test
|
|
||||||
│ ├── test_server.py # Server unit tests, PKI generation, and seat quota tests
|
|
||||||
│ └── test_win_client.py # Windows client unit tests & mTLS certificate validation
|
|
||||||
├── .gitignore # Ignores venv, caches, DBs, and private keys
|
|
||||||
├── README.md # Architecture and usage documentation
|
|
||||||
├── RELEASE_NOTES.md # Release history and changelog
|
|
||||||
├── server_config.sample.json # Reference server configuration
|
|
||||||
└── out/ # Component guides and sample configs
|
|
||||||
├── linux_server/
|
|
||||||
│ ├── README.md
|
|
||||||
│ └── server_config.sample.json
|
|
||||||
├── win_server/
|
|
||||||
│ ├── README.md
|
|
||||||
│ └── server_config.sample.json
|
|
||||||
├── linux_client/
|
|
||||||
│ ├── README.md
|
|
||||||
│ └── client_config.sample.json
|
|
||||||
└── win_client/
|
|
||||||
├── README.md
|
|
||||||
└── client_config.sample.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started & Deployment
|
## Ports & REST API Summary
|
||||||
|
|
||||||
### 1. Central Server Hub
|
| Port | Protocol | Service | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **`9443`** | `TCP / TLS 1.3` | **mTLS Log Ingestion** | Mutual TLS socket listener for edge forwarders |
|
||||||
|
| **`8443`** | `HTTP / REST` | **Management & Hermes API** | WebUI, forwarder enrollment, seat management, and AI reports |
|
||||||
|
|
||||||
1. **Install dependencies**:
|
### Core Endpoints
|
||||||
```bash
|
|
||||||
pip install -r compilation/requirements.txt
|
|
||||||
```
|
|
||||||
2. **Start the server** (generates `server_config.json`, Root CA, and server certs on first run):
|
|
||||||
```bash
|
|
||||||
python src/Server.py
|
|
||||||
```
|
|
||||||
3. **Export a client configuration**:
|
|
||||||
```bash
|
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP> --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Windows Client Deployment
|
- **`POST /api/client/enroll`**: Forwarder auto-enrollment and proactive certificate renewal.
|
||||||
1. Download `LOGAR-Client-Setup.exe` from releases and run it, or place `Win_Client.exe` and `client_config.json` in `C:\Program Files\LOGAR`.
|
- **`GET /api/clients`**: Returns active seat quota and registered edge forwarder telemetry *(Admin Auth)*.
|
||||||
2. On first run with `client_config.json`, `Win_Client.exe` automatically enrolls with the hub, receives its mTLS certificates, and establishes secure streaming.
|
- **`GET /api/hermes/report`**: Structured JSON anomaly stream for Hermes AI reasoning.
|
||||||
|
- **`POST /api/license/sync`**: Triggers immediate WebSocket sync with `logar.eibl.tech` *(Admin Auth)*.
|
||||||
### 3. Linux Client Deployment
|
- **`GET /health`**: Server health, encryption fingerprint, and mTLS status.
|
||||||
1. Run the automated installer:
|
|
||||||
```bash
|
|
||||||
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
|
||||||
```
|
|
||||||
2. The installer enrolls the client, configures `/etc/logar/certs`, and activates `logar-client.service`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Testing & Cross-Compilation
|
||||||
|
|
||||||
### 1. Component Unit Tests
|
|
||||||
```bash
|
```bash
|
||||||
|
# Run full unit test suite (35 tests)
|
||||||
python -m unittest discover -s tests -v
|
python -m unittest discover -s tests -v
|
||||||
```
|
|
||||||
Runs all **25 unit tests**, covering:
|
|
||||||
- Dynamic Root CA generation and server TLS certificate issuance.
|
|
||||||
- Dynamic client certificate issuance with CN and authority key extensions.
|
|
||||||
- Enrollment secret authentication, seat limits, and certificate revocation.
|
|
||||||
- Proactive certificate validity checks, Root CA auto-renewal, and in-flight server SSLContext reload.
|
|
||||||
- Windows & Linux event log collection, deduplication, and mTLS certificate verification.
|
|
||||||
- 12-hour evaluation window and 4-run rule progression.
|
|
||||||
|
|
||||||
### 2. End-to-End Pipeline Integration Test
|
# Run end-to-end mTLS pipeline integration test
|
||||||
```bash
|
|
||||||
# 1. Initialize test configuration
|
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
|
||||||
|
|
||||||
# 2. Launch server in background
|
|
||||||
python src/Server.py &
|
|
||||||
|
|
||||||
# 3. Run integration test
|
|
||||||
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
|
||||||
```
|
```
|
||||||
Tests client enrollment, secret rejection, mTLS TLS 1.3 socket handshake, warning 4-run rule promotion, immediate error promotion, and Hermes report output.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Releases & Binary Distribution
|
## Documentation & Deep Dives
|
||||||
|
|
||||||
Releases can be built and published through three complementary channels:
|
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||||
|
|
||||||
### 1. Tag Push Automation (Gitea Actions)
|
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||||
Pushing a release tag automatically triggers the build workflows:
|
* [System State Machines & Subsystem Lifecycles](https://gitea.eibl.tech/me0nline/LOGAR/wiki/State-Machines-and-Lifecycles)
|
||||||
```bash
|
* [PKI Architecture & Custom X.509 Seat OIDs](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||||
git tag v2.0.1
|
* [Complete REST API Specification](https://gitea.eibl.tech/me0nline/LOGAR/wiki/API-Reference)
|
||||||
git push origin v2.0.1
|
* [Production Deployment & Service Management](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Deployment-and-Installation)
|
||||||
```
|
* [CI/CD Pipeline & Cross-Compilation Workflow](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Testing-and-Releases)
|
||||||
The consolidated workflow **`release.yml`** defines two parallel jobs:
|
|
||||||
- **`release-linux`** (`ubuntu-latest`): Compiles standalone native ELF binaries (`Linux_Client.bin`, `Server.bin`) and checksums.
|
|
||||||
- **`release-windows`** (`windows-latest`): Compiles Windows executables (`Win_Client.exe`, `Server.exe`), builds Inno Setup installers, and publishes checksums (requires self-hosted Windows Act Runner).
|
|
||||||
|
|
||||||
### 2. Manual Workflow Dispatch (Gitea UI)
|
|
||||||
Workflows can be manually triggered on demand from the Gitea web interface:
|
|
||||||
1. Navigate to **Actions** $\rightarrow$ **Release Binaries & Installers** (`release.yml`).
|
|
||||||
2. Click **Run workflow**, set the release tag (defaults to `v2.0.1`), and run.
|
|
||||||
|
|
||||||
### 3. Native Local Windows Build & Direct Release Upload
|
|
||||||
For environments without a registered Windows CI runner, Windows executables can be built and published directly to Gitea releases:
|
|
||||||
```powershell
|
|
||||||
# 1. Build Windows binaries locally
|
|
||||||
python compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
# 2. Upload assets and release notes directly to the Gitea release
|
|
||||||
python compilation/upload_release.py --tag v2.0.1 --token <GITEA_TOKEN> --skip-build
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,60 +1,6 @@
|
|||||||
# LOGAR Release v2.0.1
|
# LOGAR Release v0.3.2
|
||||||
|
|
||||||
Maintenance and deployment release consolidating Gitea Actions release automation into a unified single workflow file, standardizing release dispatching across platforms, and bumping version definitions across server hub and Windows installers.
|
|
||||||
|
|
||||||
### Key Highlights & Changes in v2.0.1:
|
|
||||||
|
|
||||||
- **Consolidated Single Release Automation Workflow (`.gitea/workflows/release.yml`)**:
|
|
||||||
- Unified separate platform release files into a single, cohesive workflow (`release.yml`) running parallel jobs (`release-linux` on `ubuntu-latest` and `release-windows` on `windows-latest`).
|
|
||||||
- Standardized tag matching for Gitea Actions on `push: tags: ['v*']`.
|
|
||||||
- Added streamlined manual `workflow_dispatch` triggers with automated release tag defaulting (`v2.0.1`).
|
|
||||||
- Retired deprecated `release-linux.yml` and `release-windows.yml` files.
|
|
||||||
|
|
||||||
- **Installer & Engine Version Bump**:
|
|
||||||
- Updated FastAPI Hub engine version to `2.0.1` in `src/Server.py`.
|
|
||||||
- Bumped Inno Setup Windows Client Installer (`compilation/installer_client.iss`) `AppVersion` to `2.0.1`.
|
|
||||||
- Bumped Inno Setup Windows Server Installer (`compilation/installer_server.iss`) `AppVersion` to `2.0.1`.
|
|
||||||
|
|
||||||
- **Distribution & Release Documentation**:
|
|
||||||
- Updated root and distribution documentation across all 5 deployment targets to reflect the 25 passing unit tests and tripartite release options.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# LOGAR Release v2.0.0
|
|
||||||
|
|
||||||
Major architectural release introducing Mutual TLS 1.3 (mTLS) transport security, built-in dynamic PKI & license accounting, in-flight certificate validity monitoring and auto-renewal, and automated Windows and Linux service installers.
|
|
||||||
|
|
||||||
### Key Highlights & Changes in v2.0.0:
|
|
||||||
|
|
||||||
- **mTLS 1.3 Transport Security & Runtime Licensing**:
|
|
||||||
- Replaced legacy plain TCP sockets with mutual TLS 1.3 authentication (`ssl.CERT_REQUIRED`, TLS 1.3 minimum version).
|
|
||||||
- Hub dynamically validates incoming client Common Name (`client_id`) against active license seats in SQLite during the TLS handshake.
|
|
||||||
- Drops unauthorized, un-enrolled, or revoked clients at the transport layer before payload reading.
|
|
||||||
|
|
||||||
- **Dynamic Hub PKI Engine (`src/server_enrollment.py`)**:
|
|
||||||
- Automatically initializes an internal RSA-4096 Root CA (`ca.crt` / `ca.key`).
|
|
||||||
- Generates RSA-2048 Server TLS certificates with SANs for localhost, loopback, and server hostnames.
|
|
||||||
- Full OpenSSL 3.x and Python 3.12–3.14 compatibility via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` certificate extensions.
|
|
||||||
- Generates and signs client certificates on demand via `POST /api/client/enroll`.
|
|
||||||
|
|
||||||
- **In-Flight Certificate Validity Watchdog & Dynamic SSLContext Reloading**:
|
|
||||||
- Server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, evaluated every 12 hours) alongside startup checks.
|
|
||||||
- Automatically checks Root CA and server TLS certificate expiration against a 30-day threshold.
|
|
||||||
- Generates renewed certificates on disk with timestamped backups (`.bak`), and reloads active `ssl.SSLContext` in memory dynamically without dropping socket listeners or restarting the background service.
|
|
||||||
|
|
||||||
- **Client Proactive Expiry Check & Reactive Self-Healing Auto-Renewal**:
|
|
||||||
- **Proactive**: Forwarders (`Win_Client.py` and `Linux_Client.py`) evaluate `client.crt` validity before each run, auto-renewing via `/api/client/enroll` if expiring within 30 days.
|
|
||||||
- **Reactive**: If the hub rotates its Root CA or a TLS verification error (`ssl.SSLError` / `SSLCertVerificationError`) occurs, clients automatically catch the error, re-enroll with the hub using their enrollment secret, and reconnect cleanly.
|
|
||||||
|
|
||||||
- **Database Schema & License Quota Accounting**:
|
|
||||||
- SQLite tables `license_config` (`max_seats`, `enrollment_secret`) and `clients` (`client_id`, `hostname`, `os_type`, `cert_fingerprint`, `status`, timestamps).
|
|
||||||
- Enforces seat limits on enrollment (`HTTP 403 License seat limit reached`) while allowing active registered nodes to re-enroll/renew indefinitely.
|
|
||||||
- Added `GET /api/clients` endpoint for license auditing and telemetry tracking.
|
|
||||||
|
|
||||||
- **Automated Service Installers**:
|
|
||||||
- **Windows**: Self-contained Inno Setup installers (`LOGAR-Client-Setup.exe` and `LOGAR-Server-Setup.exe`) bundling `nssm.exe` to register, configure, and start Windows services automatically.
|
|
||||||
- **Linux**: Automated installer scripts (`compilation/install_linux_client.sh` and `install_linux_server.sh`) deploying systemd service units with auto-restart policies.
|
|
||||||
|
|
||||||
- **CI/CD Release Workflows**:
|
|
||||||
- Windows workflow (`.gitea/workflows/release-windows.yml`) and Linux workflow (`.gitea/workflows/release-linux.yml`) automated to build native executables, installers, and upload release assets on tag push.
|
|
||||||
|
|
||||||
|
### 🪟 Windows Installer Fixes
|
||||||
|
- **Standalone Windows Binaries & Python Runtime**: Resolved issues with standalone Windows executable execution and bundled runtime paths.
|
||||||
|
- **Data Directory Selection & Permissions**: Added custom data directory selection during setup and fixed permissions for background Windows services.
|
||||||
|
- **Service Lifecycle & Clean Uninstallation**: Resolved service startup issues and ensured complete cleanup of state files upon uninstallation.
|
||||||
|
|||||||
|
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: 8.1 KiB |
|
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,365 @@
|
|||||||
|
"""
|
||||||
|
LOGAR Branding & Installer Graphics Generator
|
||||||
|
Generates clean, professional SVG, PNG, ICO, and BMP visual assets for
|
||||||
|
LOGAR installers (Inno Setup & NSIS), application icons, and WebUI branding.
|
||||||
|
Zero external dependencies required (uses standard library zlib and struct).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import math
|
||||||
|
import zlib
|
||||||
|
import struct
|
||||||
|
|
||||||
|
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
||||||
|
os.makedirs(ASSETS_DIR, 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 cyberpunk 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 left with clean alpha-blended edges.
|
||||||
|
"""
|
||||||
|
bg_r, bg_g, bg_b = 255, 255, 255
|
||||||
|
|
||||||
|
# Position emblem cleanly on the left (center x = 30, center y = h / 2)
|
||||||
|
icon_center_x = 28
|
||||||
|
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 cyber 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() -> str:
|
||||||
|
"""Generates a high-quality SVG vector graphic for LOGAR."""
|
||||||
|
return """<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>"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("[*] Generating high-resolution branding assets for LOGAR...")
|
||||||
|
|
||||||
|
# 1. Generate SVG Logo
|
||||||
|
svg_path = os.path.join(ASSETS_DIR, "logo.svg")
|
||||||
|
with open(svg_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(generate_svg_logo())
|
||||||
|
print(f"[+] Saved SVG logo: {svg_path}")
|
||||||
|
|
||||||
|
# 2. Generate PNG images of various sizes
|
||||||
|
png_sizes = [16, 32, 48, 64, 128, 256, 512]
|
||||||
|
png_buffers = []
|
||||||
|
for s in png_sizes:
|
||||||
|
png_data = create_png_rgba(s, s, draw_logo_rgba)
|
||||||
|
png_buffers.append((s, s, png_data))
|
||||||
|
if s in [256, 512]:
|
||||||
|
out_p = os.path.join(ASSETS_DIR, f"logo_{s}x{s}.png" if s != 512 else "logo.png")
|
||||||
|
with open(out_p, "wb") as f:
|
||||||
|
f.write(png_data)
|
||||||
|
print(f"[+] Saved PNG logo: {out_p}")
|
||||||
|
|
||||||
|
# 3. Generate Windows ICO File
|
||||||
|
ico_path = os.path.join(ASSETS_DIR, "logo.ico")
|
||||||
|
ico_data = generate_ico_file(png_buffers[:-1]) # Up to 256x256
|
||||||
|
with open(ico_path, "wb") as f:
|
||||||
|
f.write(ico_data)
|
||||||
|
print(f"[+] Saved Windows Icon (.ico): {ico_path}")
|
||||||
|
|
||||||
|
# 4. Generate NSIS / Inno Setup Wizard Header Bitmap (150x57) with seamless white background
|
||||||
|
header_bmp_path = os.path.join(ASSETS_DIR, "wizard_header.bmp")
|
||||||
|
header_bmp_data = create_bmp_24bit(150, 57, draw_wizard_header_white_bgr)
|
||||||
|
with open(header_bmp_path, "wb") as f:
|
||||||
|
f.write(header_bmp_data)
|
||||||
|
print(f"[+] Saved Wizard Header Bitmap (White Background): {header_bmp_path}")
|
||||||
|
|
||||||
|
# 5. Generate Inno Setup Small Wizard Bitmap (55x55) with seamless white background
|
||||||
|
small_bmp_path = os.path.join(ASSETS_DIR, "wizard_small.bmp")
|
||||||
|
small_bmp_data = create_bmp_24bit(55, 55, draw_wizard_small_bgr)
|
||||||
|
with open(small_bmp_path, "wb") as f:
|
||||||
|
f.write(small_bmp_data)
|
||||||
|
print(f"[+] Saved Small Wizard Bitmap (55x55): {small_bmp_path}")
|
||||||
|
|
||||||
|
# 6. Generate NSIS / Inno Setup Wizard Sidebar Bitmap (164x314)
|
||||||
|
sidebar_bmp_path = os.path.join(ASSETS_DIR, "wizard_sidebar.bmp")
|
||||||
|
sidebar_bmp_data = create_bmp_24bit(164, 314, draw_wizard_sidebar_bgr)
|
||||||
|
with open(sidebar_bmp_path, "wb") as f:
|
||||||
|
f.write(sidebar_bmp_data)
|
||||||
|
print(f"[+] Saved Wizard Sidebar Bitmap: {sidebar_bmp_path}")
|
||||||
|
|
||||||
|
print("[+] All LOGAR branding and installer graphics generated successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,6 +9,7 @@ CONFIG_DIR="/etc/logar"
|
|||||||
|
|
||||||
echo "[+] Installing LOGAR Client..."
|
echo "[+] Installing LOGAR Client..."
|
||||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||||
|
chmod 700 "${CONFIG_DIR}/certs"
|
||||||
|
|
||||||
if [ -f "dist/Linux_Client.bin" ]; then
|
if [ -f "dist/Linux_Client.bin" ]; then
|
||||||
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
||||||
|
|||||||
@@ -4,7 +4,31 @@ set -euo pipefail
|
|||||||
INSTALL_DIR="/opt/logar-server"
|
INSTALL_DIR="/opt/logar-server"
|
||||||
CONFIG_DIR="/etc/logar"
|
CONFIG_DIR="/etc/logar"
|
||||||
|
|
||||||
echo "[+] Installing LOGAR Server..."
|
# Parse command line options
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--config-dir|--data-dir)
|
||||||
|
CONFIG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo " LOGAR Server Enterprise Linux Installer"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
# Interactive configuration if running in a terminal without CLI arguments
|
||||||
|
if [ -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
|
||||||
|
|
||||||
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" "/var/log/logar"
|
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}" "/var/log/logar"
|
||||||
|
|
||||||
if [ -f "dist/Server.bin" ]; then
|
if [ -f "dist/Server.bin" ]; then
|
||||||
|
|||||||
@@ -1,27 +1,178 @@
|
|||||||
[Setup]
|
[Setup]
|
||||||
AppName=LOGAR Client
|
AppName=LOGAR Client
|
||||||
AppVersion=2.0.1
|
AppVersion=0.3.2
|
||||||
|
AppPublisher=LOGAR Security Systems
|
||||||
|
AppPublisherURL=https://logar.eibl.tech
|
||||||
|
AppSupportURL=https://logar.eibl.tech
|
||||||
|
AppUpdatesURL=https://logar.eibl.tech
|
||||||
DefaultDirName={autopf}\LOGAR
|
DefaultDirName={autopf}\LOGAR
|
||||||
OutputDir=..\dist
|
OutputDir=..\dist
|
||||||
OutputBaseFilename=LOGAR-Client-Setup
|
OutputBaseFilename=LOGAR-Client-Setup
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
Compression=lzma
|
Compression=lzma
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
SetupIconFile=assets\logo.ico
|
||||||
|
WizardImageFile=assets\wizard_sidebar.bmp
|
||||||
|
WizardSmallImageFile=assets\wizard_small.bmp
|
||||||
|
VersionInfoCompany=LOGAR Security Systems
|
||||||
|
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||||
|
VersionInfoVersion=0.3.2.0
|
||||||
|
VersionInfoProductName=LOGAR Client
|
||||||
|
VersionInfoProductVersion=0.3.2
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
[Dirs]
|
[Dirs]
|
||||||
Name: "{commonappdata}\LOGAR"; Permissions: users-modify
|
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client.exe"""; Flags: runhidden
|
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 AppDirectory ""{app}"""; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{commonappdata}\LOGAR\client.log"""; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{code:GetDataDir}\client.log"""; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{commonappdata}\LOGAR\client_err.log"""; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client 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
|
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden
|
||||||
|
|
||||||
[UninstallRun]
|
[UninstallRun]
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Client"; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Client"; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Client confirm"; 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;
|
||||||
|
|
||||||
|
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:');
|
||||||
|
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 GetDataDir(Param: String): String;
|
||||||
|
begin
|
||||||
|
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', 'DataDir', GetDataDir(''));
|
||||||
|
|
||||||
|
CfgPath := GetConfigFile('');
|
||||||
|
if (CfgPath <> '') and FileExists(CfgPath) then
|
||||||
|
begin
|
||||||
|
DestCfg := GetDataDir('') + '\client_config.json';
|
||||||
|
FileCopy(CfgPath, DestCfg, False);
|
||||||
|
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,185 @@
|
|||||||
|
!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"
|
||||||
|
|
||||||
|
VIProductVersion "0.3.2.0"
|
||||||
|
VIAddVersionKey "ProductName" "LOGAR Client"
|
||||||
|
VIAddVersionKey "CompanyName" "LOGAR Security Systems"
|
||||||
|
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
|
||||||
|
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
|
||||||
|
VIAddVersionKey "FileVersion" "0.3.2.0"
|
||||||
|
VIAddVersionKey "ProductVersion" "0.3.2.0"
|
||||||
|
|
||||||
|
# --- Visual Graphics & Branding ---
|
||||||
|
!define MUI_ICON "assets/logo.ico"
|
||||||
|
!define MUI_UNICON "assets/logo.ico"
|
||||||
|
!define MUI_HEADERIMAGE
|
||||||
|
!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 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_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_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 .onInit
|
||||||
|
SetShellVarContext all
|
||||||
|
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||||
|
StrCpy $CFGFILE ""
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function ConfigPageCreate
|
||||||
|
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% 30u "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 38u 75% 12u "$CFGFILE"
|
||||||
|
Pop $CfgFileInput
|
||||||
|
|
||||||
|
${NSD_CreateButton} 78% 37u 22% 14u "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, copy to data directory
|
||||||
|
${If} $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" "DataDir" "$DATADIR"
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
SetShellVarContext all
|
||||||
|
ReadRegStr $DATADIR HKLM "Software\LOGAR\LOGAR-Client" "DataDir"
|
||||||
|
${If} $DATADIR == ""
|
||||||
|
StrCpy $DATADIR "$APPDATA\LOGAR"
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
# 1. Stop and remove service
|
||||||
|
ExecWait 'net stop LOGAR_Client'
|
||||||
|
ExecWait 'sc stop LOGAR_Client'
|
||||||
|
${If} ${FileExists} "$INSTDIR\nssm.exe"
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm'
|
||||||
|
${EndIf}
|
||||||
|
ExecWait 'sc delete LOGAR_Client'
|
||||||
|
|
||||||
|
# 2. Force terminate any lingering processes
|
||||||
|
ExecWait 'taskkill /F /T /IM Win_Client.exe'
|
||||||
|
ExecWait 'taskkill /F /T /IM nssm.exe'
|
||||||
|
ExecWait `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$INSTDIR\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
|
||||||
|
Sleep 1200
|
||||||
|
|
||||||
|
# 3. Clean up all installation and data files
|
||||||
|
RMDir /r "$INSTDIR\runtime"
|
||||||
|
RMDir /r "$INSTDIR\certs"
|
||||||
|
Delete "$INSTDIR\client_config.json"
|
||||||
|
Delete "$INSTDIR\client_state.json"
|
||||||
|
Delete "$INSTDIR\*.log"
|
||||||
|
Delete "$INSTDIR\Win_Client.exe"
|
||||||
|
Delete "$INSTDIR\nssm.exe"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
RMDir /r "$INSTDIR"
|
||||||
|
RMDir /r "$DATADIR"
|
||||||
|
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client"
|
||||||
|
DeleteRegKey /ifempty HKLM "Software\LOGAR"
|
||||||
|
SectionEnd
|
||||||
@@ -1,27 +1,155 @@
|
|||||||
[Setup]
|
[Setup]
|
||||||
AppName=LOGAR Server
|
AppName=LOGAR Server
|
||||||
AppVersion=2.0.1
|
AppVersion=0.3.2
|
||||||
|
AppPublisher=LOGAR Security Systems
|
||||||
|
AppPublisherURL=https://logar.eibl.tech
|
||||||
|
AppSupportURL=https://logar.eibl.tech
|
||||||
|
AppUpdatesURL=https://logar.eibl.tech
|
||||||
DefaultDirName={autopf}\LOGAR-Server
|
DefaultDirName={autopf}\LOGAR-Server
|
||||||
OutputDir=..\dist
|
OutputDir=..\dist
|
||||||
OutputBaseFilename=LOGAR-Server-Setup
|
OutputBaseFilename=LOGAR-Server-Setup
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
Compression=lzma
|
Compression=lzma
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
SetupIconFile=assets\logo.ico
|
||||||
|
WizardImageFile=assets\wizard_sidebar.bmp
|
||||||
|
WizardSmallImageFile=assets\wizard_small.bmp
|
||||||
|
VersionInfoCompany=LOGAR Security Systems
|
||||||
|
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
|
||||||
|
VersionInfoVersion=0.3.2.0
|
||||||
|
VersionInfoProductName=LOGAR Server
|
||||||
|
VersionInfoProductVersion=0.3.2
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
[Dirs]
|
[Dirs]
|
||||||
Name: "{commonappdata}\LOGAR-Server"; Permissions: users-modify
|
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Server ""{app}\Server.exe"""; Flags: runhidden
|
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 AppDirectory ""{app}"""; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{commonappdata}\LOGAR-Server\server.log"""; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{code:GetDataDir}\server.log"""; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{commonappdata}\LOGAR-Server\server_err.log"""; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server 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: "{app}\nssm.exe"; Parameters: "start LOGAR_Server"; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Server"; Flags: runhidden
|
||||||
|
|
||||||
[UninstallRun]
|
[UninstallRun]
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Server"; Flags: runhidden
|
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Server"; Flags: runhidden
|
||||||
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Server confirm"; 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;
|
||||||
|
|
||||||
|
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:');
|
||||||
|
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR-Server');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetDataDir(Param: String): String;
|
||||||
|
begin
|
||||||
|
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', '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,136 @@
|
|||||||
|
!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"
|
||||||
|
|
||||||
|
VIProductVersion "0.3.2.0"
|
||||||
|
VIAddVersionKey "ProductName" "LOGAR Server"
|
||||||
|
VIAddVersionKey "CompanyName" "LOGAR Security Systems"
|
||||||
|
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
|
||||||
|
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
|
||||||
|
VIAddVersionKey "FileVersion" "0.3.2.0"
|
||||||
|
VIAddVersionKey "ProductVersion" "0.3.2.0"
|
||||||
|
|
||||||
|
# --- Visual Graphics & Branding ---
|
||||||
|
!define MUI_ICON "assets/logo.ico"
|
||||||
|
!define MUI_UNICON "assets/logo.ico"
|
||||||
|
!define MUI_HEADERIMAGE
|
||||||
|
!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
|
||||||
|
|
||||||
|
# --- 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_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_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 .onInit
|
||||||
|
SetShellVarContext all
|
||||||
|
StrCpy $DATADIR "$APPDATA\LOGAR-Server"
|
||||||
|
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'
|
||||||
|
|
||||||
|
# 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" "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
|
||||||
|
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,14 +12,53 @@ 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):
|
||||||
os.makedirs(DIST_DIR, exist_ok=True)
|
if item == "runtime":
|
||||||
|
continue
|
||||||
|
item_path = os.path.join(DIST_DIR, item)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(item_path):
|
||||||
|
shutil.rmtree(item_path)
|
||||||
|
else:
|
||||||
|
os.remove(item_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
os.makedirs(DIST_DIR, exist_ok=True)
|
||||||
if os.path.exists(BUILD_TEMP):
|
if os.path.exists(BUILD_TEMP):
|
||||||
shutil.rmtree(BUILD_TEMP)
|
try:
|
||||||
|
shutil.rmtree(BUILD_TEMP)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
os.makedirs(BUILD_TEMP, exist_ok=True)
|
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...")
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -38,25 +77,15 @@ 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")
|
||||||
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"))
|
for f in os.listdir(SRC_DIR):
|
||||||
|
if f.endswith(".py"):
|
||||||
|
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_dir, f))
|
||||||
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,
|
||||||
@@ -67,7 +96,9 @@ 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")
|
||||||
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"))
|
for f in os.listdir(SRC_DIR):
|
||||||
|
if f.endswith(".py"):
|
||||||
|
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(srv_dir, f))
|
||||||
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 +107,297 @@ 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}")
|
||||||
|
os.makedirs(app_temp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
for f in os.listdir(SRC_DIR):
|
||||||
|
if f.endswith(".py"):
|
||||||
|
shutil.copy(os.path.join(SRC_DIR, f), os.path.join(app_temp_dir, f))
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
makensis_cmd = [makensis_bin]
|
||||||
|
compilation_dir = os.path.join(ROOT_DIR, "compilation")
|
||||||
|
success = True
|
||||||
|
for nsi in ["installer_client.nsi", "installer_server.nsi"]:
|
||||||
|
nsi_path = os.path.join(compilation_dir, nsi)
|
||||||
|
if os.path.exists(nsi_path):
|
||||||
|
print(f"[*] Compiling NSIS script: {nsi} via {makensis_cmd[0]}...")
|
||||||
|
try:
|
||||||
|
subprocess.run(makensis_cmd + [nsi_path], cwd=compilation_dir, check=True)
|
||||||
|
print(f"[+] Successfully compiled NSIS installer for {nsi}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Failed to compile {nsi} with {makensis_cmd}: {e}")
|
||||||
|
success = False
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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}...")
|
||||||
|
try:
|
||||||
|
subprocess.run(iscc_cmd + [iss_path], check=True)
|
||||||
|
print(f"[+] Successfully compiled {iss}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Failed to compile {iss} with {iscc_cmd}: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def compile_windows_installers():
|
||||||
|
if not compile_nsis_installers():
|
||||||
|
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):
|
||||||
@@ -103,20 +415,33 @@ def build_windows():
|
|||||||
else:
|
else:
|
||||||
print(f"[!] Warning: Expected {server_bin} was not found.")
|
print(f"[!] Warning: Expected {server_bin} was not found.")
|
||||||
|
|
||||||
|
# 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:
|
||||||
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
ensure_libpython()
|
||||||
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
try:
|
||||||
|
linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
|
||||||
|
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
|
||||||
|
|
||||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
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 +462,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)
|
||||||
@@ -156,16 +509,39 @@ def build_linux():
|
|||||||
else:
|
else:
|
||||||
print(f"[!] Warning: Expected {server_bin} was not found.")
|
print(f"[!] Warning: Expected {server_bin} was not found.")
|
||||||
|
|
||||||
|
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 +549,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 +580,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,7 @@ 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
|
||||||
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"))
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ Standalone compiled binary and automated systemd service distribution for Linux
|
|||||||
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
||||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
|
||||||
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
||||||
|
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||||
|
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Standalone compiled binary and automated systemd service distribution for Linux
|
|||||||
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||||
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||||
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
|
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Output:
|
|||||||
Transport Security: mTLS (TLS 1.3)
|
Transport Security: mTLS (TLS 1.3)
|
||||||
License Quota: 10 Active Seats
|
License Quota: 10 Active Seats
|
||||||
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
Evaluation Window: 12 hours | 4-Run Rule: Warnings | Immediate Pass: Errors
|
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||||
============================================================
|
============================================================
|
||||||
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
@@ -81,7 +81,7 @@ The generated `server_config.json` contains:
|
|||||||
"tls_enabled": true,
|
"tls_enabled": true,
|
||||||
"db_path": "logar_state.db",
|
"db_path": "logar_state.db",
|
||||||
"evaluation_window_hours": 12,
|
"evaluation_window_hours": 12,
|
||||||
"min_persistence_runs": 4,
|
"min_persistence_runs": 1,
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
||||||
@@ -110,8 +110,8 @@ http://<SERVER_IP>:8443/webui
|
|||||||
The WebUI provides:
|
The WebUI provides:
|
||||||
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||||
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||||
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||||
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||||
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||||
|
|
||||||
### CLI Licensing Utilities
|
### CLI Licensing Utilities
|
||||||
@@ -119,10 +119,19 @@ Export a `.lic` request file via CLI:
|
|||||||
```bash
|
```bash
|
||||||
./Server.bin --export-lic server.lic
|
./Server.bin --export-lic server.lic
|
||||||
```
|
```
|
||||||
Import a signed CA certificate via CLI:
|
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||||
```bash
|
```bash
|
||||||
|
./Server.bin --import-lic activated.lic
|
||||||
|
# Or alternatively with --import-ca:
|
||||||
./Server.bin --import-ca ca.crt
|
./Server.bin --import-ca ca.crt
|
||||||
```
|
```
|
||||||
|
Synchronize license and check for seat quota increases against the master authority:
|
||||||
|
```bash
|
||||||
|
./Server.bin --sync-license
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||||
|
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`), multi-tenant fleet isolation, user management, and authenticated WebUI dashboard access.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ Standalone compiled executable and installer distribution for Windows Server and
|
|||||||
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
||||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
|
||||||
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
- **Fleet Seat Accounting & License Revocation**: Each enrolled client consumes 1 seat from the hub's master license. If revoked by an administrator via the hub WebUI, socket connections are immediately dropped and blocked until reactivated.
|
||||||
|
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||||
|
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Standalone compiled executable and installer distribution for Windows Server env
|
|||||||
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
- **Master License Authority (`logar.eibl.tech`) & Seat-Bearing CA**: Integrates with the upstream authority. Subordinate CA certificates embed exact seat quotas via custom enterprise X.509 OID `1.3.6.1.4.1.59999.1.1`.
|
||||||
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
- **Zero-Touch mTLS Renewals**: Once initial signed CA cert is imported, the server hub automatically executes in-flight mTLS renewals with `https://logar.eibl.tech/api/license/renew` when nearing expiration (< 30 days).
|
||||||
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
- **Fleet Management & WebUI Dashboard (Port 8443)**: Modern browser WebUI accessible at `http://localhost:8443/webui` to monitor seat quotas, export `.lic` request files, copy CA CSRs, activate signed CA certs, and revoke/reactivate forwarder seats with immediate socket enforcement.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
|
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Output:
|
|||||||
Transport Security: mTLS (TLS 1.3)
|
Transport Security: mTLS (TLS 1.3)
|
||||||
License Quota: 10 Active Seats
|
License Quota: 10 Active Seats
|
||||||
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
Evaluation Window: 12 hours | 4-Run Rule: Warnings | Immediate Pass: Errors
|
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||||
============================================================
|
============================================================
|
||||||
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
@@ -81,7 +81,7 @@ The generated `server_config.json` contains:
|
|||||||
"tls_enabled": true,
|
"tls_enabled": true,
|
||||||
"db_path": "logar_state.db",
|
"db_path": "logar_state.db",
|
||||||
"evaluation_window_hours": 12,
|
"evaluation_window_hours": 12,
|
||||||
"min_persistence_runs": 4,
|
"min_persistence_runs": 1,
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
|
||||||
@@ -110,8 +110,8 @@ http://localhost:8443/webui
|
|||||||
The WebUI provides:
|
The WebUI provides:
|
||||||
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||||
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||||
3. **Import Signed CA Certificate**: Modal to paste or upload the signed `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||||
4. **Trigger mTLS Renewal Now**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech`.
|
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||||
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||||
|
|
||||||
### CLI Licensing Utilities
|
### CLI Licensing Utilities
|
||||||
@@ -119,10 +119,19 @@ Export a `.lic` request file via CLI:
|
|||||||
```powershell
|
```powershell
|
||||||
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
|
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
|
||||||
```
|
```
|
||||||
Import a signed CA certificate via CLI:
|
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||||
```powershell
|
```powershell
|
||||||
|
.\Server.exe --import-lic C:\LOGAR-Server\activated.lic
|
||||||
|
# Or alternatively with --import-ca:
|
||||||
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
||||||
```
|
```
|
||||||
|
Synchronize license and check for seat quota increases against the master authority:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --sync-license
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||||
|
The upstream Master License Authority service (`logar.eibl.tech`) is compiled and deployed via Portainer GitOps from the [`LOGAR-LICENSING`](https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git) repository, running from `gitea.eibl.tech/me0nline/logar-licensing:latest` with automated pipeline compilation and persistent volume storage. Public traffic passes through a reverse proxy (e.g., Nginx Proxy Manager with **Websockets Support** enabled), handling WebSocket licensing handshakes (`wss://logar.eibl.tech/ws/license`), multi-tenant fleet isolation, user management, and authenticated WebUI dashboard access.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,64 @@
|
|||||||
|
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 \
|
||||||
|
pydantic \
|
||||||
|
requests \
|
||||||
|
# Code quality / linter
|
||||||
|
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`, `pydantic`, `requests`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -6,7 +6,6 @@ Repo: https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
||||||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|||||||
@@ -1,445 +1,72 @@
|
|||||||
|
"""
|
||||||
|
LOGAR Linux Edge Log Forwarder
|
||||||
|
Collects Linux systemd journalctl / syslog entries and securely streams them
|
||||||
|
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import time
|
||||||
import socket
|
|
||||||
import ssl
|
|
||||||
import struct
|
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
|
||||||
import urllib.request
|
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from typing import Optional
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
|
# 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
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
import pgpy
|
import pgpy
|
||||||
|
|
||||||
|
from log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
get_state_path,
|
||||||
|
load_state,
|
||||||
|
save_state,
|
||||||
|
commit_state,
|
||||||
|
LinuxLogCollector,
|
||||||
|
BaseLogCollector
|
||||||
|
)
|
||||||
|
from client_transport import (
|
||||||
|
is_cert_expiring_soon,
|
||||||
|
enroll_client_if_needed,
|
||||||
|
get_tls_socket,
|
||||||
|
load_config,
|
||||||
|
ensure_enrolled as transport_ensure_enrolled,
|
||||||
|
send_encrypted_logs_over_socket as transport_send_encrypted_logs
|
||||||
|
)
|
||||||
|
|
||||||
CONFIG_FILE_NAME = "client_config.json"
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
STATE_FILE_NAME = "client_state.json"
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
|
||||||
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
|
||||||
if not os.path.exists(cert_path):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
from cryptography import x509
|
|
||||||
with open(cert_path, "r", encoding="utf-8") as f:
|
|
||||||
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
|
||||||
expiry = getattr(cert, "not_valid_after_utc", None)
|
|
||||||
if expiry is None:
|
|
||||||
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
return expiry <= (now + timedelta(days=threshold_days))
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def enroll_client_if_needed(
|
|
||||||
hub_url: str,
|
|
||||||
enrollment_secret: str,
|
|
||||||
cert_dir: str,
|
|
||||||
client_id: str,
|
|
||||||
hostname: str,
|
|
||||||
os_type: str = "linux",
|
|
||||||
force_renew: bool = False,
|
|
||||||
threshold_days: int = 30
|
|
||||||
):
|
|
||||||
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
|
||||||
os.makedirs(cert_dir, exist_ok=True)
|
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
||||||
cert_path = os.path.join(cert_dir, "client.crt")
|
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
|
||||||
|
|
||||||
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
|
||||||
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
|
||||||
return True
|
|
||||||
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
|
||||||
|
|
||||||
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
|
||||||
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
|
||||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
|
||||||
payload = {
|
|
||||||
"client_id": client_id,
|
|
||||||
"hostname": hostname,
|
|
||||||
"os": os_type,
|
|
||||||
"enrollment_secret": enrollment_secret
|
|
||||||
}
|
|
||||||
req = urllib.request.Request(
|
|
||||||
enroll_endpoint,
|
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
|
||||||
headers={"Content-Type": "application/json"}
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
if resp.status != 200:
|
|
||||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
with open(ca_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(data["ca_cert"])
|
|
||||||
with open(cert_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(data["client_cert"])
|
|
||||||
with open(key_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(data["client_key"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.chmod(key_path, 0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
|
||||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
||||||
cert_path = os.path.join(cert_dir, "client.crt")
|
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
|
||||||
|
|
||||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
|
||||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
|
||||||
|
|
||||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
|
||||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
||||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
||||||
ctx.check_hostname = False
|
|
||||||
|
|
||||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
|
||||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
|
||||||
|
|
||||||
|
|
||||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
|
||||||
if custom_state_path:
|
|
||||||
return custom_state_path
|
|
||||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
|
||||||
return os.path.join(config_dir, STATE_FILE_NAME)
|
|
||||||
|
|
||||||
|
|
||||||
def load_state(state_path: str) -> dict:
|
|
||||||
if os.path.exists(state_path):
|
|
||||||
try:
|
|
||||||
with open(state_path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
|
||||||
return {}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_state(state_path: str, state: dict):
|
|
||||||
try:
|
|
||||||
temp_path = f"{state_path}.tmp"
|
|
||||||
with open(temp_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(state, f, indent=2)
|
|
||||||
os.replace(temp_path, state_path)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def commit_state(state: dict, state_path: str):
|
|
||||||
if "new_last_cursor" in state:
|
|
||||||
val = state.pop("new_last_cursor")
|
|
||||||
if val:
|
|
||||||
state["last_cursor"] = val
|
|
||||||
if "new_last_timestamp_us" in state:
|
|
||||||
val = state.pop("new_last_timestamp_us")
|
|
||||||
if val:
|
|
||||||
state["last_timestamp_us"] = val
|
|
||||||
if "new_sent_cursors" in state:
|
|
||||||
state["sent_cursors"] = state.pop("new_sent_cursors")
|
|
||||||
save_state(state_path, state)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: str = CONFIG_FILE_NAME):
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"Client configuration file not found at: {config_path}\n"
|
|
||||||
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
|
||||||
)
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def get_machine_identifier() -> str:
|
|
||||||
"""
|
|
||||||
Returns the hostname of the machine sending the logs,
|
|
||||||
and appends the network/DNS domain if available.
|
|
||||||
"""
|
|
||||||
# 1. Try fully-qualified domain name (FQDN)
|
|
||||||
fqdn = socket.getfqdn()
|
|
||||||
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
|
||||||
return fqdn
|
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
|
|
||||||
# 2. Check /etc/resolv.conf domain or search directive
|
|
||||||
try:
|
|
||||||
if os.path.exists("/etc/resolv.conf"):
|
|
||||||
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
parts = line.strip().split()
|
|
||||||
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
|
||||||
domain = parts[1]
|
|
||||||
if domain and not domain.startswith("."):
|
|
||||||
return f"{hostname}.{domain}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Try reverse DNS lookup
|
|
||||||
try:
|
|
||||||
host_ip = socket.gethostbyname(hostname)
|
|
||||||
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
|
||||||
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
|
||||||
return canonical_name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
|
|
||||||
def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
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.
|
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).
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG noise.
|
||||||
State Tracking: Skips events older than lookback window (default 24h) and events
|
State Tracking: Skips events older than lookback window and already sent cursors.
|
||||||
that have already been sent in previous runs.
|
|
||||||
"""
|
"""
|
||||||
last_cursor = None
|
collector = LinuxLogCollector(state=state)
|
||||||
last_timestamp_us = 0.0
|
return collector.collect_logs(hours=hours)
|
||||||
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):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||||
Streams logs to the LOGAR hub.
|
return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux")
|
||||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
|
||||||
or falls back to OpenPGP encrypted envelope over TCP.
|
|
||||||
"""
|
|
||||||
server_host = config["server_host"]
|
|
||||||
server_port = int(config["server_port"])
|
|
||||||
cert_dir = config.get("cert_dir", "certs")
|
|
||||||
enrollment_secret = config.get("enrollment_secret")
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
|
||||||
hub_url = None
|
|
||||||
if enrollment_secret:
|
|
||||||
hermes_host = config.get("hermes_host", server_host)
|
|
||||||
hermes_port = config.get("hermes_port", 8443)
|
|
||||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
|
||||||
try:
|
|
||||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
|
||||||
|
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||||
cert_path = os.path.join(cert_dir, "client.crt")
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="linux")
|
||||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
|
||||||
|
|
||||||
if has_mtls_certs:
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
|
||||||
sock = None
|
|
||||||
try:
|
|
||||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
|
||||||
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
|
||||||
if enrollment_secret and hub_url:
|
|
||||||
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
|
||||||
try:
|
|
||||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux", force_renew=True)
|
|
||||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
|
||||||
except Exception as retry_err:
|
|
||||||
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
with sock:
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
||||||
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
resp_len_bytes = sock.recv(4)
|
|
||||||
if not resp_len_bytes:
|
|
||||||
raise ConnectionError("Server closed mTLS connection without response.")
|
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
|
||||||
resp_bytes = bytearray()
|
|
||||||
while len(resp_bytes) < resp_len:
|
|
||||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
resp_bytes.extend(chunk)
|
|
||||||
|
|
||||||
response = json.loads(resp_bytes.decode("utf-8"))
|
|
||||||
print(f"[+] Server response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Fallback to OpenPGP envelope over plain TCP socket
|
|
||||||
auth_token = config.get("auth_token", "")
|
|
||||||
pub_key_armored = config.get("server_public_key")
|
|
||||||
if not pub_key_armored:
|
|
||||||
raise ValueError("No server public key or mTLS certificates available for connection.")
|
|
||||||
|
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
|
||||||
if expected_fp and actual_fp != expected_fp:
|
|
||||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_json = json.dumps(payload)
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
|
||||||
encrypted_armored = str(encrypted_msg)
|
|
||||||
|
|
||||||
envelope = {
|
|
||||||
"auth_token": auth_token,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"encrypted_payload": encrypted_armored
|
|
||||||
}
|
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
|
||||||
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(15.0)
|
|
||||||
sock.connect((server_host, server_port))
|
|
||||||
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
resp_len_bytes = sock.recv(4)
|
|
||||||
if not resp_len_bytes:
|
|
||||||
raise ConnectionError("Server closed connection without response.")
|
|
||||||
|
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
|
||||||
resp_bytes = bytearray()
|
|
||||||
while len(resp_bytes) < resp_len:
|
|
||||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
resp_bytes.extend(chunk)
|
|
||||||
|
|
||||||
response = json.loads(resp_bytes.decode("utf-8"))
|
|
||||||
print(f"[+] Server response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -448,6 +75,7 @@ def main():
|
|||||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)")
|
parser.add_argument("--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("--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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -459,31 +87,52 @@ def main():
|
|||||||
state_path = get_state_path(args.config, args.state_file)
|
state_path = get_state_path(args.config, args.state_file)
|
||||||
state = None if args.no_state else load_state(state_path)
|
state = None if args.no_state else load_state(state_path)
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
machine_id = config.get("client_id") or get_machine_identifier()
|
||||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||||
if state and ("last_cursor" in state or "last_timestamp_us" in state):
|
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})")
|
print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})")
|
||||||
elif not args.no_state:
|
elif not args.no_state:
|
||||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||||
|
|
||||||
print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...")
|
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
|
||||||
candidate_logs = get_recent_linux_logs(hours=args.hours, state=state)
|
ensure_enrolled(config, machine_id)
|
||||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
|
||||||
|
|
||||||
if not candidate_logs:
|
collector = LinuxLogCollector(state=state, machine_id=machine_id)
|
||||||
print("[*] No new unsent events to transmit.")
|
|
||||||
if state is not None:
|
|
||||||
commit_state(state, state_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
def run_forwarder_cycle():
|
||||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
# Continuously verify/renew PKI enrollment if server is licensed & online
|
||||||
if state is not None and resp and resp.get("status") == "success":
|
ensure_enrolled(config, machine_id)
|
||||||
commit_state(state, state_path)
|
|
||||||
print(f"[+] State successfully committed to {state_path}")
|
print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...")
|
||||||
except Exception as e:
|
candidate_logs = collector.collect_logs(hours=args.hours)
|
||||||
print(f"[!] Failed to stream logs to server: {e}")
|
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
||||||
sys.exit(1)
|
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,457 +1,144 @@
|
|||||||
import os
|
"""
|
||||||
import sys
|
LOGAR Windows Edge Log Forwarder
|
||||||
import json
|
Collects Windows Event Logs (Application/System) and securely streams them
|
||||||
import socket
|
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
|
||||||
import ssl
|
"""
|
||||||
import struct
|
|
||||||
import argparse
|
import os
|
||||||
import urllib.request
|
import sys
|
||||||
import warnings
|
import time
|
||||||
from datetime import datetime, timezone, timedelta
|
import argparse
|
||||||
from typing import Optional, Dict, Any, List
|
import warnings
|
||||||
|
from typing import Optional
|
||||||
# Suppress cryptography / pgpy deprecation notices
|
|
||||||
warnings.filterwarnings("ignore")
|
# Ensure script directory and repo root are in sys.path
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
import pgpy
|
if SCRIPT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SCRIPT_DIR)
|
||||||
try:
|
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
|
||||||
import win32evtlog
|
if REPO_ROOT not in sys.path:
|
||||||
except ImportError:
|
sys.path.insert(0, REPO_ROOT)
|
||||||
win32evtlog = None
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
CONFIG_FILE_NAME = "client_config.json"
|
warnings.filterwarnings("ignore")
|
||||||
STATE_FILE_NAME = "client_state.json"
|
|
||||||
|
import pgpy
|
||||||
|
|
||||||
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
try:
|
||||||
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
import win32evtlog
|
||||||
if not os.path.exists(cert_path):
|
except ImportError:
|
||||||
return True
|
win32evtlog = None
|
||||||
try:
|
|
||||||
from cryptography import x509
|
from log_collector import (
|
||||||
with open(cert_path, "r", encoding="utf-8") as f:
|
get_machine_identifier,
|
||||||
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
is_path_writable,
|
||||||
expiry = getattr(cert, "not_valid_after_utc", None)
|
get_default_data_dir,
|
||||||
if expiry is None:
|
resolve_writable_path,
|
||||||
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
get_state_path,
|
||||||
now = datetime.now(timezone.utc)
|
load_state,
|
||||||
return expiry <= (now + timedelta(days=threshold_days))
|
save_state,
|
||||||
except Exception:
|
commit_state,
|
||||||
return True
|
WindowsLogCollector,
|
||||||
|
BaseLogCollector
|
||||||
|
)
|
||||||
def enroll_client_if_needed(
|
from client_transport import (
|
||||||
hub_url: str,
|
is_cert_expiring_soon,
|
||||||
enrollment_secret: str,
|
enroll_client_if_needed,
|
||||||
cert_dir: str,
|
get_tls_socket,
|
||||||
client_id: str,
|
load_config,
|
||||||
hostname: str,
|
ensure_enrolled as transport_ensure_enrolled,
|
||||||
os_type: str = "windows",
|
send_encrypted_logs_over_socket as transport_send_encrypted_logs
|
||||||
force_renew: bool = False,
|
)
|
||||||
threshold_days: int = 30
|
|
||||||
):
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
STATE_FILE_NAME = "client_state.json"
|
||||||
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")
|
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
"""
|
||||||
|
Scans the Windows Application Event Log backwards for events within the window.
|
||||||
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||||
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
State Tracking: Skips events older than lookback window and already sent records.
|
||||||
return True
|
"""
|
||||||
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
collector = WindowsLogCollector(state=state, channels=["Application"])
|
||||||
|
return collector.collect_logs(hours=hours)
|
||||||
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"
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
payload = {
|
"""Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
|
||||||
"client_id": client_id,
|
return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows")
|
||||||
"hostname": hostname,
|
|
||||||
"os": os_type,
|
|
||||||
"enrollment_secret": enrollment_secret
|
def ensure_enrolled(config: dict, machine_id: str) -> bool:
|
||||||
}
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
req = urllib.request.Request(
|
return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows")
|
||||||
enroll_endpoint,
|
|
||||||
data=json.dumps(payload).encode("utf-8"),
|
|
||||||
headers={"Content-Type": "application/json"}
|
def main():
|
||||||
)
|
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
||||||
if resp.status != 200:
|
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
||||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
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)")
|
||||||
with open(ca_path, "w", encoding="utf-8") as f:
|
args = parser.parse_args()
|
||||||
f.write(data["ca_cert"])
|
|
||||||
with open(cert_path, "w", encoding="utf-8") as f:
|
try:
|
||||||
f.write(data["client_cert"])
|
config = load_config(args.config)
|
||||||
with open(key_path, "w", encoding="utf-8") as f:
|
except Exception as e:
|
||||||
f.write(data["client_key"])
|
print(f"[!] Configuration error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
try:
|
|
||||||
os.chmod(key_path, 0o600)
|
state_path = get_state_path(args.config, args.state_file)
|
||||||
except Exception:
|
state = None if args.no_state else load_state(state_path)
|
||||||
pass
|
|
||||||
|
machine_id = config.get("client_id") or get_machine_identifier()
|
||||||
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
print(f"[*] Edge Forwarder Node: {machine_id}")
|
||||||
return True
|
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:
|
||||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
print(f"[*] State tracking initialized (state file: {state_path})")
|
||||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
# Proactively ensure dynamic PKI certificate enrollment with Server Hub
|
||||||
cert_path = os.path.join(cert_dir, "client.crt")
|
ensure_enrolled(config, machine_id)
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
|
||||||
|
collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"])
|
||||||
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.")
|
def run_forwarder_cycle():
|
||||||
|
# Continuously verify/renew PKI enrollment if server is licensed & online
|
||||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
ensure_enrolled(config, machine_id)
|
||||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
||||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
||||||
ctx.check_hostname = False
|
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).")
|
||||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
|
||||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
if not candidate_logs:
|
||||||
|
print("[*] No new unsent events to transmit.")
|
||||||
|
if state is not None:
|
||||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
commit_state(state, state_path)
|
||||||
if custom_state_path:
|
return
|
||||||
return custom_state_path
|
|
||||||
config_dir = os.path.dirname(os.path.abspath(config_path))
|
try:
|
||||||
return os.path.join(config_dir, STATE_FILE_NAME)
|
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)
|
||||||
def load_state(state_path: str) -> dict:
|
print(f"[+] State successfully committed to {state_path}")
|
||||||
if os.path.exists(state_path):
|
except Exception as e:
|
||||||
try:
|
print(f"[!] Failed to stream logs to server: {e}")
|
||||||
with open(state_path, "r", encoding="utf-8") as f:
|
if not args.interval:
|
||||||
return json.load(f)
|
sys.exit(1)
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
if args.interval and args.interval > 0:
|
||||||
return {}
|
print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
|
||||||
return {}
|
while True:
|
||||||
|
try:
|
||||||
|
run_forwarder_cycle()
|
||||||
def save_state(state_path: str, state: dict):
|
except Exception as e:
|
||||||
try:
|
print(f"[!] Error in forwarder cycle: {e}")
|
||||||
temp_path = f"{state_path}.tmp"
|
time.sleep(args.interval)
|
||||||
with open(temp_path, "w", encoding="utf-8") as f:
|
else:
|
||||||
json.dump(state, f, indent=2)
|
run_forwarder_cycle()
|
||||||
os.replace(temp_path, state_path)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
def commit_state(state: dict, state_path: str):
|
|
||||||
if "new_last_record_number" in state:
|
|
||||||
val = state.pop("new_last_record_number")
|
|
||||||
if val:
|
|
||||||
state["last_record_number"] = val
|
|
||||||
if "new_sent_record_ids" in state:
|
|
||||||
state["sent_record_ids"] = state.pop("new_sent_record_ids")
|
|
||||||
save_state(state_path, state)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: str = CONFIG_FILE_NAME):
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"Client configuration file not found at: {config_path}\n"
|
|
||||||
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
|
||||||
)
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def get_machine_identifier() -> str:
|
|
||||||
"""
|
|
||||||
Returns the hostname of the machine sending the logs,
|
|
||||||
and appends the network/DNS domain if available.
|
|
||||||
"""
|
|
||||||
fqdn = socket.getfqdn()
|
|
||||||
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
|
||||||
return fqdn
|
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
user_dns_domain = os.environ.get("USERDNSDOMAIN")
|
|
||||||
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
|
|
||||||
return f"{hostname}.{user_dns_domain.lower()}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip = socket.gethostbyname(hostname)
|
|
||||||
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
|
||||||
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
|
||||||
return canonical_name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
|
|
||||||
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
|
|
||||||
"""
|
|
||||||
Scans the Windows Application Event Log backwards for events within the window.
|
|
||||||
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
|
||||||
State Tracking: Skips events older than lookback window (default 24h) and events
|
|
||||||
that have already been sent in previous runs.
|
|
||||||
"""
|
|
||||||
if win32evtlog is None:
|
|
||||||
print("[!] pywin32 is not installed or not running on Windows. Returning mock/empty candidate list.")
|
|
||||||
return []
|
|
||||||
|
|
||||||
server = "localhost"
|
|
||||||
log_type = "Application"
|
|
||||||
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
|
||||||
|
|
||||||
try:
|
|
||||||
hand = win32evtlog.OpenEventLog(server, log_type)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Error opening Windows event log: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
cutoff_time = datetime.now() - timedelta(hours=hours)
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
last_record_number = 0
|
|
||||||
sent_record_ids = set()
|
|
||||||
if state:
|
|
||||||
last_record_number = int(state.get("last_record_number", 0))
|
|
||||||
sent_record_ids = set(state.get("sent_record_ids", []))
|
|
||||||
|
|
||||||
# Windows Event Log EventTypes:
|
|
||||||
# 1: EVENTLOG_ERROR_TYPE -> ERROR
|
|
||||||
# 2: EVENTLOG_WARNING_TYPE -> WARNING
|
|
||||||
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
|
|
||||||
# Excludes: 8 (Audit Success), 16 (Audit Failure), and other verbose noise
|
|
||||||
sev_map = {
|
|
||||||
1: "ERROR",
|
|
||||||
2: "WARNING",
|
|
||||||
4: "INFO"
|
|
||||||
}
|
|
||||||
|
|
||||||
newest_record_number = 0
|
|
||||||
collected_record_ids = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
|
||||||
if not events:
|
|
||||||
break
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
rec_num = int(event.RecordNumber)
|
|
||||||
if newest_record_number == 0:
|
|
||||||
newest_record_number = rec_num
|
|
||||||
|
|
||||||
# 1. Skip entries older than lookback window (default: 24h)
|
|
||||||
if event.TimeGenerated < cutoff_time:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 2. Skip already sent events if we've reached records <= last_record_number
|
|
||||||
# (unless the log was cleared and numbers wrapped, i.e. newest_record_number < last_record_number)
|
|
||||||
if last_record_number > 0 and newest_record_number >= last_record_number:
|
|
||||||
if rec_num <= last_record_number:
|
|
||||||
break
|
|
||||||
|
|
||||||
rec_id = f"{rec_num}:{event.TimeGenerated.isoformat()}"
|
|
||||||
if rec_id in sent_record_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Filter: upload everything from INFO to ERROR only
|
|
||||||
if event.EventType in sev_map:
|
|
||||||
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
|
|
||||||
logs.append({
|
|
||||||
"server": machine_id,
|
|
||||||
"os_type": "windows",
|
|
||||||
"signature": event.SourceName or "Windows-Event",
|
|
||||||
"severity": sev_map[event.EventType],
|
|
||||||
"message": msg[:2048] # Limit message length
|
|
||||||
})
|
|
||||||
collected_record_ids.append(rec_id)
|
|
||||||
|
|
||||||
if events[-1].TimeGenerated < cutoff_time:
|
|
||||||
break
|
|
||||||
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
|
|
||||||
break
|
|
||||||
|
|
||||||
win32evtlog.CloseEventLog(hand)
|
|
||||||
|
|
||||||
if state is not None:
|
|
||||||
target_rec = max(newest_record_number, last_record_number)
|
|
||||||
state["new_last_record_number"] = target_rec
|
|
||||||
state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
|
|
||||||
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
return logs
|
|
||||||
|
|
||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
|
||||||
"""
|
|
||||||
Streams logs to the LOGAR hub.
|
|
||||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
|
||||||
or falls back to OpenPGP encrypted envelope over TCP.
|
|
||||||
"""
|
|
||||||
server_host = config["server_host"]
|
|
||||||
server_port = int(config["server_port"])
|
|
||||||
cert_dir = config.get("cert_dir", "certs")
|
|
||||||
enrollment_secret = config.get("enrollment_secret")
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
|
||||||
hub_url = None
|
|
||||||
if enrollment_secret:
|
|
||||||
hermes_host = config.get("hermes_host", server_host)
|
|
||||||
hermes_port = config.get("hermes_port", 8443)
|
|
||||||
hub_url = f"http://{hermes_host}:{hermes_port}"
|
|
||||||
try:
|
|
||||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
|
||||||
|
|
||||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
|
||||||
cert_path = os.path.join(cert_dir, "client.crt")
|
|
||||||
key_path = os.path.join(cert_dir, "client.key")
|
|
||||||
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
|
||||||
|
|
||||||
if has_mtls_certs:
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
|
||||||
sock = None
|
|
||||||
try:
|
|
||||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
|
||||||
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
|
||||||
if enrollment_secret and hub_url:
|
|
||||||
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
|
||||||
try:
|
|
||||||
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows", force_renew=True)
|
|
||||||
sock = get_tls_socket(server_host, server_port, cert_dir)
|
|
||||||
except Exception as retry_err:
|
|
||||||
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
with sock:
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
||||||
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
resp_len_bytes = sock.recv(4)
|
|
||||||
if not resp_len_bytes:
|
|
||||||
raise ConnectionError("Server closed mTLS connection without response.")
|
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
|
||||||
resp_bytes = bytearray()
|
|
||||||
while len(resp_bytes) < resp_len:
|
|
||||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
resp_bytes.extend(chunk)
|
|
||||||
|
|
||||||
response = json.loads(resp_bytes.decode("utf-8"))
|
|
||||||
print(f"[+] Server response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Fallback to OpenPGP envelope over plain TCP socket
|
|
||||||
auth_token = config.get("auth_token", "")
|
|
||||||
pub_key_armored = config.get("server_public_key")
|
|
||||||
if not pub_key_armored:
|
|
||||||
raise ValueError("No server public key or mTLS certificates available for connection.")
|
|
||||||
|
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
|
||||||
if expected_fp and actual_fp != expected_fp:
|
|
||||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"server": machine_id,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"logs": logs
|
|
||||||
}
|
|
||||||
payload_json = json.dumps(payload)
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
|
||||||
encrypted_armored = str(encrypted_msg)
|
|
||||||
|
|
||||||
envelope = {
|
|
||||||
"auth_token": auth_token,
|
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"encrypted_payload": encrypted_armored
|
|
||||||
}
|
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
|
||||||
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
||||||
sock.settimeout(15.0)
|
|
||||||
sock.connect((server_host, server_port))
|
|
||||||
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
|
||||||
sock.sendall(frame)
|
|
||||||
|
|
||||||
resp_len_bytes = sock.recv(4)
|
|
||||||
if not resp_len_bytes:
|
|
||||||
raise ConnectionError("Server closed connection without response.")
|
|
||||||
|
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
|
||||||
resp_bytes = bytearray()
|
|
||||||
while len(resp_bytes) < resp_len:
|
|
||||||
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
resp_bytes.extend(chunk)
|
|
||||||
|
|
||||||
response = json.loads(resp_bytes.decode("utf-8"))
|
|
||||||
print(f"[+] Server response: {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
|
|
||||||
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
|
||||||
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
|
|
||||||
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
|
|
||||||
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
config = load_config(args.config)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Configuration error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
state_path = get_state_path(args.config, args.state_file)
|
|
||||||
state = None if args.no_state else load_state(state_path)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
print(f"[*] Edge Forwarder Node: {machine_id}")
|
|
||||||
if state and "last_record_number" in state:
|
|
||||||
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
|
|
||||||
elif not args.no_state:
|
|
||||||
print(f"[*] State tracking initialized (state file: {state_path})")
|
|
||||||
|
|
||||||
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
|
|
||||||
candidate_logs = get_recent_windows_logs(hours=args.hours, state=state)
|
|
||||||
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
|
|
||||||
|
|
||||||
if not candidate_logs:
|
|
||||||
print("[*] No new unsent events to transmit.")
|
|
||||||
if state is not None:
|
|
||||||
commit_state(state, state_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = send_encrypted_logs_over_socket(config, candidate_logs)
|
|
||||||
if state is not None and resp and resp.get("status") == "success":
|
|
||||||
commit_state(state, state_path)
|
|
||||||
print(f"[+] State successfully committed to {state_path}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Failed to stream logs to server: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()␍
|
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
LOGAR - Unified Client Transport & PKI Enrollment Module
|
||||||
|
Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing,
|
||||||
|
and dynamic certificate enrollment with the LOGAR Server Hub.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import urllib.request
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
import pgpy
|
||||||
|
from log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||||
|
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||||
|
if not os.path.exists(cert_path):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as f:
|
||||||
|
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||||
|
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||||
|
if expiry is None:
|
||||||
|
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return expiry <= (now + timedelta(days=threshold_days))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_client_if_needed(
|
||||||
|
hub_url: str,
|
||||||
|
enrollment_secret: str,
|
||||||
|
cert_dir: str,
|
||||||
|
client_id: str,
|
||||||
|
hostname: str,
|
||||||
|
os_type: str = "windows",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> bool:
|
||||||
|
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||||
|
return True
|
||||||
|
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||||
|
|
||||||
|
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||||
|
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||||
|
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||||
|
payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os": os_type,
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
enroll_endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_key"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||||
|
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||||
|
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||||
|
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.check_hostname = False
|
||||||
|
|
||||||
|
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||||
|
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = CONFIG_FILE_NAME) -> dict:
|
||||||
|
"""Loads and normalizes client configuration JSON."""
|
||||||
|
actual_config_path = config_path
|
||||||
|
if not os.path.exists(actual_config_path):
|
||||||
|
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
|
||||||
|
if os.path.exists(data_dir_cfg):
|
||||||
|
actual_config_path = data_dir_cfg
|
||||||
|
|
||||||
|
if not os.path.exists(actual_config_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Client configuration file not found at: {config_path}\n"
|
||||||
|
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
|
||||||
|
)
|
||||||
|
with open(actual_config_path, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Resolve cert_dir to a writable directory
|
||||||
|
cfg_dir = os.path.dirname(os.path.abspath(actual_config_path))
|
||||||
|
raw_cert_dir = config.get("cert_dir", "certs")
|
||||||
|
if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir):
|
||||||
|
config["cert_dir"] = raw_cert_dir
|
||||||
|
else:
|
||||||
|
candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir))
|
||||||
|
if is_path_writable(candidate) or is_path_writable(cfg_dir):
|
||||||
|
config["cert_dir"] = candidate
|
||||||
|
else:
|
||||||
|
config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs")
|
||||||
|
|
||||||
|
# If ca_cert is present in config and ca.crt doesn't exist yet, save it
|
||||||
|
ca_cert_pem = config.get("ca_cert")
|
||||||
|
if ca_cert_pem and isinstance(ca_cert_pem, str):
|
||||||
|
ca_file = os.path.join(config["cert_dir"], "ca.crt")
|
||||||
|
if not os.path.exists(ca_file):
|
||||||
|
try:
|
||||||
|
os.makedirs(config["cert_dir"], exist_ok=True)
|
||||||
|
with open(ca_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(ca_cert_pem)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool:
|
||||||
|
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
|
if not enrollment_secret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
server_host = config.get("server_host", "127.0.0.1")
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None, ""):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_encrypted_logs_over_socket(
|
||||||
|
config: dict,
|
||||||
|
logs: list,
|
||||||
|
os_type: Optional[str] = None,
|
||||||
|
machine_id: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Streams logs to the LOGAR hub.
|
||||||
|
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||||
|
or falls back to OpenPGP encrypted envelope over TCP socket.
|
||||||
|
"""
|
||||||
|
node_os = os_type or ("windows" if sys.platform == "win32" else "linux")
|
||||||
|
server_host = config["server_host"]
|
||||||
|
server_port = int(config["server_port"])
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
|
actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier()
|
||||||
|
|
||||||
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if not has_mtls_certs and enrollment_secret and hub_url:
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment before TLS connection failed: {e}")
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": actual_machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
|
if expected_fp and actual_fp != expected_fp:
|
||||||
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"server": actual_machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"auth_token": auth_token,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"encrypted_payload": encrypted_armored
|
||||||
|
}
|
||||||
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.settimeout(15.0)
|
||||||
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
"""
|
||||||
|
LOGAR - Unified Cross-Platform Log Collector & State Management
|
||||||
|
Provides modular log collection classes and state management for Windows and Linux,
|
||||||
|
reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import warnings
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any, Set
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import win32evtlog
|
||||||
|
except ImportError:
|
||||||
|
win32evtlog = None
|
||||||
|
|
||||||
|
CONFIG_FILE_NAME = "client_config.json"
|
||||||
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_identifier() -> str:
|
||||||
|
"""
|
||||||
|
Returns the fully qualified hostname of the machine sending/collecting logs.
|
||||||
|
Cross-platform resolution:
|
||||||
|
1. FQDN (if non-local)
|
||||||
|
2. Windows USERDNSDOMAIN (on Windows)
|
||||||
|
3. Linux /etc/resolv.conf domain/search directive (on Linux)
|
||||||
|
4. Reverse DNS lookup
|
||||||
|
5. socket.gethostname() fallback
|
||||||
|
"""
|
||||||
|
fqdn = socket.getfqdn()
|
||||||
|
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
||||||
|
return fqdn
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
|
||||||
|
# Windows DNS domain
|
||||||
|
if sys.platform == "win32":
|
||||||
|
user_dns_domain = os.environ.get("USERDNSDOMAIN")
|
||||||
|
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
|
||||||
|
return f"{hostname}.{user_dns_domain.lower()}"
|
||||||
|
|
||||||
|
# Linux resolv.conf search / domain
|
||||||
|
if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"):
|
||||||
|
try:
|
||||||
|
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
||||||
|
domain = parts[1]
|
||||||
|
if domain and not domain.startswith("."):
|
||||||
|
return f"{hostname}.{domain}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reverse DNS lookup
|
||||||
|
try:
|
||||||
|
host_ip = socket.gethostbyname(hostname)
|
||||||
|
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
||||||
|
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
||||||
|
return canonical_name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_writable(filepath: str) -> bool:
|
||||||
|
"""Checks if a file or directory path is actually writable by the current process."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
if os.path.isdir(filepath):
|
||||||
|
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
with open(filepath, "a") as f:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_data_dir(app_name: str = "LOGAR") -> str:
|
||||||
|
"""Returns a suitable writable application data directory."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# 1. ProgramData (C:\ProgramData\LOGAR)
|
||||||
|
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
||||||
|
target_prog = os.path.join(prog_data, app_name)
|
||||||
|
if is_path_writable(target_prog):
|
||||||
|
return target_prog
|
||||||
|
|
||||||
|
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
|
||||||
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
||||||
|
target_local = os.path.join(local_app, app_name)
|
||||||
|
if is_path_writable(target_local):
|
||||||
|
return target_local
|
||||||
|
|
||||||
|
# 3. Temp directory (%TEMP%\LOGAR)
|
||||||
|
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
||||||
|
if is_path_writable(temp_dir):
|
||||||
|
return temp_dir
|
||||||
|
else:
|
||||||
|
# Linux / Unix candidates: /var/lib/<app_name>, /tmp/<app_name>, ~/.local/share/<app_name>
|
||||||
|
var_lib = f"/var/lib/{app_name.lower()}"
|
||||||
|
if is_path_writable(var_lib):
|
||||||
|
return var_lib
|
||||||
|
user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}")
|
||||||
|
if is_path_writable(user_share):
|
||||||
|
return user_share
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
if is_path_writable(cwd):
|
||||||
|
return cwd
|
||||||
|
return os.path.expanduser("~")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
|
||||||
|
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
||||||
|
if not path:
|
||||||
|
path = default_filename
|
||||||
|
|
||||||
|
if os.path.isabs(path) and is_path_writable(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
||||||
|
return os.path.abspath(path)
|
||||||
|
|
||||||
|
data_dir = get_default_data_dir(app_name)
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
return os.path.join(data_dir, fname)
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
|
if custom_state_path:
|
||||||
|
return custom_state_path
|
||||||
|
config_dir = os.path.dirname(os.path.abspath(config_path))
|
||||||
|
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
|
||||||
|
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
|
||||||
|
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(state_path: str) -> dict:
|
||||||
|
"""Loads state tracking dictionary from disk."""
|
||||||
|
if os.path.exists(state_path):
|
||||||
|
try:
|
||||||
|
with open(state_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state_path: str, state: dict):
|
||||||
|
"""Atomically saves state dictionary to disk."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
|
||||||
|
temp_path = f"{state_path}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
os.replace(temp_path, state_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def commit_state(state: dict, state_path: str):
|
||||||
|
"""
|
||||||
|
Commits staged cursor/record state keys to persistent fields and writes to state_path.
|
||||||
|
Supports both Windows record numbers and Linux cursors/timestamps.
|
||||||
|
"""
|
||||||
|
if "new_last_record_number" in state:
|
||||||
|
val = state.pop("new_last_record_number")
|
||||||
|
if val:
|
||||||
|
state["last_record_number"] = val
|
||||||
|
if "new_sent_record_ids" in state:
|
||||||
|
state["sent_record_ids"] = state.pop("new_sent_record_ids")
|
||||||
|
if "new_last_cursor" in state:
|
||||||
|
val = state.pop("new_last_cursor")
|
||||||
|
if val:
|
||||||
|
state["last_cursor"] = val
|
||||||
|
if "new_last_timestamp_us" in state:
|
||||||
|
val = state.pop("new_last_timestamp_us")
|
||||||
|
if val:
|
||||||
|
state["last_timestamp_us"] = val
|
||||||
|
if "new_sent_cursors" in state:
|
||||||
|
state["sent_cursors"] = state.pop("new_sent_cursors")
|
||||||
|
save_state(state_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseLogCollector(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for platform-specific edge and hub log collectors.
|
||||||
|
Manages state persistence, machine identifiers, lookback windows, and normalization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||||
|
self.state = state if state is not None else {}
|
||||||
|
self.machine_id = machine_id or get_machine_identifier()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Collects normalized logs within the given lookback window.
|
||||||
|
Returns a list of dicts:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"server": str,
|
||||||
|
"os_type": "windows"|"linux",
|
||||||
|
"signature": str,
|
||||||
|
"severity": "ERROR"|"WARNING"|"INFO",
|
||||||
|
"message": str
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self, state_path: Optional[str] = None):
|
||||||
|
"""Commits staged state keys and optionally writes to disk."""
|
||||||
|
if state_path:
|
||||||
|
commit_state(self.state, state_path)
|
||||||
|
else:
|
||||||
|
if "new_last_record_number" in self.state:
|
||||||
|
val = self.state.pop("new_last_record_number")
|
||||||
|
if val:
|
||||||
|
self.state["last_record_number"] = val
|
||||||
|
if "new_sent_record_ids" in self.state:
|
||||||
|
self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids")
|
||||||
|
if "new_last_cursor" in self.state:
|
||||||
|
val = self.state.pop("new_last_cursor")
|
||||||
|
if val:
|
||||||
|
self.state["last_cursor"] = val
|
||||||
|
if "new_last_timestamp_us" in self.state:
|
||||||
|
val = self.state.pop("new_last_timestamp_us")
|
||||||
|
if val:
|
||||||
|
self.state["last_timestamp_us"] = val
|
||||||
|
if "new_sent_cursors" in self.state:
|
||||||
|
self.state["sent_cursors"] = self.state.pop("new_sent_cursors")
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsLogCollector(BaseLogCollector):
|
||||||
|
"""
|
||||||
|
Windows Event Log Collector.
|
||||||
|
Reads Application and System (or custom channels) backwards.
|
||||||
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
|
||||||
|
State Tracking: Skips events older than lookback window and already sent records.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
state: Optional[dict] = None,
|
||||||
|
machine_id: Optional[str] = None,
|
||||||
|
channels: Optional[List[str]] = None
|
||||||
|
):
|
||||||
|
super().__init__(state=state, machine_id=machine_id)
|
||||||
|
self.channels = channels or ["Application"]
|
||||||
|
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
if win32evtlog is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
last_record_number = 0
|
||||||
|
sent_record_ids: Set[str] = set()
|
||||||
|
if self.state:
|
||||||
|
last_record_number = int(self.state.get("last_record_number", 0))
|
||||||
|
sent_record_ids = set(self.state.get("sent_record_ids", []))
|
||||||
|
|
||||||
|
# Windows Event Log EventTypes:
|
||||||
|
# 1: EVENTLOG_ERROR_TYPE -> ERROR
|
||||||
|
# 2: EVENTLOG_WARNING_TYPE -> WARNING
|
||||||
|
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
|
||||||
|
sev_map = {
|
||||||
|
1: "ERROR",
|
||||||
|
2: "WARNING",
|
||||||
|
4: "INFO"
|
||||||
|
}
|
||||||
|
|
||||||
|
newest_record_number = 0
|
||||||
|
collected_record_ids = []
|
||||||
|
|
||||||
|
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
|
||||||
|
|
||||||
|
for log_type in self.channels:
|
||||||
|
try:
|
||||||
|
hand = win32evtlog.OpenEventLog("localhost", log_type)
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
events = win32evtlog.ReadEventLog(hand, flags, 0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if not events:
|
||||||
|
break
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
rec_num = int(event.RecordNumber)
|
||||||
|
if newest_record_number == 0:
|
||||||
|
newest_record_number = rec_num
|
||||||
|
|
||||||
|
# 1. Skip entries older than lookback window
|
||||||
|
if event.TimeGenerated < cutoff_time:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. Skip already sent events
|
||||||
|
if last_record_number > 0 and newest_record_number >= last_record_number:
|
||||||
|
if rec_num <= last_record_number:
|
||||||
|
break
|
||||||
|
|
||||||
|
rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}"
|
||||||
|
if rec_id in sent_record_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter: upload everything from INFO to ERROR
|
||||||
|
if event.EventType in sev_map:
|
||||||
|
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
|
||||||
|
sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event")
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "windows",
|
||||||
|
"signature": sig,
|
||||||
|
"severity": sev_map[event.EventType],
|
||||||
|
"message": msg[:2048]
|
||||||
|
})
|
||||||
|
collected_record_ids.append(rec_id)
|
||||||
|
|
||||||
|
if events[-1].TimeGenerated < cutoff_time:
|
||||||
|
break
|
||||||
|
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
win32evtlog.CloseEventLog(hand)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
target_rec = max(newest_record_number, last_record_number)
|
||||||
|
self.state["new_last_record_number"] = target_rec
|
||||||
|
self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
|
||||||
|
# Also update direct keys for immediate server-side ingestion
|
||||||
|
self.state["last_record_number"] = target_rec
|
||||||
|
self.state["sent_record_ids"] = self.state["new_sent_record_ids"]
|
||||||
|
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxLogCollector(BaseLogCollector):
|
||||||
|
"""
|
||||||
|
Linux Systemd Journal / Syslog Log Collector.
|
||||||
|
Collects info, warnings, and errors from journalctl (or syslog fallback).
|
||||||
|
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise.
|
||||||
|
State Tracking: Skips events older than lookback window and already sent cursors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
|
||||||
|
super().__init__(state=state, machine_id=machine_id)
|
||||||
|
|
||||||
|
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
last_cursor = None
|
||||||
|
last_timestamp_us = 0.0
|
||||||
|
sent_cursors: Set[str] = set()
|
||||||
|
if self.state:
|
||||||
|
last_cursor = self.state.get("last_cursor")
|
||||||
|
try:
|
||||||
|
last_timestamp_us = float(self.state.get("last_timestamp_us", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
last_timestamp_us = 0.0
|
||||||
|
sent_cursors = set(self.state.get("sent_cursors", []))
|
||||||
|
|
||||||
|
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
|
||||||
|
result = None
|
||||||
|
|
||||||
|
if last_cursor:
|
||||||
|
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
|
||||||
|
if res.returncode == 0:
|
||||||
|
result = res
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# journalctl not present, attempt fallback to /var/log/syslog or messages
|
||||||
|
return self._collect_syslog_fallback(hours=hours)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Error running journalctl: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result or result.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
|
||||||
|
|
||||||
|
newest_cursor = None
|
||||||
|
newest_timestamp_us = last_timestamp_us
|
||||||
|
collected_cursors = []
|
||||||
|
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line_str = line.strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line_str)
|
||||||
|
entry_cursor = entry.get("__CURSOR")
|
||||||
|
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
|
||||||
|
|
||||||
|
entry_ts_us = 0.0
|
||||||
|
if entry_ts_us_raw:
|
||||||
|
try:
|
||||||
|
entry_ts_us = float(entry_ts_us_raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 1. Skip entries older than lookback window
|
||||||
|
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. Skip already sent events
|
||||||
|
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
|
||||||
|
continue
|
||||||
|
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Advance tracking
|
||||||
|
if entry_cursor:
|
||||||
|
newest_cursor = entry_cursor
|
||||||
|
collected_cursors.append(entry_cursor)
|
||||||
|
if entry_ts_us > newest_timestamp_us:
|
||||||
|
newest_timestamp_us = entry_ts_us
|
||||||
|
|
||||||
|
priority = int(entry.get("PRIORITY", "6"))
|
||||||
|
if priority > 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if priority <= 3:
|
||||||
|
sev = "ERROR"
|
||||||
|
elif priority in (4, 5):
|
||||||
|
sev = "WARNING"
|
||||||
|
else:
|
||||||
|
sev = "INFO"
|
||||||
|
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "linux",
|
||||||
|
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
||||||
|
"severity": sev,
|
||||||
|
"message": entry.get("MESSAGE", "")[:2048]
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.state is not None:
|
||||||
|
self.state["new_last_cursor"] = newest_cursor or last_cursor
|
||||||
|
self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
|
||||||
|
self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
|
||||||
|
# Also update direct keys for immediate server-side ingestion
|
||||||
|
self.state["last_cursor"] = self.state["new_last_cursor"]
|
||||||
|
self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"]
|
||||||
|
self.state["sent_cursors"] = self.state["new_sent_cursors"]
|
||||||
|
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return logs
|
||||||
|
|
||||||
|
def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]:
|
||||||
|
"""Fallback log reader for non-systemd Linux environments."""
|
||||||
|
candidates = ["/var/log/syslog", "/var/log/messages"]
|
||||||
|
target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None)
|
||||||
|
if not target:
|
||||||
|
return []
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
try:
|
||||||
|
with open(target, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
lines = f.readlines()[-500:] # Read recent 500 lines
|
||||||
|
for line in lines:
|
||||||
|
line_str = line.strip()
|
||||||
|
if not line_str:
|
||||||
|
continue
|
||||||
|
sev = "INFO"
|
||||||
|
lower_l = line_str.lower()
|
||||||
|
if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]):
|
||||||
|
sev = "ERROR"
|
||||||
|
elif any(w in lower_l for w in ["warn", "notice"]):
|
||||||
|
sev = "WARNING"
|
||||||
|
|
||||||
|
logs.append({
|
||||||
|
"server": self.machine_id,
|
||||||
|
"os_type": "linux",
|
||||||
|
"signature": "syslog",
|
||||||
|
"severity": sev,
|
||||||
|
"message": line_str[:2048]
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
def get_log_collector(
|
||||||
|
os_type: Optional[str] = None,
|
||||||
|
state: Optional[dict] = None,
|
||||||
|
machine_id: Optional[str] = None,
|
||||||
|
channels: Optional[List[str]] = None
|
||||||
|
) -> BaseLogCollector:
|
||||||
|
"""
|
||||||
|
Factory helper that returns the appropriate BaseLogCollector instance.
|
||||||
|
If os_type is omitted, auto-detects based on sys.platform.
|
||||||
|
"""
|
||||||
|
target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux")
|
||||||
|
if target_os == "windows":
|
||||||
|
chans = channels or (["Application", "System"] if channels is None else ["Application"])
|
||||||
|
return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans)
|
||||||
|
else:
|
||||||
|
return LinuxLogCollector(state=state, machine_id=machine_id)
|
||||||
@@ -251,8 +251,24 @@ def generate_server_cert_if_needed(
|
|||||||
encryption_algorithm=serialization.NoEncryption()
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
).decode("utf-8")
|
).decode("utf-8")
|
||||||
|
|
||||||
|
# Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain
|
||||||
|
full_server_cert_pem = server_cert_pem
|
||||||
|
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
if os.path.exists(ca_cert_path):
|
||||||
|
with open(ca_cert_path, "r", encoding="utf-8") as f:
|
||||||
|
ca_pem = f.read().strip()
|
||||||
|
if ca_pem and ca_pem not in full_server_cert_pem:
|
||||||
|
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem
|
||||||
|
|
||||||
|
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
if os.path.exists(upstream_ca_path):
|
||||||
|
with open(upstream_ca_path, "r", encoding="utf-8") as f:
|
||||||
|
upstream_pem = f.read().strip()
|
||||||
|
if upstream_pem and upstream_pem not in full_server_cert_pem:
|
||||||
|
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem
|
||||||
|
|
||||||
with open(server_cert_path, "w", encoding="utf-8") as f:
|
with open(server_cert_path, "w", encoding="utf-8") as f:
|
||||||
f.write(server_cert_pem)
|
f.write(full_server_cert_pem)
|
||||||
with open(server_key_path, "w", encoding="utf-8") as f:
|
with open(server_key_path, "w", encoding="utf-8") as f:
|
||||||
f.write(server_key_pem)
|
f.write(server_key_pem)
|
||||||
|
|
||||||
@@ -654,16 +670,263 @@ def sign_ca_csr_with_seats(
|
|||||||
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
return signed_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_and_sync_license_ws(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
license_id: Optional[str] = None,
|
||||||
|
license_authority_ws_url: str = "wss://logar.eibl.tech/ws/license",
|
||||||
|
server_fingerprint: str = "",
|
||||||
|
hostname: str = "",
|
||||||
|
comm_cert_pem: Optional[str] = None,
|
||||||
|
master_root_ca_pem: Optional[str] = None,
|
||||||
|
seats_taken: Optional[int] = None,
|
||||||
|
timeout: int = 15,
|
||||||
|
verify_tls: bool = True
|
||||||
|
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Connects to the Master License Authority via WebSocket protocol, authenticating and
|
||||||
|
submitting the server Sub-CA CSR to receive a cryptographically signed Sub-CA license
|
||||||
|
certificate with embedded seat quotas.
|
||||||
|
"""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_key_path = os.path.join(cert_dir, "ca.key")
|
||||||
|
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
|
||||||
|
# 1. Load or generate client Sub-CA private key
|
||||||
|
if os.path.exists(ca_key_path):
|
||||||
|
with open(ca_key_path, "r", encoding="utf-8") as f:
|
||||||
|
ca_key_pem = f.read()
|
||||||
|
ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None)
|
||||||
|
else:
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||||
|
ca_key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption()
|
||||||
|
).decode("utf-8")
|
||||||
|
with open(ca_key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(ca_key_pem)
|
||||||
|
|
||||||
|
# 2. Build Sub-CA CSR
|
||||||
|
csr_common_name = f"LOGAR-Server-CA-{license_id or 'Node'}"
|
||||||
|
subject = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Subordinate Node"),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, csr_common_name),
|
||||||
|
])
|
||||||
|
csr = (
|
||||||
|
x509.CertificateSigningRequestBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
key_encipherment=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
content_commitment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False
|
||||||
|
),
|
||||||
|
critical=True
|
||||||
|
)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
|
||||||
|
# 3. Connect via WebSocket
|
||||||
|
try:
|
||||||
|
from websockets.sync.client import connect as ws_connect
|
||||||
|
except ImportError:
|
||||||
|
return False, "websockets library not available", None
|
||||||
|
|
||||||
|
ssl_context = None
|
||||||
|
if license_authority_ws_url.startswith("wss://"):
|
||||||
|
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||||
|
if not verify_tls:
|
||||||
|
ssl_context.check_hostname = False
|
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
elif master_root_ca_pem:
|
||||||
|
ssl_context.load_verify_locations(cadata=master_root_ca_pem)
|
||||||
|
else:
|
||||||
|
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
if os.path.exists(upstream_path):
|
||||||
|
ssl_context.load_verify_locations(cafile=upstream_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ws_connect(
|
||||||
|
license_authority_ws_url,
|
||||||
|
ssl=ssl_context,
|
||||||
|
open_timeout=timeout,
|
||||||
|
close_timeout=timeout
|
||||||
|
) as ws:
|
||||||
|
payload = {
|
||||||
|
"action": "enroll_or_sync",
|
||||||
|
"license_id": license_id,
|
||||||
|
"communication_cert": comm_cert_pem,
|
||||||
|
"server_csr": csr_pem,
|
||||||
|
"server_fingerprint": server_fingerprint,
|
||||||
|
"hostname": hostname
|
||||||
|
}
|
||||||
|
if seats_taken is not None:
|
||||||
|
payload["seats_taken"] = seats_taken
|
||||||
|
ws.send(json.dumps(payload))
|
||||||
|
raw_reply = ws.recv(timeout=timeout)
|
||||||
|
reply = json.loads(raw_reply)
|
||||||
|
|
||||||
|
if reply.get("status") != "success":
|
||||||
|
return False, f"WebSocket licensing error: {reply.get('message', raw_reply)}", None
|
||||||
|
|
||||||
|
new_ca_pem = reply.get("ca_cert")
|
||||||
|
if not new_ca_pem:
|
||||||
|
return False, "License response missing 'ca_cert'", None
|
||||||
|
|
||||||
|
# Backup old ca.crt if present
|
||||||
|
if os.path.exists(ca_cert_path):
|
||||||
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
try:
|
||||||
|
os.replace(ca_cert_path, f"{ca_cert_path}.{ts}.bak")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_ca_pem)
|
||||||
|
|
||||||
|
upstream_ca = reply.get("master_root_ca") or master_root_ca_pem
|
||||||
|
if upstream_ca:
|
||||||
|
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(upstream_ca)
|
||||||
|
|
||||||
|
lic_info = extract_license_info_from_cert(new_ca_pem)
|
||||||
|
return True, f"License {license_id} successfully enrolled via WebSocket ({reply.get('seats')} seats)", {
|
||||||
|
"license_id": license_id,
|
||||||
|
"seats": reply.get("seats"),
|
||||||
|
"ca_cert_pem": new_ca_pem,
|
||||||
|
"master_root_ca_pem": upstream_ca,
|
||||||
|
"license_info": lic_info
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"WebSocket enrollment failed: {str(e)}", None
|
||||||
|
|
||||||
|
|
||||||
|
def import_license_package(
|
||||||
|
lic_content_or_path: str,
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
server_fingerprint: str = "",
|
||||||
|
hostname: str = ""
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Imports and validates a license package or certificate.
|
||||||
|
Supports:
|
||||||
|
1. Bootstrap JSON .lic package (auto-enrolls via WebSocket to receive signed CA cert)
|
||||||
|
2. Activated JSON .lic package containing 'ca_cert' and optional 'master_root_ca'
|
||||||
|
3. Raw PEM Subordinate CA certificate
|
||||||
|
4. File path pointing to any of the above
|
||||||
|
Saves the signed CA certificate to cert_dir/ca.crt (backing up any old ca.crt),
|
||||||
|
and if 'master_root_ca' is present, saves it to cert_dir/upstream_ca.crt for mTLS trust.
|
||||||
|
Returns extracted license attributes and PEM contents.
|
||||||
|
"""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
raw = lic_content_or_path
|
||||||
|
if os.path.exists(lic_content_or_path) and os.path.isfile(lic_content_or_path):
|
||||||
|
with open(lic_content_or_path, "r", encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
|
||||||
|
ca_pem = None
|
||||||
|
master_root_ca_pem = None
|
||||||
|
|
||||||
|
trimmed = raw.strip()
|
||||||
|
if trimmed.startswith("{"):
|
||||||
|
try:
|
||||||
|
data = json.loads(trimmed)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to parse JSON .lic package: {e}")
|
||||||
|
|
||||||
|
# If this is a bootstrap license package, auto-enroll over WebSocket
|
||||||
|
if data.get("format") == "LOGAR_BOOTSTRAP_LICENSE" or (data.get("communication_cert") and not data.get("ca_cert")):
|
||||||
|
lic_id = data.get("license_id")
|
||||||
|
ws_url = data.get("license_authority", "wss://logar.eibl.tech/ws/license")
|
||||||
|
comm_cert = data.get("communication_cert")
|
||||||
|
master_ca = data.get("master_root_ca")
|
||||||
|
|
||||||
|
if master_ca:
|
||||||
|
with open(os.path.join(cert_dir, "upstream_ca.crt"), "w", encoding="utf-8") as f:
|
||||||
|
f.write(master_ca)
|
||||||
|
|
||||||
|
ok, msg, res = enroll_and_sync_license_ws(
|
||||||
|
cert_dir=cert_dir,
|
||||||
|
license_id=lic_id,
|
||||||
|
license_authority_ws_url=ws_url,
|
||||||
|
server_fingerprint=server_fingerprint or data.get("server_fingerprint", ""),
|
||||||
|
hostname=hostname or data.get("hostname", ""),
|
||||||
|
comm_cert_pem=comm_cert,
|
||||||
|
master_root_ca_pem=master_ca
|
||||||
|
)
|
||||||
|
if not ok or not res:
|
||||||
|
raise ValueError(msg)
|
||||||
|
return res
|
||||||
|
|
||||||
|
ca_pem = data.get("ca_cert") or data.get("cert_pem")
|
||||||
|
master_root_ca_pem = data.get("master_root_ca") or data.get("upstream_ca")
|
||||||
|
elif "-----BEGIN CERTIFICATE-----" in trimmed:
|
||||||
|
ca_pem = trimmed
|
||||||
|
|
||||||
|
if not ca_pem:
|
||||||
|
raise ValueError("Provided license package does not contain a valid 'ca_cert' PEM certificate.")
|
||||||
|
|
||||||
|
info = extract_license_info_from_cert(ca_pem)
|
||||||
|
if not info.get("valid"):
|
||||||
|
raise ValueError(f"Invalid CA certificate in license: {info.get('error')}")
|
||||||
|
|
||||||
|
# Backup existing ca.crt if present
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
if os.path.exists(ca_path):
|
||||||
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
try:
|
||||||
|
os.replace(ca_path, f"{ca_path}.{ts}.bak")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(ca_pem)
|
||||||
|
|
||||||
|
# Save upstream_ca.crt if master_root_ca is present
|
||||||
|
if master_root_ca_pem:
|
||||||
|
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
with open(upstream_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(master_root_ca_pem)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ca_cert_pem": ca_pem,
|
||||||
|
"master_root_ca_pem": master_root_ca_pem,
|
||||||
|
"license_info": info,
|
||||||
|
"seats": info.get("seats"),
|
||||||
|
"license_id": info.get("license_id"),
|
||||||
|
"issuer_url": info.get("issuer_url"),
|
||||||
|
"fingerprint": info.get("fingerprint"),
|
||||||
|
"expires_at": info.get("not_valid_after")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def renew_ca_cert_mtls(
|
def renew_ca_cert_mtls(
|
||||||
cert_dir: str = "certs",
|
cert_dir: str = "certs",
|
||||||
license_server_url: str = "https://logar.eibl.tech",
|
license_server_url: str = "https://logar.eibl.tech",
|
||||||
upstream_ca_pem: Optional[str] = None,
|
upstream_ca_pem: Optional[str] = None,
|
||||||
|
requested_seats: Optional[int] = None,
|
||||||
|
seat_increment: int = 0,
|
||||||
|
seats_taken: Optional[int] = None,
|
||||||
|
active_seats: Optional[int] = None,
|
||||||
|
hostname: Optional[str] = None,
|
||||||
|
server_fingerprint: Optional[str] = None,
|
||||||
timeout: int = 15,
|
timeout: int = 15,
|
||||||
verify_tls: bool = True
|
verify_tls: bool = True
|
||||||
) -> Tuple[bool, str, Optional[str]]:
|
) -> Tuple[bool, str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Connects to the master License Authority using mutual TLS (mTLS), authenticating
|
Connects to the master License Authority using mutual TLS (mTLS), authenticating
|
||||||
with the server's current ca.crt and ca.key, and requests an automated renewal.
|
with the server's current ca.crt and ca.key, and requests an automated renewal.
|
||||||
|
Supports requesting seat quota increases or increments during renewal and reporting active seat usage.
|
||||||
If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
|
If renewed, writes backup of previous ca.crt and saves new ca.crt in cert_dir.
|
||||||
Returns (success, message, new_ca_cert_pem).
|
Returns (success, message, new_ca_cert_pem).
|
||||||
"""
|
"""
|
||||||
@@ -681,11 +944,22 @@ def renew_ca_cert_mtls(
|
|||||||
current_pem = f.read()
|
current_pem = f.read()
|
||||||
info = extract_license_info_from_cert(current_pem)
|
info = extract_license_info_from_cert(current_pem)
|
||||||
|
|
||||||
payload = json.dumps({
|
effective_seats_taken = seats_taken if seats_taken is not None else active_seats
|
||||||
|
payload_dict = {
|
||||||
"license_id": info.get("license_id"),
|
"license_id": info.get("license_id"),
|
||||||
"fingerprint": info.get("fingerprint"),
|
"fingerprint": info.get("fingerprint"),
|
||||||
"current_cert": current_pem
|
"current_cert": current_pem,
|
||||||
}).encode("utf-8")
|
"seats_taken": effective_seats_taken,
|
||||||
|
"active_seats": effective_seats_taken,
|
||||||
|
"hostname": hostname or "",
|
||||||
|
"server_fingerprint": server_fingerprint or ""
|
||||||
|
}
|
||||||
|
if requested_seats is not None:
|
||||||
|
payload_dict["requested_seats"] = requested_seats
|
||||||
|
if seat_increment:
|
||||||
|
payload_dict["seat_increment"] = seat_increment
|
||||||
|
|
||||||
|
payload = json.dumps(payload_dict).encode("utf-8")
|
||||||
|
|
||||||
path = parsed.path or "/api/license/renew"
|
path = parsed.path or "/api/license/renew"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -699,7 +973,7 @@ def renew_ca_cert_mtls(
|
|||||||
ssl_ctx.check_hostname = False
|
ssl_ctx.check_hostname = False
|
||||||
ssl_ctx.verify_mode = ssl.CERT_NONE
|
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
elif upstream_ca_pem:
|
elif upstream_ca_pem:
|
||||||
ssl_ctx.load_verify_locations(cdata=upstream_ca_pem)
|
ssl_ctx.load_verify_locations(cadata=upstream_ca_pem)
|
||||||
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem:
|
if os.path.exists(upstream_ca_path) and verify_tls and not upstream_ca_pem:
|
||||||
ssl_ctx.load_verify_locations(cafile=upstream_ca_path)
|
ssl_ctx.load_verify_locations(cafile=upstream_ca_path)
|
||||||
@@ -730,6 +1004,13 @@ def renew_ca_cert_mtls(
|
|||||||
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
with open(ca_cert_path, "w", encoding="utf-8") as f:
|
||||||
f.write(new_cert_pem)
|
f.write(new_cert_pem)
|
||||||
|
|
||||||
|
# Update upstream_ca.crt if master root CA certificate was provided
|
||||||
|
master_root_pem = result.get("master_root_ca")
|
||||||
|
if master_root_pem:
|
||||||
|
upstream_path = os.path.join(cert_dir, "upstream_ca.crt")
|
||||||
|
with open(upstream_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(master_root_pem)
|
||||||
|
|
||||||
return True, "Certificate renewed successfully via mTLS", new_cert_pem
|
return True, "Certificate renewed successfully via mTLS", new_cert_pem
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices during test runs
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
@@ -6,6 +6,11 @@ import sqlite3
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
# Ensure src/ is importable
|
# Ensure src/ is importable
|
||||||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||||
@@ -15,32 +20,68 @@ if SRC_DIR not in sys.path:
|
|||||||
sys.path.insert(0, SRC_DIR)
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
import server_enrollment as enrollment
|
import server_enrollment as enrollment
|
||||||
import License_Authority as authority
|
|
||||||
import Server
|
import Server
|
||||||
|
|
||||||
|
|
||||||
class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
def _create_mock_signed_license_cert(csr_pem: str, seats: int = 50, license_id: str = "LIC-TEST-12345") -> tuple:
|
||||||
|
"""
|
||||||
|
Helper to generate a mock master CA and sign a sub-CA CSR with LOGAR license OIDs
|
||||||
|
using pure cryptography library (without importing the private License_Authority submodule).
|
||||||
|
"""
|
||||||
|
# 1. Generate Root CA
|
||||||
|
ca_key = rsa.generate_private_key(65537, 2048)
|
||||||
|
ca_name = x509.Name([
|
||||||
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR Master Authority"),
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, "LOGAR-Master-Root-CA")
|
||||||
|
])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(ca_name)
|
||||||
|
.issuer_name(ca_name)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(days=1))
|
||||||
|
.not_valid_after(now + timedelta(days=3650))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=1), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
|
||||||
|
# 2. Parse CSR and sign Subordinate CA with custom OIDs
|
||||||
|
csr = x509.load_pem_x509_csr(csr_pem.encode("utf-8"))
|
||||||
|
sub_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(csr.subject)
|
||||||
|
.issuer_name(ca_name)
|
||||||
|
.public_key(csr.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(hours=1))
|
||||||
|
.not_valid_after(now + timedelta(days=365))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
|
||||||
|
.add_extension(x509.UnrecognizedExtension(enrollment.OID_LOGAR_SEATS, str(seats).encode("utf-8")), critical=False)
|
||||||
|
.add_extension(x509.UnrecognizedExtension(enrollment.OID_LOGAR_LICENSE_ID, license_id.encode("utf-8")), critical=False)
|
||||||
|
.add_extension(x509.UnrecognizedExtension(enrollment.OID_LOGAR_ISSUER_URL, b"https://logar.eibl.tech"), critical=False)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
sub_pem = sub_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
return sub_pem, ca_pem
|
||||||
|
|
||||||
|
|
||||||
|
class TestServerLicensingAndWebUI(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.test_dir = os.path.join(ROOT_DIR, "test_lic_tmp")
|
self.test_dir = os.path.join(ROOT_DIR, "test_lic_tmp")
|
||||||
os.makedirs(self.test_dir, exist_ok=True)
|
os.makedirs(self.test_dir, exist_ok=True)
|
||||||
self.master_ca_dir = os.path.join(self.test_dir, "master_ca")
|
|
||||||
self.server_cert_dir = os.path.join(self.test_dir, "server_certs")
|
self.server_cert_dir = os.path.join(self.test_dir, "server_certs")
|
||||||
self.db_path = os.path.join(self.test_dir, "license_auth.db")
|
|
||||||
self.server_db = os.path.join(self.test_dir, "server_state.db")
|
self.server_db = os.path.join(self.test_dir, "server_state.db")
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if os.path.exists(self.test_dir):
|
if os.path.exists(self.test_dir):
|
||||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
def test_master_root_ca_generation(self):
|
|
||||||
"""Verifies Master Root CA creation and structure."""
|
|
||||||
cert_pem, key_pem = authority.generate_master_root_ca(cert_dir=self.master_ca_dir)
|
|
||||||
self.assertIn("BEGIN CERTIFICATE", cert_pem)
|
|
||||||
self.assertIn("BEGIN RSA PRIVATE KEY", key_pem)
|
|
||||||
self.assertTrue(os.path.exists(os.path.join(self.master_ca_dir, "master_root_ca.crt")))
|
|
||||||
|
|
||||||
def test_ca_csr_and_lic_request_generation(self):
|
def test_ca_csr_and_lic_request_generation(self):
|
||||||
"""Verifies subordinate CA CSR and .lic request file generation."""
|
"""Verifies subordinate CA CSR and .lic request file generation on the server."""
|
||||||
lic_data, lic_json = enrollment.create_license_request_lic(
|
lic_data, lic_json = enrollment.create_license_request_lic(
|
||||||
cert_dir=self.server_cert_dir,
|
cert_dir=self.server_cert_dir,
|
||||||
license_server="https://logar.eibl.tech",
|
license_server="https://logar.eibl.tech",
|
||||||
@@ -55,88 +96,26 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
|||||||
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "ca.csr")))
|
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "ca.csr")))
|
||||||
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "logar_server.lic")))
|
self.assertTrue(os.path.exists(os.path.join(self.server_cert_dir, "logar_server.lic")))
|
||||||
|
|
||||||
def test_authority_issue_sub_ca_with_exact_seats(self):
|
def test_license_info_extraction_and_custom_oids(self):
|
||||||
"""Verifies that License Authority signs CSR, embeds exact seat quota via custom OID, and verifies in DB."""
|
"""Verifies extraction of embedded custom seat OIDs and license attributes from certificates."""
|
||||||
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
result = authority.issue_license_from_csr(
|
sub_pem, _ = _create_mock_signed_license_cert(csr_pem, seats=42, license_id="LIC-ABC-4242")
|
||||||
csr_pem=csr_pem,
|
|
||||||
seats=42,
|
|
||||||
days_valid=365,
|
|
||||||
server_fingerprint="FP123456",
|
|
||||||
hostname="hub-node-1",
|
|
||||||
db_path=self.db_path,
|
|
||||||
cert_dir=self.master_ca_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result["status"], "success")
|
info = enrollment.extract_license_info_from_cert(sub_pem)
|
||||||
self.assertEqual(result["seats"], 42)
|
|
||||||
self.assertIn("BEGIN CERTIFICATE", result["ca_cert"])
|
|
||||||
|
|
||||||
# Decode certificate to verify seat quota extraction
|
|
||||||
info = enrollment.extract_license_info_from_cert(result["ca_cert"])
|
|
||||||
self.assertTrue(info["valid"])
|
self.assertTrue(info["valid"])
|
||||||
self.assertEqual(info["seats"], 42)
|
self.assertEqual(info["seats"], 42)
|
||||||
self.assertEqual(info["license_id"], result["license_id"])
|
self.assertEqual(info["license_id"], "LIC-ABC-4242")
|
||||||
self.assertEqual(info["issuer_url"], "https://logar.eibl.tech")
|
self.assertEqual(info["issuer_url"], "https://logar.eibl.tech")
|
||||||
self.assertFalse(info["is_expired"])
|
self.assertFalse(info["is_expired"])
|
||||||
self.assertTrue(info["is_ca"])
|
self.assertTrue(info["is_ca"])
|
||||||
|
|
||||||
# Check authority database
|
|
||||||
lics = authority.list_licenses(db_path=self.db_path)
|
|
||||||
self.assertEqual(len(lics), 1)
|
|
||||||
self.assertEqual(lics[0]["license_id"], result["license_id"])
|
|
||||||
self.assertEqual(lics[0]["seats"], 42)
|
|
||||||
self.assertEqual(lics[0]["status"], "active")
|
|
||||||
|
|
||||||
def test_authority_renewal_and_revocation(self):
|
|
||||||
"""Verifies that an active license cert can be renewed, and revoked licenses are rejected."""
|
|
||||||
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
|
||||||
initial = authority.issue_license_from_csr(
|
|
||||||
csr_pem=csr_pem,
|
|
||||||
seats=15,
|
|
||||||
db_path=self.db_path,
|
|
||||||
cert_dir=self.master_ca_dir
|
|
||||||
)
|
|
||||||
lic_id = initial["license_id"]
|
|
||||||
|
|
||||||
# Successful renewal
|
|
||||||
renewed = authority.renew_license_cert(
|
|
||||||
current_cert_pem=initial["ca_cert"],
|
|
||||||
days_valid=365,
|
|
||||||
db_path=self.db_path,
|
|
||||||
cert_dir=self.master_ca_dir
|
|
||||||
)
|
|
||||||
self.assertEqual(renewed["status"], "renewed")
|
|
||||||
self.assertEqual(renewed["seats"], 15)
|
|
||||||
|
|
||||||
# Check renewal count in DB
|
|
||||||
lics = authority.list_licenses(db_path=self.db_path)
|
|
||||||
self.assertEqual(lics[0]["renewal_count"], 1)
|
|
||||||
|
|
||||||
# Revoke the license
|
|
||||||
revoked = authority.revoke_license(lic_id, db_path=self.db_path)
|
|
||||||
self.assertTrue(revoked)
|
|
||||||
|
|
||||||
# Renewal attempt after revocation must fail
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
authority.renew_license_cert(
|
|
||||||
current_cert_pem=renewed["ca_cert"],
|
|
||||||
db_path=self.db_path,
|
|
||||||
cert_dir=self.master_ca_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_server_license_import_and_seat_update(self):
|
def test_server_license_import_and_seat_update(self):
|
||||||
"""Verifies that importing a signed CA cert updates the server's seat quota in DB and memory."""
|
"""Verifies that importing a signed CA cert updates the server's seat quota in DB and memory."""
|
||||||
# 1. Generate CSR on server side
|
# 1. Generate CSR on server side
|
||||||
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
|
||||||
# 2. Authority signs it with 50 seats
|
# 2. Mock sign it with 50 seats
|
||||||
signed = authority.issue_license_from_csr(
|
sub_pem, _ = _create_mock_signed_license_cert(csr_pem, seats=50, license_id="LIC-IMPORT-50")
|
||||||
csr_pem=csr_pem,
|
|
||||||
seats=50,
|
|
||||||
db_path=self.db_path,
|
|
||||||
cert_dir=self.master_ca_dir
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Setup mock server state and DB
|
# 3. Setup mock server state and DB
|
||||||
Server.init_db(self.server_db, max_seats=5)
|
Server.init_db(self.server_db, max_seats=5)
|
||||||
@@ -160,16 +139,16 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
|||||||
# 5. Save the signed CA certificate to cert_dir/ca.crt
|
# 5. Save the signed CA certificate to cert_dir/ca.crt
|
||||||
ca_path = os.path.join(self.server_cert_dir, "ca.crt")
|
ca_path = os.path.join(self.server_cert_dir, "ca.crt")
|
||||||
with open(ca_path, "w", encoding="utf-8") as f:
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
f.write(signed["ca_cert"])
|
f.write(sub_pem)
|
||||||
|
|
||||||
# 6. Extract summary after import
|
# 6. Extract summary after import
|
||||||
summary_after = Server.get_license_summary()
|
summary_after = Server.get_license_summary()
|
||||||
self.assertEqual(summary_after["max_seats"], 50)
|
self.assertEqual(summary_after["max_seats"], 50)
|
||||||
self.assertEqual(summary_after["license_id"], signed["license_id"])
|
self.assertEqual(summary_after["license_id"], "LIC-IMPORT-50")
|
||||||
self.assertEqual(summary_after["status"], "ACTIVE")
|
self.assertEqual(summary_after["status"], "ACTIVE")
|
||||||
|
|
||||||
def test_server_client_revocation_and_reactivation(self):
|
def test_server_client_revocation_and_reactivation(self):
|
||||||
"""Verifies revocation and reactivation of edge client seats."""
|
"""Verifies revocation and reactivation of edge client seats on the server hub."""
|
||||||
Server.init_db(self.server_db, max_seats=2)
|
Server.init_db(self.server_db, max_seats=2)
|
||||||
Server.SERVER_STATE["config"] = {
|
Server.SERVER_STATE["config"] = {
|
||||||
"db_path": self.server_db,
|
"db_path": self.server_db,
|
||||||
@@ -202,6 +181,30 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
|||||||
self.assertEqual(summary2["active_seats"], 2)
|
self.assertEqual(summary2["active_seats"], 2)
|
||||||
self.assertEqual(summary2["available_seats"], 0)
|
self.assertEqual(summary2["available_seats"], 0)
|
||||||
|
|
||||||
|
def test_activated_lic_package_handling(self):
|
||||||
|
"""Verifies that an activated .lic package structure can be processed and verified."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, root_pem = _create_mock_signed_license_cert(csr_pem, seats=20, license_id="LIC-ACT-20")
|
||||||
|
|
||||||
|
lic_package = {
|
||||||
|
"format": "LOGAR_ACTIVATED_LICENSE",
|
||||||
|
"version": "2.0.1",
|
||||||
|
"license_id": "LIC-ACT-20",
|
||||||
|
"seats": 20,
|
||||||
|
"server_fingerprint": "FP_TEST_HUB",
|
||||||
|
"ca_cert": sub_pem,
|
||||||
|
"master_root_ca": root_pem,
|
||||||
|
"days_valid": 365
|
||||||
|
}
|
||||||
|
self.assertEqual(lic_package["seats"], 20)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", lic_package["ca_cert"])
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", lic_package["master_root_ca"])
|
||||||
|
|
||||||
|
# Verify certificate parses
|
||||||
|
info = enrollment.extract_license_info_from_cert(lic_package["ca_cert"])
|
||||||
|
self.assertEqual(info["seats"], 20)
|
||||||
|
self.assertEqual(info["license_id"], "LIC-ACT-20")
|
||||||
|
|
||||||
def test_webui_rendering(self):
|
def test_webui_rendering(self):
|
||||||
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
||||||
Server.init_db(self.server_db, max_seats=10)
|
Server.init_db(self.server_db, max_seats=10)
|
||||||
@@ -218,10 +221,11 @@ class TestLicenseAuthorityAndWebUI(unittest.TestCase):
|
|||||||
|
|
||||||
response = Server.serve_webui()
|
response = Server.serve_webui()
|
||||||
html = response.body.decode("utf-8")
|
html = response.body.decode("utf-8")
|
||||||
self.assertIn("LOGAR Fleet Hub & License Management", html.replace("&", "&"))
|
self.assertIn("LOGAR Fleet Hub", html)
|
||||||
self.assertIn("Export .lic Request", html)
|
self.assertNotIn("LOGAR Fleet Hub & License Management", html)
|
||||||
self.assertIn("Import & Activate Signed CA Certificate", html.replace("&", "&"))
|
self.assertNotIn("Export .lic Request", html)
|
||||||
self.assertIn("Renew via mTLS Now", html)
|
self.assertNotIn("Import License (.lic / ca.crt)", html)
|
||||||
|
self.assertIn("Sync & Renew via mTLS", html)
|
||||||
self.assertIn("logar.eibl.tech", html)
|
self.assertIn("logar.eibl.tech", html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import unittest
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||||
@@ -26,7 +27,12 @@ class TestLinuxClientComponent(unittest.TestCase):
|
|||||||
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
||||||
hashes=[HashAlgorithm.SHA256],
|
hashes=[HashAlgorithm.SHA256],
|
||||||
ciphers=[SymmetricKeyAlgorithm.AES256],
|
ciphers=[SymmetricKeyAlgorithm.AES256],
|
||||||
compression=[CompressionAlgorithm.Uncompressed]
|
compression=[
|
||||||
|
CompressionAlgorithm.ZIP,
|
||||||
|
CompressionAlgorithm.ZLIB,
|
||||||
|
CompressionAlgorithm.BZ2,
|
||||||
|
CompressionAlgorithm.Uncompressed
|
||||||
|
]
|
||||||
)
|
)
|
||||||
self.server_priv = key
|
self.server_priv = key
|
||||||
self.server_pub = key.pubkey
|
self.server_pub = key.pubkey
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for LOGAR log_collector and client_transport modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||||
|
|
||||||
|
from log_collector import (
|
||||||
|
get_machine_identifier,
|
||||||
|
is_path_writable,
|
||||||
|
get_default_data_dir,
|
||||||
|
resolve_writable_path,
|
||||||
|
get_state_path,
|
||||||
|
load_state,
|
||||||
|
save_state,
|
||||||
|
commit_state,
|
||||||
|
BaseLogCollector,
|
||||||
|
WindowsLogCollector,
|
||||||
|
LinuxLogCollector,
|
||||||
|
get_log_collector
|
||||||
|
)
|
||||||
|
from client_transport import (
|
||||||
|
is_cert_expiring_soon,
|
||||||
|
load_config
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogCollectorModule(unittest.TestCase):
|
||||||
|
def test_machine_identifier(self):
|
||||||
|
mid = get_machine_identifier()
|
||||||
|
self.assertIsInstance(mid, str)
|
||||||
|
self.assertTrue(len(mid) > 0)
|
||||||
|
self.assertNotEqual(mid, "localhost")
|
||||||
|
|
||||||
|
def test_path_writable_and_data_dir(self):
|
||||||
|
data_dir = get_default_data_dir("LOGAR_TEST")
|
||||||
|
self.assertIsInstance(data_dir, str)
|
||||||
|
self.assertTrue(is_path_writable(data_dir))
|
||||||
|
|
||||||
|
def test_state_lifecycle_windows_and_linux(self):
|
||||||
|
state_file = "test_shared_state.json"
|
||||||
|
try:
|
||||||
|
state = load_state(state_file)
|
||||||
|
self.assertEqual(state, {})
|
||||||
|
|
||||||
|
# Windows staged state
|
||||||
|
state["new_last_record_number"] = 100
|
||||||
|
state["new_sent_record_ids"] = ["100:2026-09-27T00:00:00"]
|
||||||
|
commit_state(state, state_file)
|
||||||
|
self.assertNotIn("new_last_record_number", state)
|
||||||
|
self.assertEqual(state["last_record_number"], 100)
|
||||||
|
|
||||||
|
# Linux staged state
|
||||||
|
state["new_last_cursor"] = "c_xyz"
|
||||||
|
state["new_last_timestamp_us"] = 123456789.0
|
||||||
|
state["new_sent_cursors"] = ["c_xyz"]
|
||||||
|
commit_state(state, state_file)
|
||||||
|
self.assertNotIn("new_last_cursor", state)
|
||||||
|
self.assertEqual(state["last_cursor"], "c_xyz")
|
||||||
|
self.assertEqual(state["last_timestamp_us"], 123456789.0)
|
||||||
|
|
||||||
|
# Reload and verify
|
||||||
|
reloaded = load_state(state_file)
|
||||||
|
self.assertEqual(reloaded["last_record_number"], 100)
|
||||||
|
self.assertEqual(reloaded["last_cursor"], "c_xyz")
|
||||||
|
finally:
|
||||||
|
if os.path.exists(state_file):
|
||||||
|
try:
|
||||||
|
os.remove(state_file)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_windows_log_collector_severity_mapping(self):
|
||||||
|
state = {}
|
||||||
|
collector = WindowsLogCollector(state=state, machine_id="test-win-node", channels=["Application"])
|
||||||
|
self.assertEqual(collector.machine_id, "test-win-node")
|
||||||
|
self.assertEqual(collector.channels, ["Application"])
|
||||||
|
|
||||||
|
def test_linux_log_collector_priority_normalization(self):
|
||||||
|
state = {}
|
||||||
|
collector = LinuxLogCollector(state=state, machine_id="test-linux-node")
|
||||||
|
self.assertEqual(collector.machine_id, "test-linux-node")
|
||||||
|
|
||||||
|
def test_get_log_collector_factory(self):
|
||||||
|
win_col = get_log_collector(os_type="windows", machine_id="win-1")
|
||||||
|
self.assertIsInstance(win_col, WindowsLogCollector)
|
||||||
|
self.assertEqual(win_col.machine_id, "win-1")
|
||||||
|
|
||||||
|
lin_col = get_log_collector(os_type="linux", machine_id="lin-1")
|
||||||
|
self.assertIsInstance(lin_col, LinuxLogCollector)
|
||||||
|
self.assertEqual(lin_col.machine_id, "lin-1")
|
||||||
|
|
||||||
|
auto_col = get_log_collector()
|
||||||
|
self.assertIsInstance(auto_col, BaseLogCollector)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -8,6 +8,7 @@ import struct
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import shutil
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -30,8 +31,14 @@ HERMES_PORT = 8443
|
|||||||
|
|
||||||
|
|
||||||
def run_tests():
|
def run_tests():
|
||||||
print("=== [1] Verifying server_config.json & client_config.json ===")
|
server_cfg_path = "server_config.json"
|
||||||
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json")
|
if not os.path.exists(server_cfg_path):
|
||||||
|
server_cfg_path = os.path.join(ROOT_DIR, "server_config.json")
|
||||||
|
if not os.path.exists(server_cfg_path):
|
||||||
|
progdata_path = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"), "LOGAR-Server", "server_config.json")
|
||||||
|
if os.path.exists(progdata_path):
|
||||||
|
server_cfg_path = progdata_path
|
||||||
|
|
||||||
client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json")
|
client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json")
|
||||||
|
|
||||||
assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"
|
assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"
|
||||||
@@ -51,9 +58,84 @@ def run_tests():
|
|||||||
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
||||||
|
|
||||||
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
|
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
|
||||||
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
os.makedirs(cert_dir, exist_ok=True)
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
client_id = "test-edge-node.corp.internal"
|
client_id = "test-edge-node.corp.internal"
|
||||||
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
|
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
|
||||||
|
auth_token = server_conf.get("auth_token", "")
|
||||||
|
|
||||||
|
print("\n=== [1.5] Testing Server License Activation via Web API (/api/license/import) ===")
|
||||||
|
import server_enrollment
|
||||||
|
|
||||||
|
# 1. Check current license status
|
||||||
|
req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
|
||||||
|
with urllib.request.urlopen(req_status_init, timeout=5) as resp:
|
||||||
|
init_lic_stat = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
if init_lic_stat.get("status") == "UNLICENSED":
|
||||||
|
unlicensed_enroll_payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": client_id,
|
||||||
|
"os": "linux",
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req_unlicensed = urllib.request.Request(
|
||||||
|
f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll",
|
||||||
|
data=json.dumps(unlicensed_enroll_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req_unlicensed, timeout=5):
|
||||||
|
assert False, "Expected HTTP 403 on unlicensed server enrollment"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}"
|
||||||
|
print("[OK] Unlicensed server correctly rejected edge client enrollment.")
|
||||||
|
|
||||||
|
# 2. Fetch CA CSR from Server
|
||||||
|
req_csr = urllib.request.Request(
|
||||||
|
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr",
|
||||||
|
headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req_csr, timeout=5) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
csr_data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
csr_pem = csr_data["csr"]
|
||||||
|
print("[OK] Fetched subordinate CA CSR from server.")
|
||||||
|
|
||||||
|
# 3. Generate test master CA and sign subordinate CA with 10 seats
|
||||||
|
master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca")
|
||||||
|
master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA")
|
||||||
|
sub_pem = server_enrollment.sign_ca_csr_with_seats(
|
||||||
|
csr_pem=csr_pem,
|
||||||
|
master_ca_cert_pem=master_pem,
|
||||||
|
master_ca_key_pem=master_key_pem,
|
||||||
|
license_id="LIC-TEST-PIPELINE-10",
|
||||||
|
seats=10,
|
||||||
|
days_valid=365
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Import signed license package to server
|
||||||
|
import_payload = {"ca_cert": sub_pem, "master_root_ca": master_pem}
|
||||||
|
req_import = urllib.request.Request(
|
||||||
|
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/import",
|
||||||
|
data=json.dumps(import_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req_import, timeout=5) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
import_res = json.loads(resp.read().decode("utf-8"))
|
||||||
|
assert import_res["status"] == "success"
|
||||||
|
assert import_res["seats"] == 10
|
||||||
|
print("[OK] Successfully imported signed license (.lic). Server activated with 10 seats.")
|
||||||
|
|
||||||
|
# 5. Verify license status
|
||||||
|
req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
|
||||||
|
with urllib.request.urlopen(req_status, timeout=5) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
lic_stat = json.loads(resp.read().decode("utf-8"))
|
||||||
|
assert lic_stat["status"] == "ACTIVE"
|
||||||
|
assert lic_stat["max_seats"] == 10
|
||||||
|
print("[OK] Verified server license status is ACTIVE.")
|
||||||
|
|
||||||
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
|
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
|
||||||
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
|
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
|
||||||
@@ -109,7 +191,9 @@ def run_tests():
|
|||||||
print(f"[OK] Client enrolled successfully. Certificates stored in {cert_dir}")
|
print(f"[OK] Client enrolled successfully. Certificates stored in {cert_dir}")
|
||||||
|
|
||||||
# 2c. Verify client shows in /api/clients
|
# 2c. Verify client shows in /api/clients
|
||||||
clients_req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/clients")
|
auth_tok = server_conf.get("auth_token") or client_conf.get("auth_token")
|
||||||
|
client_headers = {"Authorization": f"Bearer {auth_tok}"} if auth_tok else {}
|
||||||
|
clients_req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/clients", headers=client_headers)
|
||||||
with urllib.request.urlopen(clients_req, timeout=5) as resp:
|
with urllib.request.urlopen(clients_req, timeout=5) as resp:
|
||||||
clients_data = json.loads(resp.read().decode("utf-8"))
|
clients_data = json.loads(resp.read().decode("utf-8"))
|
||||||
assert clients_data["active_seats"] >= 1
|
assert clients_data["active_seats"] >= 1
|
||||||
@@ -146,8 +230,8 @@ def run_tests():
|
|||||||
resp_bytes.extend(chunk)
|
resp_bytes.extend(chunk)
|
||||||
return json.loads(resp_bytes.decode("utf-8"))
|
return json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
|
||||||
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule for Warnings over mTLS ===")
|
print("\n=== [3] Testing Temporal Persistence & Direct Anomaly Verification over mTLS ===")
|
||||||
test_signature = "TestServiceDegraded"
|
test_signature = f"TestServiceDegraded_{int(time.time())}"
|
||||||
candidate_log = [{
|
candidate_log = [{
|
||||||
"server": client_id,
|
"server": client_id,
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
@@ -161,10 +245,10 @@ def run_tests():
|
|||||||
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
|
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
|
||||||
promoted = resp.get("promoted_verified", 0)
|
promoted = resp.get("promoted_verified", 0)
|
||||||
print(f"[Run {run_num}/4] Ingested successfully via mTLS. Promoted to verified: {promoted}")
|
print(f"[Run {run_num}/4] Ingested successfully via mTLS. Promoted to verified: {promoted}")
|
||||||
if run_num < 4:
|
if run_num == 1:
|
||||||
assert promoted == 0, f"Expected 0 promoted on run {run_num} for warning, got {promoted}"
|
assert promoted == 1, f"Expected 1 promoted on run 1 for warning, got {promoted}"
|
||||||
else:
|
else:
|
||||||
assert promoted == 1, f"Expected 1 promoted on run 4 for warning, got {promoted}"
|
assert promoted == 0, f"Expected 0 newly promoted on run {run_num} (already verified), got {promoted}"
|
||||||
|
|
||||||
# Inspect SQLite database directly
|
# Inspect SQLite database directly
|
||||||
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
||||||
@@ -178,10 +262,10 @@ def run_tests():
|
|||||||
print(f"[DB Verification] Issue '{test_signature}' -> run_count: {run_count}, status: {status}")
|
print(f"[DB Verification] Issue '{test_signature}' -> run_count: {run_count}, status: {status}")
|
||||||
assert run_count >= 4, f"Expected run_count >= 4, got {run_count}"
|
assert run_count >= 4, f"Expected run_count >= 4, got {run_count}"
|
||||||
assert status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
|
assert status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
|
||||||
print("[OK] 4-Run Rule verified: Warning promoted to VERIFIED anomaly on 4th run over mTLS!")
|
print("[OK] Direct verification confirmed: Warning immediately verified for Hermes with run count tracking over mTLS!")
|
||||||
|
|
||||||
print("\n=== [4] Testing Immediate Pass for Errors over mTLS ===")
|
print("\n=== [4] Testing Immediate Pass for Errors over mTLS ===")
|
||||||
error_signature = "TestServiceCrashImmediate"
|
error_signature = f"TestServiceCrashImmediate_{int(time.time())}"
|
||||||
error_log = [{
|
error_log = [{
|
||||||
"server": client_id,
|
"server": client_id,
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
@@ -207,7 +291,9 @@ def run_tests():
|
|||||||
print("[OK] Immediate pass verified: Error promoted to VERIFIED anomaly immediately!")
|
print("[OK] Immediate pass verified: Error promoted to VERIFIED anomaly immediately!")
|
||||||
|
|
||||||
print("\n=== [5] Testing Hermes Reporting Endpoint (/api/hermes/report) ===")
|
print("\n=== [5] Testing Hermes Reporting Endpoint (/api/hermes/report) ===")
|
||||||
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report")
|
token = server_conf.get("auth_token") or client_conf.get("auth_token")
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||||
|
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report", headers=headers)
|
||||||
with urllib.request.urlopen(req, timeout=5) as response:
|
with urllib.request.urlopen(req, timeout=5) as response:
|
||||||
assert response.status == 200, f"Expected 200, got {response.status}"
|
assert response.status == 200, f"Expected 200, got {response.status}"
|
||||||
hermes_data = json.loads(response.read().decode("utf-8"))
|
hermes_data = json.loads(response.read().decode("utf-8"))
|
||||||
@@ -240,7 +326,6 @@ def run_tests():
|
|||||||
linux_logs = get_recent_linux_logs(hours=24)
|
linux_logs = get_recent_linux_logs(hours=24)
|
||||||
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
|
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
|
||||||
|
|
||||||
import shutil
|
|
||||||
if os.path.exists(cert_dir):
|
if os.path.exists(cert_dir):
|
||||||
shutil.rmtree(cert_dir, ignore_errors=True)
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import struct
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import unittest
|
import unittest
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -21,12 +22,8 @@ import pgpy
|
|||||||
|
|
||||||
class TestServerComponent(unittest.TestCase):
|
class TestServerComponent(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.test_db = "test_server_state.db"
|
self.test_db = f"test_server_state_{uuid.uuid4().hex[:8]}.db"
|
||||||
self.test_config = "test_server_config.json"
|
self.test_config = f"test_server_config_{uuid.uuid4().hex[:8]}.json"
|
||||||
if os.path.exists(self.test_db):
|
|
||||||
os.remove(self.test_db)
|
|
||||||
if os.path.exists(self.test_config):
|
|
||||||
os.remove(self.test_config)
|
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if os.path.exists(self.test_db):
|
if os.path.exists(self.test_db):
|
||||||
@@ -75,7 +72,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
if os.path.exists(client_out):
|
if os.path.exists(client_out):
|
||||||
os.remove(client_out)
|
os.remove(client_out)
|
||||||
|
|
||||||
def test_4_run_rule_and_12h_window(self):
|
def test_direct_hermes_anomaly_and_12h_window(self):
|
||||||
Server.init_db(self.test_db)
|
Server.init_db(self.test_db)
|
||||||
log_entry = {
|
log_entry = {
|
||||||
"server": "app-worker-01.corp.local",
|
"server": "app-worker-01.corp.local",
|
||||||
@@ -89,23 +86,24 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
"logs": [log_entry]
|
"logs": [log_entry]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Runs 1 to 3: WARNING should remain TRANSIENT
|
# Run 1: WARNING is immediately VERIFIED for Hermes AI reasoning
|
||||||
for run_idx in range(1, 4):
|
res1 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=1)
|
||||||
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
self.assertEqual(res1["status"], "success")
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res1["promoted_verified"], 1)
|
||||||
self.assertEqual(res["promoted_verified"], 0)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnWarning",))
|
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnWarning",))
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertEqual(row[0], 3)
|
self.assertEqual(row[0], 1)
|
||||||
self.assertEqual(row[1], "TRANSIENT")
|
self.assertEqual(row[1], "VERIFIED")
|
||||||
|
|
||||||
# Run 4: promotes WARNING to VERIFIED!
|
# Runs 2 to 4: Increments consecutive run count while remaining VERIFIED
|
||||||
res4 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
for run_idx in range(2, 5):
|
||||||
self.assertEqual(res4["promoted_verified"], 1)
|
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=1)
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(res["promoted_verified"], 0)
|
||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -130,7 +128,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Run 1: ERROR must immediately promote to VERIFIED
|
# Run 1: ERROR must immediately promote to VERIFIED
|
||||||
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=1)
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
self.assertEqual(res["promoted_verified"], 1)
|
self.assertEqual(res["promoted_verified"], 1)
|
||||||
|
|
||||||
@@ -156,7 +154,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
{"server": "app-worker-01", "signature": "SigTrace", "severity": "TRACE", "message": "Trace msg", "os_type": "linux"}
|
{"server": "app-worker-01", "signature": "SigTrace", "severity": "TRACE", "message": "Trace msg", "os_type": "linux"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=1)
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
@@ -171,10 +169,10 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
self.assertNotIn("SigDebug", rows)
|
self.assertNotIn("SigDebug", rows)
|
||||||
self.assertNotIn("SigTrace", rows)
|
self.assertNotIn("SigTrace", rows)
|
||||||
|
|
||||||
# SigErr is immediately VERIFIED; SigWarn and SigInfo are TRANSIENT on run 1
|
# All actionable issues (ERROR, WARNING, INFO) are promoted to VERIFIED immediately for Hermes reasoning
|
||||||
self.assertEqual(rows["SigErr"], "VERIFIED")
|
self.assertEqual(rows["SigErr"], "VERIFIED")
|
||||||
self.assertEqual(rows["SigWarn"], "TRANSIENT")
|
self.assertEqual(rows["SigWarn"], "VERIFIED")
|
||||||
self.assertEqual(rows["SigInfo"], "TRANSIENT")
|
self.assertEqual(rows["SigInfo"], "VERIFIED")
|
||||||
|
|
||||||
def test_license_schema_and_pki_generation(self):
|
def test_license_schema_and_pki_generation(self):
|
||||||
secret = "test-secret-12345"
|
secret = "test-secret-12345"
|
||||||
@@ -222,7 +220,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
test_cert_dir = "test_certs_enroll"
|
test_cert_dir = "test_certs_enroll"
|
||||||
try:
|
try:
|
||||||
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
Server.SERVER_STATE["config"] = {"db_path": self.test_db}
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir}
|
||||||
Server.SERVER_STATE["ca_cert"] = ca_cert
|
Server.SERVER_STATE["ca_cert"] = ca_cert
|
||||||
Server.SERVER_STATE["ca_key"] = ca_key
|
Server.SERVER_STATE["ca_key"] = ca_key
|
||||||
Server.SERVER_STATE["ca_cert_pem"] = ca_pem
|
Server.SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||||
@@ -248,7 +246,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
resp1 = Server.enroll_client(req1)
|
resp1 = Server.enroll_client(req1)
|
||||||
self.assertIn("client_cert", resp1)
|
self.assertIn("client_cert", resp1)
|
||||||
self.assertIn("client_key", resp1)
|
self.assertIn("client_key", resp1)
|
||||||
self.assertEqual(resp1["ca_cert"], ca_pem)
|
self.assertTrue(resp1["ca_cert"].strip().startswith(ca_pem.strip()))
|
||||||
|
|
||||||
# 3. Valid enrollment for client 2
|
# 3. Valid enrollment for client 2
|
||||||
req2 = Server.ClientEnrollRequest(
|
req2 = Server.ClientEnrollRequest(
|
||||||
@@ -328,6 +326,152 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
if os.path.exists(test_cert_dir):
|
if os.path.exists(test_cert_dir):
|
||||||
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_user_auth_and_session(self):
|
||||||
|
"""Tests admin password hashing, verification, session tokens, and password change."""
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
|
||||||
|
# Verify default admin credentials
|
||||||
|
self.assertTrue(Server.verify_user_credentials("admin", "admin_logar_2026!", db_path=self.test_db))
|
||||||
|
self.assertFalse(Server.verify_user_credentials("admin", "wrong_password", db_path=self.test_db))
|
||||||
|
self.assertFalse(Server.verify_user_credentials("nonexistent", "password", db_path=self.test_db))
|
||||||
|
|
||||||
|
# Test session token generation & validation
|
||||||
|
token = Server.generate_session_token("admin")
|
||||||
|
self.assertEqual(Server.verify_session_token(token), "admin")
|
||||||
|
self.assertIsNone(Server.verify_session_token("invalid.token.structure"))
|
||||||
|
|
||||||
|
# Test changing password
|
||||||
|
Server.change_user_password("admin", "new_secure_pass123", db_path=self.test_db)
|
||||||
|
self.assertFalse(Server.verify_user_credentials("admin", "admin_logar_2026!", db_path=self.test_db))
|
||||||
|
self.assertTrue(Server.verify_user_credentials("admin", "new_secure_pass123", db_path=self.test_db))
|
||||||
|
|
||||||
|
def test_hermes_tokens_and_auth(self):
|
||||||
|
"""Tests Hermes Bearer token creation, verification, revocation, and deletion."""
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
|
||||||
|
# Test default Hermes token was created on init
|
||||||
|
tokens = Server.list_hermes_tokens(db_path=self.test_db)
|
||||||
|
self.assertEqual(len(tokens), 1)
|
||||||
|
self.assertEqual(tokens[0]["status"], "active")
|
||||||
|
default_token = tokens[0]["token"]
|
||||||
|
self.assertTrue(Server.verify_hermes_token(default_token, db_path=self.test_db))
|
||||||
|
|
||||||
|
# Test creating new token
|
||||||
|
new_tok = Server.create_hermes_token(name="Hermes Autonomous Agent 01", db_path=self.test_db)
|
||||||
|
self.assertIn("token", new_tok)
|
||||||
|
self.assertEqual(new_tok["name"], "Hermes Autonomous Agent 01")
|
||||||
|
self.assertTrue(Server.verify_hermes_token(new_tok["token"], db_path=self.test_db))
|
||||||
|
|
||||||
|
# Test revoking token
|
||||||
|
self.assertTrue(Server.revoke_hermes_token(new_tok["token"], db_path=self.test_db))
|
||||||
|
self.assertFalse(Server.verify_hermes_token(new_tok["token"], db_path=self.test_db))
|
||||||
|
|
||||||
|
# Test deleting token
|
||||||
|
self.assertTrue(Server.delete_hermes_token(new_tok["token"], db_path=self.test_db))
|
||||||
|
tokens_after = Server.list_hermes_tokens(db_path=self.test_db)
|
||||||
|
self.assertEqual(len(tokens_after), 1)
|
||||||
|
|
||||||
|
def test_client_profile_export(self):
|
||||||
|
"""Tests client profile dictionary generation and export formatting."""
|
||||||
|
cfg = Server.load_or_init_config(self.test_config)
|
||||||
|
profile = Server.generate_client_profile_dict(
|
||||||
|
server_host="logar-hub.corp.internal",
|
||||||
|
server_port=9443,
|
||||||
|
client_id="worker-node-101",
|
||||||
|
config_dict=cfg
|
||||||
|
)
|
||||||
|
self.assertEqual(profile["server_host"], "logar-hub.corp.internal")
|
||||||
|
self.assertEqual(profile["server_port"], 9443)
|
||||||
|
self.assertEqual(profile["client_id"], "worker-node-101")
|
||||||
|
self.assertEqual(profile["server_fingerprint"], cfg["server_fingerprint"])
|
||||||
|
self.assertEqual(profile["server_public_key"], cfg["public_key"])
|
||||||
|
self.assertEqual(profile["enrollment_secret"], cfg["enrollment_secret"])
|
||||||
|
self.assertEqual(profile["format"], "LOGAR_CLIENT_PROFILE")
|
||||||
|
|
||||||
|
def test_client_seat_management_and_relative_time(self):
|
||||||
|
"""Tests client listing, seat approval, revocation, deletion, and relative timestamp helper."""
|
||||||
|
Server.init_db(self.test_db, enrollment_secret="test-sec-123", max_seats=5)
|
||||||
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db}
|
||||||
|
|
||||||
|
# Populate test clients
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
five_min_ago = (now - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
two_hours_ago = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, last_seen) VALUES ('c-win', 'WIN-PROD-01', 'windows', 'FP_W1', 'active', ?)", (five_min_ago,))
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, last_seen) VALUES ('c-lin', 'LIN-PROD-01', 'linux', 'FP_L1', 'active', ?)", (two_hours_ago,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 1. Test listing
|
||||||
|
client_data = Server.list_clients()
|
||||||
|
self.assertEqual(client_data["active_seats"], 2)
|
||||||
|
self.assertEqual(client_data["max_seats"], 5)
|
||||||
|
self.assertEqual(len(client_data["clients"]), 2)
|
||||||
|
self.assertEqual(client_data["clients"][0]["last_activity_relative"], "5m ago")
|
||||||
|
|
||||||
|
# 2. Test revoking
|
||||||
|
rev_req = Server.ClientActionRequest(client_id="c-win")
|
||||||
|
rev_res = Server.revoke_client(rev_req)
|
||||||
|
self.assertEqual(rev_res["status"], "revoked")
|
||||||
|
client_data = Server.list_clients()
|
||||||
|
self.assertEqual(client_data["active_seats"], 1)
|
||||||
|
|
||||||
|
# 3. Test approving / reactivating
|
||||||
|
app_res = Server.approve_client(rev_req)
|
||||||
|
self.assertEqual(app_res["status"], "active")
|
||||||
|
client_data = Server.list_clients()
|
||||||
|
self.assertEqual(client_data["active_seats"], 2)
|
||||||
|
|
||||||
|
# 4. Test deleting client record
|
||||||
|
del_res = Server.delete_client(rev_req)
|
||||||
|
self.assertEqual(del_res["status"], "deleted")
|
||||||
|
client_data = Server.list_clients()
|
||||||
|
self.assertEqual(len(client_data["clients"]), 1)
|
||||||
|
self.assertEqual(client_data["active_seats"], 1)
|
||||||
|
|
||||||
|
# 5. Test format_relative_time edge cases
|
||||||
|
self.assertEqual(Server.format_relative_time(None), "Never")
|
||||||
|
self.assertEqual(Server.format_relative_time(""), "Never")
|
||||||
|
self.assertEqual(Server.format_relative_time("invalid-date-string"), "invalid-date-str")
|
||||||
|
now_fresh = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
rel = Server.format_relative_time(now_fresh)
|
||||||
|
self.assertTrue(rel.endswith("s ago") or rel == "Just now", f"Unexpected relative time: {rel}")
|
||||||
|
|
||||||
|
def test_integrated_local_log_collector_zero_seats(self):
|
||||||
|
"""Tests that local hub log collection runs without license, registering a 0-seat built-in client."""
|
||||||
|
Server.init_db(self.test_db, enrollment_secret="test-sec-456", max_seats=0)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.test_db,
|
||||||
|
"evaluation_window_hours": 12,
|
||||||
|
"min_persistence_runs": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run cycle
|
||||||
|
state = {}
|
||||||
|
processed = Server.run_local_log_collection_cycle(self.test_db, window_hours=12, min_runs=1, state=state)
|
||||||
|
|
||||||
|
# Verify local hub client entry exists in clients table
|
||||||
|
machine_id = Server.get_machine_identifier()
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT client_id, cert_fingerprint, status FROM clients WHERE client_id = ?", (machine_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row[0], machine_id)
|
||||||
|
self.assertEqual(row[1], "LOCAL-HOST-BUILTIN")
|
||||||
|
self.assertEqual(row[2], "active")
|
||||||
|
|
||||||
|
# Verify active seat count is STILL 0 (exempt from seat accounting)
|
||||||
|
client_data = Server.list_clients()
|
||||||
|
self.assertEqual(client_data["active_seats"], 0)
|
||||||
|
self.assertEqual(len(client_data["clients"]), 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import unittest
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
|
||||||
@@ -26,7 +27,12 @@ class TestWinClientComponent(unittest.TestCase):
|
|||||||
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
usage={KeyFlags.EncryptCommunications, KeyFlags.EncryptStorage},
|
||||||
hashes=[HashAlgorithm.SHA256],
|
hashes=[HashAlgorithm.SHA256],
|
||||||
ciphers=[SymmetricKeyAlgorithm.AES256],
|
ciphers=[SymmetricKeyAlgorithm.AES256],
|
||||||
compression=[CompressionAlgorithm.Uncompressed]
|
compression=[
|
||||||
|
CompressionAlgorithm.ZIP,
|
||||||
|
CompressionAlgorithm.ZLIB,
|
||||||
|
CompressionAlgorithm.BZ2,
|
||||||
|
CompressionAlgorithm.Uncompressed
|
||||||
|
]
|
||||||
)
|
)
|
||||||
self.server_priv = key
|
self.server_priv = key
|
||||||
self.server_pub = key.pubkey
|
self.server_pub = key.pubkey
|
||||||
|
|||||||