Compare commits
209
Commits
08aa4edfb1
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37f959e402 | ||
|
|
f1e5e8f986 | ||
|
|
5a0fdb4374 | ||
|
|
fdfa8dfe7f | ||
|
|
b40966ac4c | ||
|
|
e47fe609a6 | ||
|
|
48bb279601 | ||
|
|
fd34fe9911 | ||
|
|
a7ff1c845d | ||
|
|
b0066c9ea6 | ||
|
|
079c6cb70f | ||
|
|
49525594a3 | ||
|
|
04ccc999a8 | ||
|
|
267aa54076 | ||
|
|
2c9f8d9b91 | ||
|
|
125870b135 | ||
|
|
f0df5a6e4d | ||
|
|
5b59b18c2c | ||
|
|
ec80908d5b | ||
|
|
92d858841d | ||
|
|
0e616d614c | ||
|
|
38b36c28a0 | ||
|
|
c51194e699 | ||
|
|
3a3d251817 | ||
|
|
25adb46d48 | ||
|
|
b33294f36c | ||
|
|
b8069ee66b | ||
|
|
d025689193 | ||
|
|
96126cdf84 | ||
|
|
17c1791b7d | ||
|
|
911014fae7 | ||
|
|
de95a3283d | ||
|
|
b0bf322275 | ||
|
|
a496b7e822 | ||
|
|
29784e166f | ||
|
|
56844983e2 | ||
|
|
07b8f48631 | ||
|
|
fa8b9e0af5 | ||
|
|
93d9f0e463 | ||
|
|
41cb956416 | ||
|
|
42916dc8ee | ||
|
|
a34934959d | ||
|
|
fe296b42e7 | ||
|
|
e04082d4b8 | ||
|
|
ad022cc447 | ||
|
|
344d57ff49 | ||
|
|
19c60616b1 | ||
|
|
14b3586b10 | ||
|
|
a9d5d641cd | ||
|
|
1d8449515e | ||
|
|
668f6e66cd | ||
|
|
6ac2c89aac | ||
|
|
cce439f0e2 | ||
|
|
2c6265b6cc | ||
|
|
4396052ed3 | ||
|
|
c2fed0379c | ||
|
|
e01421ea8c | ||
|
|
2e10aa4819 | ||
|
|
777d6300a8 | ||
|
|
c3bb440eff | ||
|
|
ea3a2cd541 | ||
|
|
8c6d3f57f9 | ||
|
|
f7584320f9 | ||
|
|
d9d895e468 | ||
|
|
4c8db1ec94 | ||
|
|
5fe74325ec | ||
|
|
871d3c15ab | ||
|
|
7c57cf0645 | ||
|
|
2d75b595e6 | ||
|
|
d152b65f6e | ||
|
|
eced9bc5a1 | ||
|
|
3292702dd3 | ||
|
|
fac6ae8847 | ||
|
|
f4996a53cc | ||
|
|
05f703b024 | ||
|
|
eb628ff40b | ||
|
|
79a2720363 | ||
|
|
27de5e9e06 | ||
|
|
dacf01d5b5 | ||
|
|
432a488019 | ||
|
|
384d096de2 | ||
|
|
85bae117b6 | ||
|
|
c8b309d0af | ||
|
|
3b2c3e0fd7 | ||
|
|
b7da54feb2 | ||
|
|
2cd7a990b5 | ||
|
|
2980ad2268 | ||
|
|
95c33c1bf3 | ||
|
|
561ce2e037 | ||
|
|
e89ff9fa27 | ||
|
|
494fef07ba | ||
|
|
4be0107329 | ||
|
|
914db938c3 | ||
|
|
506d3fc234 | ||
|
|
ff5caaf8c7 | ||
|
|
63e8ff5492 | ||
|
|
18e28e2797 | ||
|
|
0e9e3f0f64 | ||
|
|
ea8e98218c | ||
|
|
d9cfb06fad | ||
|
|
76812a1711 | ||
|
|
9584b2c229 | ||
|
|
c9e9a5d267 | ||
|
|
91aee0e591 | ||
|
|
86ae3a11bc | ||
|
|
76d53fbd92 | ||
|
|
39790d371b | ||
|
|
6300481349 | ||
|
|
59029aa3d9 | ||
|
|
431352ddce | ||
|
|
52be1f23a6 | ||
|
|
353001b40f | ||
|
|
99743d9074 | ||
|
|
cbda93517b | ||
|
|
38b634b68f | ||
|
|
16539837c0 | ||
|
|
7b9160af9c | ||
|
|
f72bb06115 | ||
|
|
840daf0688 | ||
|
|
947a786868 | ||
|
|
b457c7575f | ||
|
|
ed9c4aa9d6 | ||
|
|
e4ea8f8deb | ||
|
|
6399912995 | ||
|
|
e77437a042 | ||
|
|
1c2d46cef8 | ||
|
|
9ff116fc4d | ||
|
|
983312573d | ||
|
|
bc2cc65571 | ||
|
|
4b74ea23af | ||
|
|
82eb10889d | ||
|
|
efa9ea7720 | ||
|
|
25a36c8092 | ||
|
|
4171649bd3 | ||
|
|
5670965e9d | ||
|
|
032a5a61b4 | ||
|
|
b1327a4336 | ||
|
|
6332dff670 | ||
|
|
8f21df03b4 | ||
|
|
4943f5be62 | ||
|
|
5a303808a3 | ||
|
|
dc1617a8f1 | ||
|
|
d331da0b13 | ||
|
|
378127c25b | ||
|
|
4970ef7c25 | ||
|
|
79a78ba1c1 | ||
|
|
14533e0427 | ||
|
|
d192b43aaf | ||
|
|
4a352337a5 | ||
|
|
2fba971aeb | ||
|
|
f605375639 | ||
|
|
f5ff8ab6cc | ||
|
|
fd6da560ed | ||
|
|
956dfc5d93 | ||
|
|
35e6a8df3e | ||
|
|
c121291dda | ||
|
|
d19bbb2ec1 | ||
|
|
e6dc82ff55 | ||
|
|
56d8516427 | ||
|
|
94ad3f9461 | ||
|
|
26a4509429 | ||
|
|
80ae42088f | ||
|
|
42f01addca | ||
|
|
a9f096ef1b | ||
|
|
26d3d59812 | ||
|
|
5fec32327f | ||
|
|
478807d873 | ||
|
|
149ba6dfec | ||
|
|
a11b05f0a9 | ||
|
|
722d2a1fec | ||
|
|
5883b78822 | ||
|
|
d79de301bf | ||
|
|
184fdc6bc6 | ||
|
|
4625b650e5 | ||
|
|
f38bbfb540 | ||
|
|
18f0286692 | ||
|
|
c9f769ef2b | ||
|
|
c01c09ecbd | ||
|
|
2afe94fb36 | ||
|
|
4650cbcafc | ||
|
|
0d613b3d22 | ||
|
|
916d3764a2 | ||
|
|
0e7d299594 | ||
|
|
2cff629e23 | ||
|
|
cfc633c398 | ||
|
|
d61e343fbe | ||
|
|
4a446a2e73 | ||
|
|
b24108a788 | ||
|
|
1c985c85c8 | ||
|
|
249b754423 | ||
|
|
16faad063d | ||
|
|
c705be57eb | ||
|
|
3d6a2b86d6 | ||
|
|
1d0845b548 | ||
|
|
11f16ed8e1 | ||
|
|
8cb3089e8e | ||
|
|
5bbb56b4c3 | ||
|
|
a04d9bac9f | ||
|
|
8813d2d865 | ||
|
|
7e9f7a56f8 | ||
|
|
99920ef395 | ||
|
|
ccb23d65e5 | ||
|
|
fb9ef6e6cb | ||
|
|
491d2b1194 | ||
|
|
e83a5b3e0f | ||
|
|
0901ccb3eb | ||
|
|
cb4c763e0e | ||
|
|
84579d8719 | ||
|
|
4e2242e0ae |
@@ -0,0 +1,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
-13
@@ -6,33 +6,35 @@ on:
|
|||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '*'
|
- '*'
|
||||||
|
- '**'
|
||||||
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Component Tests & Pipeline Verification
|
name: Run Component Tests & Pipeline Verification
|
||||||
runs-on: ubuntu-latest
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
|
runs-on: logar
|
||||||
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: |
|
||||||
python3 -m py_compile src/Server.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
python3 -m py_compile src/Server.py src/server_enrollment.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
||||||
|
|
||||||
- name: Run Component Unit Tests
|
- name: Run Component Unit Tests
|
||||||
run: |
|
run: |
|
||||||
python3 -m unittest discover -s tests -v
|
PYTHONWARNINGS="ignore" python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
- name: Run End-to-End Pipeline Integration Test
|
- name: Run End-to-End Pipeline Integration Test
|
||||||
run: |
|
run: |
|
||||||
@@ -47,10 +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,43 +0,0 @@
|
|||||||
name: Release Linux Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published, created]
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (optional)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-linux:
|
|
||||||
name: Build & Release Linux Binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Python and Build Dependencies
|
|
||||||
run: |
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y python3 python3-pip python3-venv binutils zip
|
|
||||||
fi
|
|
||||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
|
||||||
pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -r compilation/requirements.txt
|
|
||||||
|
|
||||||
- name: Compile Standalone Linux Binaries
|
|
||||||
run: |
|
|
||||||
python3 compilation/package_dist.py --target linux
|
|
||||||
|
|
||||||
- name: Publish Linux Release Assets
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
python3 compilation/upload_release.py --skip-build
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
name: Release Windows Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published, created]
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- '*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (optional)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-windows:
|
|
||||||
name: Build & Release Windows Binaries
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py -m pip install --upgrade pip
|
|
||||||
& $py -m pip install pyinstaller -r compilation/requirements.txt
|
|
||||||
|
|
||||||
- name: Compile Standalone Windows Binaries
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
- name: Publish Windows Release Assets
|
|
||||||
shell: powershell
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/upload_release.py --skip-build
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
name: Release Binaries & Packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to publish assets to (default: v0.3.1)'
|
||||||
|
required: false
|
||||||
|
default: 'v0.3.1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build & Release Standalone Binaries (Linux & Windows Cross-Compilation)
|
||||||
|
runs-on: logar
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install System and Cross-Compilation Build Dependencies
|
||||||
|
run: |
|
||||||
|
echo "=== SYSTEM DIAGNOSTICS ==="
|
||||||
|
uname -a || true
|
||||||
|
python3 --version || true
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "[*] Installing Python shared libraries, headers, NSIS via apt-get..."
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
$SUDO apt-get clean || true
|
||||||
|
$SUDO apt-get update --allow-releaseinfo-change -o Acquire::Check-Valid-Until=false -y || $SUDO apt-get update -y || true
|
||||||
|
$SUDO apt-get -f install -y || true
|
||||||
|
PY_VER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "3.9")
|
||||||
|
echo "[*] Detected Python version: ${PY_VER}"
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends \
|
||||||
|
nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" \
|
||||||
|
python3-dev libpython3-dev build-essential gcc make curl tar gzip zip unzip || true
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends nsis nsis-common || true
|
||||||
|
for pkg in nsis nsis-common "libpython${PY_VER}" "libpython${PY_VER}-dev" "python${PY_VER}-dev" python3-dev libpython3-dev build-essential; do
|
||||||
|
$SUDO apt-get install -y --fix-broken --fix-missing --allow-unauthenticated --no-install-recommends "$pkg" || true
|
||||||
|
done
|
||||||
|
echo "[*] Verifying makensis:"
|
||||||
|
which makensis || echo "makensis not in PATH"
|
||||||
|
echo "[*] Verifying libpython shared library:"
|
||||||
|
find /usr/lib /usr/local/lib -name "libpython*.so*" 2>/dev/null || true
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
echo "[*] Installing dependencies via apk..."
|
||||||
|
$SUDO apk add --no-cache nsis python3-dev build-base gcc make curl tar gzip zip unzip || true
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo "[*] Installing dependencies via dnf..."
|
||||||
|
$SUDO dnf install -y mingw32-nsis nsis python3-devel gcc make curl tar gzip zip unzip || true
|
||||||
|
fi
|
||||||
|
python3 compilation/ensure_pip.py
|
||||||
|
python3 -m pip install --upgrade pip --break-system-packages 2>/dev/null || python3 -m pip install --upgrade pip 2>/dev/null || true
|
||||||
|
python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt --break-system-packages 2>/dev/null || python3 -m pip install pyinstaller distlib cryptography -r compilation/requirements.txt
|
||||||
|
|
||||||
|
- name: Compile Standalone Binaries (Linux & Windows)
|
||||||
|
run: |
|
||||||
|
python3 compilation/package_dist.py --target all
|
||||||
|
|
||||||
|
- name: Publish Unified Release Assets
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
python3 compilation/upload_release.py --skip-build
|
||||||
@@ -11,6 +11,7 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
*.exe
|
*.exe
|
||||||
|
!compilation/nssm.exe
|
||||||
*.bin
|
*.bin
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
@@ -28,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/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "LOGAR-LICENSING"]
|
||||||
|
path = LOGAR-LICENSING
|
||||||
|
url = https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
|
||||||
Submodule
+1
Submodule LOGAR-LICENSING added at 7bdb7300ec
@@ -1,387 +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 OpenPGP encryption, authenticated TCP streaming, temporal persistence tracking across 12-hour evaluation windows, an automated 4-run rule to filter transient warnings, and immediate pass-through for critical errors before reporting verified anomalies to **Hermes**.
|
[](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. [Security & Cryptographic Model](#security--cryptographic-model)
|
- **Mutual TLS 1.3 (`:9443`)**: Enforces bidirectional cryptographic identity verification (`ssl.CERT_REQUIRED`) for all streaming log frames.
|
||||||
4. [Cloud-Side Temporal Persistence & 4-Run Rule](#cloud-side-temporal-persistence--4-run-rule)
|
- **Hierarchical PKI & Seat Licensing**: Subordinate CAs automatically enroll and sync seat quotas with the Master Authority (`logar.eibl.tech`) over WebSockets.
|
||||||
5. [Agentic Hermes Integration](#agentic-hermes-integration)
|
- **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. [Repository & Shippables Structure](#repository--shippables-structure)
|
- **WebUI Dashboard**: Authenticated, 2-tab management console for live anomaly telemetry, client seat leasing, and Sub-CA lifecycle management.
|
||||||
8. [Getting Started & Installation](#getting-started--installation)
|
|
||||||
9. [Running Tests](#running-tests)
|
|
||||||
10. [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 state and no local SQLite or cache files.
|
|
||||||
- **Source-Level Filtering**: Agents upload candidate entries spanning from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), while stripping verbose debugging noise (`DEBUG`, audit entries) and skipping any entries older than 24 hours.
|
|
||||||
- **End-to-End Encryption**: Logs are encrypted using the server's OpenPGP public key before leaving the edge node.
|
|
||||||
- **Secure TCP Sockets**: Ingestion occurs over low-overhead authenticated TCP sockets rather than bulky HTTP/HTTPS endpoints.
|
|
||||||
|
|
||||||
### 2. Cloud-Side Temporal Persistence & Severity Routing
|
|
||||||
The central Python/TCP hub handles the heavy lifting:
|
|
||||||
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
|
||||||
- Candidate issues are evaluated over a **12-hour temporal evaluation window**.
|
|
||||||
- **Warning Persistence (4-Run Rule)**: `WARNING` level issues must persist across **at least 4 consecutive runs / cycles** within the 12-hour window to be confirmed as genuine anomalies, automatically filtering out transient blips.
|
|
||||||
- **Immediate Error Pass**: Critical errors (`ERROR`, `CRITICAL`, `FATAL`) bypass the 4-run threshold and are promoted immediately to `VERIFIED` on their first occurrence.
|
|
||||||
|
|
||||||
### 3. Agentic Integration with Hermes
|
|
||||||
Instead of human engineers manually diving through noisy logs, **Hermes** ingests pre-filtered anomalies directly from the cloud hub (`GET /api/hermes/report`), treating verified errors and 4-run validated warnings as actionable system artifacts to trigger precise team notifications.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture & Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph Edge Nodes [State-Tracking Edge Forwarders]
|
subgraph Authority [Master License Authority - logar.eibl.tech]
|
||||||
W[Win_Client.py / Win_Client.exe / Win_Client.pyz<br/>Windows Event Log Application]
|
M_CA[Master Root CA]
|
||||||
L[Linux_Client.py / Linux_Client.bin<br/>systemd journalctl -p info]
|
AUTH_WS[WebSocket Licensing Hub]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Security Layer [Security & Framing]
|
subgraph ServerHub [LOGAR Server Hub]
|
||||||
E[OpenPGP Payload Encryption<br/>Server Public Key & Fingerprint]
|
SUB_CA[Subordinate CA]
|
||||||
S[Length-Prefixed Framing<br/>4-byte Big-Endian + Auth Envelope]
|
SEAT_MGR[Dynamic Seat Manager]
|
||||||
|
MTLS_SRV[mTLS 1.3 Ingestion Server<br/>Port 9443]
|
||||||
|
TEMP_ENG[12h Temporal Persistence Engine]
|
||||||
|
PROV_API[Hermes Provisioning & WebUI API<br/>Port 8443]
|
||||||
|
|
||||||
|
SUB_CA --> SEAT_MGR
|
||||||
|
SEAT_MGR --> MTLS_SRV
|
||||||
|
MTLS_SRV --> TEMP_ENG
|
||||||
|
TEMP_ENG --> PROV_API
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Cloud Hub [LOGAR Central Server Hub]
|
subgraph EdgeNodes [Heterogeneous Edge Forwarders]
|
||||||
TCP[Authenticated TCP Listener<br/>Port 9443]
|
WIN[Win_Client.exe<br/>Windows Event Logs]
|
||||||
DEC[OpenPGP Decryption<br/>Server Private Key]
|
LIN[Linux_Client.bin<br/>systemd journalctl]
|
||||||
DB[(SQLite Persistence<br/>active_issues & ingest_runs)]
|
|
||||||
RULE{12h Window &<br/>4-Run Rule}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Agentic Reporting [Downstream Integration]
|
subgraph Downstream [Hermes AI Layer]
|
||||||
API[FastAPI / Uvicorn Reporting<br/>Port 8443]
|
HERMES[Hermes AI Engine]
|
||||||
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
W --> E
|
M_CA -->|Signs Sub-CA Cert| SUB_CA
|
||||||
L --> E
|
AUTH_WS <==>|Live WebSocket Sync| SUB_CA
|
||||||
E --> S
|
|
||||||
S -->|TCP Stream| TCP
|
WIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
TCP --> DEC
|
LIN -->|1. Auto-Enrollment| SUB_CA
|
||||||
DEC --> RULE
|
WIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
RULE --> DB
|
LIN ==>|2. mTLS 1.3 Stream :9443| MTLS_SRV
|
||||||
DB --> API
|
PROV_API ==>|3. GET /api/hermes/report| HERMES
|
||||||
API --> HERMES
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security & Cryptographic Model
|
## Quick Start & Automated Installers
|
||||||
|
|
||||||
### Pure-Python OpenPGP (RFC 4880)
|
LOGAR provides automated production service installers for both Linux (`systemd`) and Windows (`services.msc` via NSSM):
|
||||||
- **Zero OS Binary Dependency**: Utilizes `pgpy` and `cryptography` in pure Python. **No native GnuPG or `gpg` binary installation is required** on the server, Windows nodes, or Linux nodes.
|
|
||||||
- **First-Run Automatic Key Generation**: On the first launch, if `server_config.json` is missing, `Server.py` automatically generates:
|
### 1. Server Hub Installation
|
||||||
- An OpenPGP RSA 2048 keypair with encryption-only usage flags.
|
|
||||||
- An armored private key (`private_key`) and public key (`public_key`).
|
- **Linux (Automated systemd Installer)**:
|
||||||
- A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
|
||||||
- A cryptographically random authentication secret token (`auth_token`).
|
|
||||||
- **Client Configuration Exporter**:
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
sudo ./compilation/install_linux_server.sh
|
||||||
```
|
```
|
||||||
Produces an anonymous client config containing only the server socket coordinates, authentication token, and the encryption-only public key & fingerprint.
|
*Deploys standalone `Server.bin` to `/opt/logar-server`, initializes PKI, and enables `logar-server.service`.*
|
||||||
- **Socket Protocol Framing**:
|
|
||||||
- `[4 bytes big-endian unsigned int]` : Total envelope length.
|
- **Windows (Inno Setup / NSIS Installer)**:
|
||||||
- `[JSON Envelope]` :
|
Download and execute `LOGAR-Server-Setup.exe` from [Releases](https://gitea.eibl.tech/me0nline/LOGAR/releases). Installs `Server.exe` into `C:\Program Files\LOGAR-Server` and registers `LOGAR_Server` Windows service.
|
||||||
```json
|
|
||||||
{
|
#### Default WebUI Management Credentials
|
||||||
"auth_token": "<SECRET_TOKEN>",
|
Once installed or executed, access the web console:
|
||||||
"timestamp": "2026-09-03T...",
|
- **URL**: `http://<server-ip>:8443/webui`
|
||||||
"encrypted_payload": "-----BEGIN PGP MESSAGE-----\n..."
|
- **Default Username**: `admin`
|
||||||
}
|
- **Default Password**: `admin_logar_2026!` *(Configurable via `LOGAR_ADMIN_USER` / `LOGAR_ADMIN_PASS` or in the WebUI user settings)*
|
||||||
```
|
|
||||||
- Unauthorized clients or invalid authentication tokens are rejected immediately.
|
### 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).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cloud-Side Temporal Persistence & 4-Run Rule
|
## Repository Structure
|
||||||
|
|
||||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
|
||||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
|
||||||
- **12-Hour Evaluation Window**:
|
|
||||||
- When an issue is observed, the hub compares `(now - last_seen)`.
|
|
||||||
- If more than 12 hours have passed since the issue was last recorded, the previous window is expired and the cycle resets to `run_count = 1`.
|
|
||||||
- **4-Run Rule for Warnings**:
|
|
||||||
- The 4-run persistence threshold specifically applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
|
||||||
- For each distinct run batch, `run_count` increments.
|
|
||||||
- Warnings with `run_count < 4` are marked as `TRANSIENT` and excluded from Hermes reports.
|
|
||||||
- When `run_count >= 4` within the active 12-hour window, the warning transitions to `VERIFIED`.
|
|
||||||
- **Immediate Verification for Errors**:
|
|
||||||
- High-severity events (`ERROR`, `CRITICAL`, `FATAL`) **always pass immediately**.
|
|
||||||
- On their very first ingestion (`run_count = 1`), errors are promoted directly to `VERIFIED` and surfaced to Hermes without waiting for 4 runs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agentic Hermes Integration
|
|
||||||
|
|
||||||
The server hub serves a REST reporting API (default port `8443`):
|
|
||||||
|
|
||||||
### `GET /api/hermes/report`
|
|
||||||
Returns all **verified anomalies** (immediate critical errors and warnings verified after 4 consecutive runs within the 12-hour evaluation window):
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "corp.internal:web-app-01.corp.internal:NginxWorkerCrash",
|
|
||||||
"site": "corp.internal",
|
|
||||||
"server": "web-app-01.corp.internal",
|
|
||||||
"signature": "NginxWorkerCrash",
|
|
||||||
"severity": "ERROR",
|
|
||||||
"message": "Worker process 4120 terminated with signal 11",
|
|
||||||
"os_type": "linux",
|
|
||||||
"first_seen": "2026-09-03T09:00:00+00:00",
|
|
||||||
"last_seen": "2026-09-03T21:00:00+00:00",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"evaluation_window": "12h",
|
|
||||||
"verified": true,
|
|
||||||
"status": "VERIFIED"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /api/hermes/all`
|
|
||||||
Diagnostic endpoint listing all active issues (both `TRANSIENT` candidate blips and `VERIFIED` anomalies).
|
|
||||||
|
|
||||||
### `GET /health`
|
|
||||||
Returns hub health, encryption fingerprint, and listener ports.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dynamic Machine & Domain Identification
|
|
||||||
|
|
||||||
Client configurations intentionally contain **no machine name or site name**. Both forwarders dynamically identify their host and domain at runtime via `get_machine_identifier()`:
|
|
||||||
1. **Fully Qualified Domain Name (FQDN)**: Checked via `socket.getfqdn()`.
|
|
||||||
2. **OS-Specific Domain Discovery**:
|
|
||||||
- **Windows**: Checks Active Directory environment variable `USERDNSDOMAIN` / `USERDOMAIN`.
|
|
||||||
- **Linux**: Parses `/etc/resolv.conf` `domain` and `search` directives.
|
|
||||||
3. **Reverse DNS Lookup**: Resolves canonical hostname via `socket.gethostbyaddr`.
|
|
||||||
4. **Fallback**: Local hostname `socket.gethostname()`.
|
|
||||||
|
|
||||||
The server automatically infers site attribution from domain qualifiers (e.g. `node01.corp.internal` $\rightarrow$ site `corp.internal`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository & Shippables Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
LOGAR/
|
LOGAR/
|
||||||
├── .gitea/
|
├── .gitea/workflows/ # CI/CD pipelines (ci.yml, release.yml)
|
||||||
│ └── workflows/
|
├── compilation/ # Build scripts, Linux installers & Inno Setup scripts
|
||||||
│ ├── ci.yml # Continuous Integration automated test suite (runs on every push)
|
│ ├── install_linux_client.sh # Linux client systemd auto-installer
|
||||||
│ ├── release-linux.yml # Linux release workflow (compiles Server.bin, Linux_Client.bin, checksums)
|
│ ├── install_linux_server.sh # Linux server systemd auto-installer
|
||||||
│ └── release-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums)
|
│ ├── package_dist.py # Standalone binary cross-compiler
|
||||||
├── compilation/ # Build, packaging, and release automation tools
|
│ └── upload_release.py # Gitea release asset publisher
|
||||||
│ ├── package_dist.py # Multi-platform standalone binary packaging script
|
├── src/ # Application source code
|
||||||
│ ├── requirements.txt # Unified project dependencies
|
│ ├── Server.py # Central mTLS hub, temporal engine & REST API
|
||||||
│ └── upload_release.py # Direct Gitea REST API release asset publisher
|
│ ├── server_enrollment.py # Sub-CA PKI and certificate generator
|
||||||
├── src/ # Core application source modules
|
│ ├── Win_Client.py # Windows edge forwarder
|
||||||
│ ├── __init__.py
|
│ └── Linux_Client.py # Linux edge forwarder
|
||||||
│ ├── Server.py # Central TCP server and Hermes API
|
├── tests/ # Automated test suites (35 unit tests + pipeline test)
|
||||||
│ ├── Win_Client.py # Windows edge forwarder
|
├── LOGAR-LICENSING/ # Standalone Master License Authority (Git submodule)
|
||||||
│ └── Linux_Client.py # Linux edge forwarder
|
├── RELEASE_NOTES.md # Version history and security changelog
|
||||||
├── tests/ # Automated test suites
|
└── README.md # Repository overview
|
||||||
│ ├── test_linux_client.py # Linux client unit tests
|
|
||||||
│ ├── test_pipeline.py # End-to-end integration test
|
|
||||||
│ ├── test_server.py # Server unit tests
|
|
||||||
│ └── test_win_client.py # Windows client unit tests
|
|
||||||
├── .gitignore # Ignore venv, caches, DBs, and private keys
|
|
||||||
├── README.md # Comprehensive documentation
|
|
||||||
├── RELEASE_NOTES.md # Release history and changelog
|
|
||||||
├── server_config.sample.json # Central server sample configuration
|
|
||||||
└── out/ # Standalone deployment documentation & sample configs
|
|
||||||
├── linux_server/
|
|
||||||
│ ├── README.md # Linux systemd service installation & hub guide
|
|
||||||
│ └── server_config.sample.json # Reference server configuration
|
|
||||||
├── win_server/
|
|
||||||
│ ├── README.md # Windows service (NSSM/Task Scheduler) installation guide
|
|
||||||
│ └── server_config.sample.json # Reference server configuration
|
|
||||||
├── linux_client/
|
|
||||||
│ ├── README.md # Linux service & timer installation guide
|
|
||||||
│ └── client_config.sample.json # Reference client configuration
|
|
||||||
└── win_client/
|
|
||||||
├── README.md # Windows service installation & configuration guide
|
|
||||||
└── client_config.sample.json # Reference client configuration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started & Installation
|
## Ports & REST API Summary
|
||||||
|
|
||||||
### 1. Central Server Hub
|
| Port | Protocol | Service | Description |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **`9443`** | `TCP / TLS 1.3` | **mTLS Log Ingestion** | Mutual TLS socket listener for edge forwarders |
|
||||||
|
| **`8443`** | `HTTP / REST` | **Management & Hermes API** | WebUI, forwarder enrollment, seat management, and AI reports |
|
||||||
|
|
||||||
1. **Install dependencies**:
|
### Core Endpoints
|
||||||
```bash
|
|
||||||
pip install -r compilation/requirements.txt
|
|
||||||
```
|
|
||||||
2. **Start the server** (generates `server_config.json` and keypair on first run):
|
|
||||||
```bash
|
|
||||||
python src/Server.py
|
|
||||||
```
|
|
||||||
3. **Export a client configuration**:
|
|
||||||
```bash
|
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP> --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Windows Client Deployment
|
- **`POST /api/client/enroll`**: Forwarder auto-enrollment and proactive certificate renewal.
|
||||||
|
- **`GET /api/clients`**: Returns active seat quota and registered edge forwarder telemetry *(Admin Auth)*.
|
||||||
#### Option A: Precompiled Standalone Executable (Recommended)
|
- **`GET /api/hermes/report`**: Structured JSON anomaly stream for Hermes AI reasoning.
|
||||||
1. Download `Win_Client.exe` (or `Win_Client.pyz`) from the repository releases.
|
- **`POST /api/license/sync`**: Triggers immediate WebSocket sync with `logar.eibl.tech` *(Admin Auth)*.
|
||||||
2. Place `client_config.json` (exported from the server) in the same directory.
|
- **`GET /health`**: Server health, encryption fingerprint, and mTLS status.
|
||||||
3. Run manually or schedule via Task Scheduler (every 3 hours):
|
|
||||||
```powershell
|
|
||||||
.\Win_Client.exe --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Win_Client.py`, `requirements.txt`, and `client_config.json` to the target machine.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```powershell
|
|
||||||
python -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. Run manually or schedule via Task Scheduler:
|
|
||||||
```powershell
|
|
||||||
python Win_Client.py --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Linux Client Deployment
|
|
||||||
|
|
||||||
#### Option A: Precompiled Standalone Binary (Recommended)
|
|
||||||
1. Download `Linux_Client.bin` from the repository releases.
|
|
||||||
2. Place `Linux_Client.bin` and `client_config.json` into `/opt/logar/` and make it executable:
|
|
||||||
```bash
|
|
||||||
chmod +x /opt/logar/Linux_Client.bin
|
|
||||||
```
|
|
||||||
3. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * /opt/logar/Linux_Client.bin --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Linux_Client.py`, `requirements.txt`, and `client_config.json` to `/opt/logar/`.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. (Optional) Run `out/linux_client/build_bin.sh` to compile a standalone ELF binary locally if desired.
|
|
||||||
4. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * python3 /opt/logar/Linux_Client.py --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Testing & Cross-Compilation
|
||||||
|
|
||||||
### 1. Component-Specific Unit Tests
|
|
||||||
The test suite is located in `tests/` and exercises all components:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all unit tests
|
# Run full unit test suite (35 tests)
|
||||||
python -m unittest discover -s tests
|
python -m unittest discover -s tests -v
|
||||||
|
|
||||||
# Or run component tests individually:
|
# Run end-to-end mTLS pipeline integration test
|
||||||
python -m unittest tests/test_server.py
|
python tests/test_pipeline.py
|
||||||
python -m unittest tests/test_win_client.py
|
|
||||||
python -m unittest tests/test_linux_client.py
|
# Cross-compile native Linux and Windows binaries on Linux runner
|
||||||
|
python compilation/package_dist.py --target all
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. End-to-End Pipeline Integration Test
|
|
||||||
The pipeline test exercises invalid token rejection, encrypted socket streaming, database persistence, status promotion upon the 4th run, and the Hermes API report output.
|
|
||||||
|
|
||||||
1. **Start the server** in Shell 1 (creates `server_config.json` on first run):
|
|
||||||
```bash
|
|
||||||
python src/Server.py
|
|
||||||
```
|
|
||||||
2. **Export client configuration** in Shell 2 (required for testing):
|
|
||||||
```bash
|
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
3. **Execute the integration test** in Shell 2:
|
|
||||||
```bash
|
|
||||||
python tests/test_pipeline.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Continuous Integration via Gitea Actions
|
|
||||||
|
|
||||||
Continuous integration is automated via [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) and triggers automatically on **every push** and pull request:
|
|
||||||
1. **Syntax Compilation**: Validates all Python scripts (`src/Server.py`, `src/Win_Client.py`, `src/Linux_Client.py`, `compilation/package_dist.py`, `compilation/upload_release.py`, `tests/test_pipeline.py`, and test suites).
|
|
||||||
2. **Component Unit Tests**: Discovers and runs all unit tests in `tests/` (`test_server.py`, `test_win_client.py`, `test_linux_client.py`).
|
|
||||||
3. **End-to-End Pipeline Verification**: Automatically spins up the LOGAR server hub, generates test configs, runs `tests/test_pipeline.py` (testing socket authentication, 4-run rule persistence, Hermes API report, and client integrations), and shuts down the test instance.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automated Releases via Gitea Actions
|
## Documentation & Deep Dives
|
||||||
|
|
||||||
Release builds are automated via two dedicated Gitea Actions workflows running concurrently on native platform runners:
|
For in-depth operational guides, lifecycle state machines, and PKI specifications, consult the **[LOGAR Gitea Wiki](https://gitea.eibl.tech/me0nline/LOGAR/wiki)**:
|
||||||
- [`.gitea/workflows/release-linux.yml`](.gitea/workflows/release-linux.yml) (`ubuntu-latest`)
|
|
||||||
- [`.gitea/workflows/release-windows.yml`](.gitea/workflows/release-windows.yml) (`windows-latest`)
|
|
||||||
|
|
||||||
### Publishing a Release
|
* [Architecture & Design Details](https://gitea.eibl.tech/me0nline/LOGAR/wiki/Architecture-and-Design)
|
||||||
Whenever you want to release a new version with compiled standalone binaries:
|
* [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 v1.0.2
|
* [Complete REST API Specification](https://gitea.eibl.tech/me0nline/LOGAR/wiki/API-Reference)
|
||||||
git push origin v1.0.2
|
* [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)
|
||||||
*(You can also trigger builds manually via the Gitea UI using the **Run workflow** button (`workflow_dispatch`) on either workflow).*
|
|
||||||
|
|
||||||
### Automated Multi-Platform Compilation:
|
|
||||||
1. **Linux Runner** (`release-linux.yml`):
|
|
||||||
- Compiles native Linux ELF executables: `Linux_Client.bin` and `Server.bin`.
|
|
||||||
- Generates dedicated SHA-256 checksum files:
|
|
||||||
- `linux_client_sha256sum` (verification for `Linux_Client.bin`)
|
|
||||||
- `linux_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `linux_server_sha256sum` (verification for `Server.bin`)
|
|
||||||
- `SHA256SUMS_linux.txt` (summary manifest)
|
|
||||||
- Attaches all Linux assets to the Gitea release.
|
|
||||||
|
|
||||||
2. **Windows Runner** (`release-windows.yml`):
|
|
||||||
- Compiles native Windows PE executables: `Win_Client.exe` and `Server.exe`.
|
|
||||||
- Generates dedicated SHA-256 checksum files:
|
|
||||||
- `win_client_sha256sum` (verification for `Win_Client.exe`)
|
|
||||||
- `win_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `win_server_sha256sum` (verification for `Server.exe`)
|
|
||||||
- `SHA256SUMS_windows.txt` (summary manifest)
|
|
||||||
- Attaches all Windows assets to the Gitea release.
|
|
||||||
|
|
||||||
3. **Concurrent Publishing & Conflict Handling**:
|
|
||||||
`upload_release.py` includes automatic retry and conflict resolution so concurrent Windows and Linux runners attach their respective assets to the release without collision.
|
|
||||||
|
|
||||||
### Verifying Checksums
|
|
||||||
- On Linux:
|
|
||||||
```bash
|
|
||||||
sha256sum -c linux_client_sha256sum
|
|
||||||
# or
|
|
||||||
sha256sum -c linux_server_sha256sum
|
|
||||||
```
|
|
||||||
- On Windows (PowerShell):
|
|
||||||
```powershell
|
|
||||||
Get-FileHash .\Win_Client.exe -Algorithm SHA256
|
|
||||||
Get-Content .\win_client_sha256sum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Packaging & Manual Upload
|
|
||||||
You can also compile and package binaries locally anytime:
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
py -3.12 compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
# Linux
|
|
||||||
python3 compilation/package_dist.py --target linux
|
|
||||||
```
|
|
||||||
To upload local builds directly to Gitea:
|
|
||||||
```powershell
|
|
||||||
python compilation/upload_release.py --tag v1.0.2 --token <YOUR_GITEA_TOKEN>
|
|
||||||
```
|
|
||||||
*(Environment variables `GITEA_TOKEN`, `GITEA_SERVER_URL`, `GITEA_REPOSITORY`, and `GITEA_REF_NAME` are also supported automatically).*
|
|
||||||
|
|||||||
+12
-12
@@ -1,13 +1,13 @@
|
|||||||
# LOGAR Release v1.0.2
|
# LOGAR Release v0.3.1
|
||||||
|
|
||||||
### Changes in this Release:
|
### 🛡️ Security Fixes & Defensive Hardening
|
||||||
- **Warning Persistence & Immediate Error Routing**: Restructured temporal verification on the central hub so the 4-run persistence rule across the 12-hour evaluation window strictly governs `WARNING` and `INFO` events to suppress transient blips. High-severity `ERROR`, `CRITICAL`, and `FATAL` events are now promoted to `VERIFIED` immediately on their first occurrence and reported to Hermes without waiting for consecutive runs.
|
- **Mandatory Admin Authentication**: Enforced strict administrator authentication across all fleet management endpoints (`/api/clients`, `/api/clients/approve`, `/api/clients/revoke`, `/api/clients/delete`, `/api/client/export-profile`) and license management APIs.
|
||||||
- **Server Deployment Packages in `out/`**: Added comprehensive deployment guides and configuration templates for both Linux Server hub (systemd service) and Windows Server hub (NSSM service / Task Scheduler) under `out/linux_server` and `out/win_server`.
|
- **Brute-Force Mitigation & Rate Limiting**: Implemented thread-safe sliding-window IP rate limiting on `/login` (10 attempts/min) and `/api/client/enroll` (15 attempts/min) with progressive delays and `HTTP 429` throttling.
|
||||||
- **Refactored Repository Layout**: Reorganized codebase by moving runtime forwarders and server hub into `src/`, compilation/release packaging utilities into `compilation/`, and all unit and pipeline verification tests into `tests/`.
|
- **Cookie Security Flags**: Session cookies now enforce `HttpOnly=True`, `SameSite="Lax"`, and dynamic `Secure=True` for HTTPS connections and reverse-proxy deployments (`X-Forwarded-Proto`).
|
||||||
- **Dual Platform Gitea Release Automation**: Dedicated Windows (`release-windows.yml`) and Linux (`release-linux.yml`) Gitea Actions to compile native platform binaries (`Win_Client.exe` and `Server.exe` on Windows; `Linux_Client.bin` and `Server.bin` on Linux).
|
- **Cryptographic Random Secrets**: Server Hub and License Authority session secrets are now generated with 256-bit CSPRNG (`secrets.token_hex(32)`) on initialization.
|
||||||
- **Dedicated SHA-256 Checksums**: Release assets now include dedicated checksum files matching `[win/linux]_[client/agent]_sha256sum` (`win_client_sha256sum`, `win_agent_sha256sum`, `win_server_sha256sum`, `linux_client_sha256sum`, `linux_agent_sha256sum`, `linux_server_sha256sum`).
|
- **License Renewal Validation**: Hardened `/api/license/renew` with explicit PEM structure and database validation, preventing unhandled exceptions.
|
||||||
- **Removed Client Filter Logic**: Removed restrictive source-level noise filtering on edge forwarders. Clients now collect and stream all candidate events from `INFO` up to `ERROR` over the lookback window instead of discarding them at the source.
|
|
||||||
- **State Tracking & Deduplication**: Added persistent client state tracking (`client_state.json`) with journalctl cursors and Windows Event Log record numbers to guarantee that previously transmitted events are never resent.
|
### ⚡ Operational Improvements & Modernization
|
||||||
- **24-Hour Lookback Window**: Forwarders now scan and upload events from the last 24 hours (default `--hours 24`), skipping older entries.
|
- **FastAPI Lifespan Handlers**: Migrated legacy startup events to modern ASGI lifespan context managers.
|
||||||
- **Lightweight Distribution Structure**: Cleaned `out/` to strictly contain deployment documentation and sample configurations.
|
- **OpenPGP Compression Preferences**: Aligned Server public key compression algorithms (`ZIP`, `ZLIB`, `BZ2`, `Uncompressed`) with client preferences.
|
||||||
- **Automated Gitea CI/CD**: Integrated push testing workflow (`ci.yml`) and multi-platform release asset packaging.
|
- **Cross-Platform Compilation**: Unified automated packaging for standalone Windows and Linux binaries.
|
||||||
|
|||||||
@@ -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,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HUB_URL="${1:-http://hub.example.com:8443}"
|
||||||
|
ENROLL_SECRET="${2:-}"
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/logar-client"
|
||||||
|
CONFIG_DIR="/etc/logar"
|
||||||
|
|
||||||
|
echo "[+] Installing LOGAR Client..."
|
||||||
|
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}/certs"
|
||||||
|
chmod 700 "${CONFIG_DIR}/certs"
|
||||||
|
|
||||||
|
if [ -f "dist/Linux_Client.bin" ]; then
|
||||||
|
cp dist/Linux_Client.bin "${INSTALL_DIR}/Linux_Client"
|
||||||
|
elif [ -f "dist/Linux_Client" ]; then
|
||||||
|
cp dist/Linux_Client "${INSTALL_DIR}/Linux_Client"
|
||||||
|
else
|
||||||
|
echo "[!] Warning: dist/Linux_Client binary not found in current directory. Continuing with existing binary if present."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${INSTALL_DIR}/Linux_Client" ]; then
|
||||||
|
chmod +x "${INSTALL_DIR}/Linux_Client"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bootstrap certificate if missing and enrollment secret is provided
|
||||||
|
if [ ! -f "${CONFIG_DIR}/certs/client.crt" ] && [ -n "${ENROLL_SECRET}" ]; then
|
||||||
|
echo "[+] Enrolling client with LOGAR Hub..."
|
||||||
|
MACHINE_ID=$(cat /etc/machine-id 2>/dev/null || hostname)
|
||||||
|
RESPONSE=$(curl -s -X POST "${HUB_URL}/api/client/enroll" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"client_id\": \"${MACHINE_ID}\", \"hostname\": \"$(hostname)\", \"os\": \"linux\", \"enrollment_secret\": \"${ENROLL_SECRET}\"}")
|
||||||
|
|
||||||
|
echo "${RESPONSE}" | grep -q "client_cert" || {
|
||||||
|
echo "[!] Enrollment failed: ${RESPONSE}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "${RESPONSE}" | jq -r .ca_cert > "${CONFIG_DIR}/certs/ca.crt"
|
||||||
|
echo "${RESPONSE}" | jq -r .client_cert > "${CONFIG_DIR}/certs/client.crt"
|
||||||
|
echo "${RESPONSE}" | jq -r .client_key > "${CONFIG_DIR}/certs/client.key"
|
||||||
|
else
|
||||||
|
python3 -c "import sys, json; data=json.loads(sys.stdin.read()); open('${CONFIG_DIR}/certs/ca.crt','w').write(data['ca_cert']); open('${CONFIG_DIR}/certs/client.crt','w').write(data['client_cert']); open('${CONFIG_DIR}/certs/client.key','w').write(data['client_key'])" <<< "${RESPONSE}"
|
||||||
|
fi
|
||||||
|
chmod 600 "${CONFIG_DIR}/certs/client.key"
|
||||||
|
echo "[+] Certificates written to ${CONFIG_DIR}/certs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF > /etc/systemd/system/logar-client.service
|
||||||
|
[Unit]
|
||||||
|
Description=LOGAR Edge Log Aggregator Client
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${INSTALL_DIR}/Linux_Client --config ${CONFIG_DIR}/config.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now logar-client.service || true
|
||||||
|
echo "[+] LOGAR Client service configured and activated."
|
||||||
|
else
|
||||||
|
echo "[+] Systemd service installed at /etc/systemd/system/logar-client.service"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/logar-server"
|
||||||
|
CONFIG_DIR="/etc/logar"
|
||||||
|
|
||||||
|
# Parse command line options
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--config-dir|--data-dir)
|
||||||
|
CONFIG_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo " LOGAR Server Enterprise Linux Installer"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
if [ -f "dist/Server.bin" ]; then
|
||||||
|
cp dist/Server.bin "${INSTALL_DIR}/Server"
|
||||||
|
elif [ -f "dist/Server" ]; then
|
||||||
|
cp dist/Server "${INSTALL_DIR}/Server"
|
||||||
|
elif [ -f "dist/LOGAR_Server" ]; then
|
||||||
|
cp dist/LOGAR_Server "${INSTALL_DIR}/Server"
|
||||||
|
else
|
||||||
|
echo "[!] Warning: dist/Server.bin binary not found in current directory. Continuing with existing binary if present."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${INSTALL_DIR}/Server" ]; then
|
||||||
|
chmod +x "${INSTALL_DIR}/Server"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF > /etc/systemd/system/logar-server.service
|
||||||
|
[Unit]
|
||||||
|
Description=LOGAR Hub and Aggregator Engine
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=${INSTALL_DIR}/Server --config ${CONFIG_DIR}/server_config.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
|
User=root
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now logar-server.service || true
|
||||||
|
echo "[+] LOGAR Server service installed and activated."
|
||||||
|
else
|
||||||
|
echo "[+] Systemd service installed at /etc/systemd/system/logar-server.service"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
[Setup]
|
||||||
|
AppName=LOGAR Client
|
||||||
|
AppVersion=0.3.1
|
||||||
|
AppPublisher=LOGAR Security Systems
|
||||||
|
AppPublisherURL=https://logar.eibl.tech
|
||||||
|
AppSupportURL=https://logar.eibl.tech
|
||||||
|
AppUpdatesURL=https://logar.eibl.tech
|
||||||
|
DefaultDirName={autopf}\LOGAR
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=LOGAR-Client-Setup
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
VersionInfoCompany=LOGAR Security Systems
|
||||||
|
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
|
||||||
|
VersionInfoVersion=0.3.1.0
|
||||||
|
VersionInfoProductName=LOGAR Client
|
||||||
|
VersionInfoProductVersion=0.3.1
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client.exe"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{code:GetDataDir}\client.log"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{code:GetDataDir}\client_err.log"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppParameters ""--config """"{code:GetDataDir}\client_config.json"""""""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Client"; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Client confirm"; Flags: runhidden
|
||||||
|
|
||||||
|
[UninstallDelete]
|
||||||
|
Type: filesandordirs; Name: "{app}\certs"
|
||||||
|
Type: files; Name: "{app}\client_config.json"
|
||||||
|
Type: files; Name: "{app}\client_state.json"
|
||||||
|
Type: files; Name: "{app}\*.log"
|
||||||
|
Type: filesandordirs; Name: "{app}\runtime"
|
||||||
|
Type: filesandordirs; Name: "{app}"
|
||||||
|
Type: filesandordirs; Name: "{commonappdata}\LOGAR"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var
|
||||||
|
DataDirPage: TInputDirWizardPage;
|
||||||
|
|
||||||
|
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');
|
||||||
|
end;
|
||||||
|
|
||||||
|
function GetDataDir(Param: String): String;
|
||||||
|
begin
|
||||||
|
Result := DataDirPage.Values[0];
|
||||||
|
if Result = '' then
|
||||||
|
Result := ExpandConstant('{commonappdata}\LOGAR');
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
begin
|
||||||
|
if CurStep = ssPostInstall then
|
||||||
|
begin
|
||||||
|
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir(''));
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
var
|
||||||
|
CustomDataDir: String;
|
||||||
|
begin
|
||||||
|
if CurUninstallStep = usPostUninstall then
|
||||||
|
begin
|
||||||
|
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', CustomDataDir) then
|
||||||
|
begin
|
||||||
|
if (CustomDataDir <> '') and DirExists(CustomDataDir) then
|
||||||
|
DelTree(CustomDataDir, True, True, True);
|
||||||
|
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
!include "MUI2.nsh"
|
||||||
|
!include "LogicLib.nsh"
|
||||||
|
|
||||||
|
Name "LOGAR Client"
|
||||||
|
OutFile "../dist/LOGAR-Client-Setup.exe"
|
||||||
|
InstallDir "$PROGRAMFILES64\LOGAR"
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
|
||||||
|
BrandingText "LOGAR Enterprise SIEM & Edge Forwarder"
|
||||||
|
|
||||||
|
VIProductVersion "0.3.1.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.1.0"
|
||||||
|
VIAddVersionKey "ProductVersion" "0.3.1.0"
|
||||||
|
|
||||||
|
Var DATADIR
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Section "Install"
|
||||||
|
SetShellVarContext all
|
||||||
|
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'
|
||||||
|
|
||||||
|
# Install and configure service via NSSM
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStdout "$DATADIR\client.log"'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStderr "$DATADIR\client_err.log"'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppParameters "--config \"$DATADIR\client_config.json\""'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" 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}
|
||||||
|
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm'
|
||||||
|
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"
|
||||||
|
SectionEnd
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
[Setup]
|
||||||
|
AppName=LOGAR Server
|
||||||
|
AppVersion=0.3.1
|
||||||
|
AppPublisher=LOGAR Security Systems
|
||||||
|
AppPublisherURL=https://logar.eibl.tech
|
||||||
|
AppSupportURL=https://logar.eibl.tech
|
||||||
|
AppUpdatesURL=https://logar.eibl.tech
|
||||||
|
DefaultDirName={autopf}\LOGAR-Server
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=LOGAR-Server-Setup
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
Compression=lzma
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
VersionInfoCompany=LOGAR Security Systems
|
||||||
|
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
|
||||||
|
VersionInfoVersion=0.3.1.0
|
||||||
|
VersionInfoProductName=LOGAR Server
|
||||||
|
VersionInfoProductVersion=0.3.1
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\dist\runtime\*"; DestDir: "{app}\runtime"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
Name: "{code:GetDataDir}"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Server ""{app}\Server.exe"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppDirectory ""{app}"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{code:GetDataDir}\server.log"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{code:GetDataDir}\server_err.log"""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppParameters ""--config """"{code:GetDataDir}\server_config.json"""""""; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Server"; Flags: runhidden
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Server"; Flags: runhidden
|
||||||
|
Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Server confirm"; Flags: runhidden
|
||||||
|
|
||||||
|
[UninstallDelete]
|
||||||
|
Type: filesandordirs; Name: "{app}\certs"
|
||||||
|
Type: files; Name: "{app}\server_config.json"
|
||||||
|
Type: files; Name: "{app}\logar_state.db"
|
||||||
|
Type: files; Name: "{app}\logar_state.db-*"
|
||||||
|
Type: files; Name: "{app}\*.log"
|
||||||
|
Type: filesandordirs; Name: "{app}\runtime"
|
||||||
|
Type: filesandordirs; Name: "{app}"
|
||||||
|
Type: filesandordirs; Name: "{commonappdata}\LOGAR-Server"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var
|
||||||
|
DataDirPage: TInputDirWizardPage;
|
||||||
|
|
||||||
|
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);
|
||||||
|
begin
|
||||||
|
if CurStep = ssPostInstall then
|
||||||
|
begin
|
||||||
|
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', GetDataDir(''));
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
var
|
||||||
|
CustomDataDir: String;
|
||||||
|
begin
|
||||||
|
if CurUninstallStep = usPostUninstall then
|
||||||
|
begin
|
||||||
|
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', CustomDataDir) then
|
||||||
|
begin
|
||||||
|
if (CustomDataDir <> '') and DirExists(CustomDataDir) then
|
||||||
|
DelTree(CustomDataDir, True, True, True);
|
||||||
|
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server');
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
!include "MUI2.nsh"
|
||||||
|
!include "LogicLib.nsh"
|
||||||
|
|
||||||
|
Name "LOGAR Server"
|
||||||
|
OutFile "../dist/LOGAR-Server-Setup.exe"
|
||||||
|
InstallDir "$PROGRAMFILES64\LOGAR-Server"
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
|
||||||
|
BrandingText "LOGAR Enterprise SIEM & Log Ingestion"
|
||||||
|
|
||||||
|
VIProductVersion "0.3.1.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.1.0"
|
||||||
|
VIAddVersionKey "ProductVersion" "0.3.1.0"
|
||||||
|
|
||||||
|
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
|
||||||
|
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}
|
||||||
|
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Server'
|
||||||
|
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Server confirm'
|
||||||
|
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"
|
||||||
|
SectionEnd
|
||||||
Binary file not shown.
+386
-29
@@ -12,6 +12,7 @@ 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)
|
shutil.rmtree(DIST_DIR)
|
||||||
@@ -20,6 +21,22 @@ def clean_and_prep():
|
|||||||
shutil.rmtree(BUILD_TEMP)
|
shutil.rmtree(BUILD_TEMP)
|
||||||
os.makedirs(BUILD_TEMP, exist_ok=True)
|
os.makedirs(BUILD_TEMP, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
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 +55,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 +74,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 +85,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 +393,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 +440,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 +487,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 +527,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 +558,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"))
|
||||||
|
|||||||
+49
-59
@@ -1,40 +1,59 @@
|
|||||||
# LOGAR Linux Edge Forwarder
|
# LOGAR Linux Edge Forwarder
|
||||||
|
|
||||||
Standalone compiled binary distribution for Linux edge servers running systemd.
|
Standalone compiled binary and automated systemd service distribution for Linux edge servers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, encrypts the payload using OpenPGP, and streams candidate events over an authenticated TCP socket to the central LOGAR hub.
|
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, auto-enrolls with the central LOGAR hub, and streams candidate events over mutual TLS 1.3 (**mTLS**) to the central hub.
|
||||||
|
|
||||||
### Key Capabilities
|
### Key Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone executable binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p info`). Drops debug noise (priority 7) and skips events older than 24 hours.
|
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with machine-bound client certificates.
|
||||||
|
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `/etc/logar/certs/`, and establishes secure mTLS streaming.
|
||||||
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
- **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.
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||||
|
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
## 1. Automated Installation via Script (Recommended)
|
||||||
|
|
||||||
|
Run the automated installer script:
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||||
|
```
|
||||||
|
This script:
|
||||||
|
1. Installs the binary to `/opt/logar-client/Linux_Client`.
|
||||||
|
2. Creates `/etc/logar/certs` with strict permissions.
|
||||||
|
3. Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||||
|
4. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-client.service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generating & Deploying the Configuration File
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
### Step 1: Generate `client_config.json` on the Server
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
Run the following command on your central LOGAR server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||||
- Default TCP port is `9443`.
|
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
### Step 2: Configuration Structure
|
||||||
The generated `client_config.json` contains:
|
The generated `client_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_host": "192.168.1.100",
|
"server_host": "192.168.1.100",
|
||||||
"server_port": 9443,
|
"server_port": 9443,
|
||||||
|
"hermes_host": "192.168.1.100",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||||
|
"cert_dir": "certs",
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
"auth_token": "a1b2c3d4e5f6..."
|
||||||
@@ -42,11 +61,10 @@ The generated `client_config.json` contains:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||||
|
|
||||||
### Step 3: Copy to Edge Node
|
### Step 3: Copy to Edge Node
|
||||||
Place `Linux_Client.bin` and `client_config.json` into the target directory (recommended: `/opt/logar/`):
|
Place `Linux_Client.bin` and `client_config.json` into the target directory (e.g. `/opt/logar/`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/logar
|
sudo mkdir -p /opt/logar
|
||||||
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
||||||
@@ -55,14 +73,14 @@ sudo chmod +x /opt/logar/Linux_Client.bin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Running Manually
|
## 3. Running Manually
|
||||||
|
|
||||||
Test the forwarder interactively:
|
Test the forwarder interactively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/logar
|
cd /opt/logar
|
||||||
./Linux_Client.bin --hours 24
|
./Linux_Client.bin --hours 24
|
||||||
```
|
```
|
||||||
|
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||||
|
|
||||||
### Command-Line Arguments
|
### Command-Line Arguments
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
@@ -74,13 +92,10 @@ cd /opt/logar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Installing as a Systemd Service & Timer (Recommended)
|
## 4. Manual Systemd Service & Timer Setup
|
||||||
|
|
||||||
Running `Linux_Client.bin` via a systemd timer ensures reliable periodic execution, automatic restart, and native log integration with `journalctl`.
|
|
||||||
|
|
||||||
### Step 1: Create the Systemd Service Unit
|
### Step 1: Create the Systemd Service Unit
|
||||||
Create `/etc/systemd/system/logar-forwarder.service`:
|
Create `/etc/systemd/system/logar-client.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LOGAR Edge Log Forwarder
|
Description=LOGAR Edge Log Forwarder
|
||||||
@@ -88,9 +103,11 @@ After=network-online.target
|
|||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=simple
|
||||||
WorkingDirectory=/opt/logar
|
WorkingDirectory=/opt/logar
|
||||||
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
User=root
|
User=root
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
@@ -99,51 +116,24 @@ StandardError=journal
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Create the Systemd Timer Unit
|
### Step 2: Enable and Start the Service
|
||||||
Create `/etc/systemd/system/logar-forwarder.timer` to execute the forwarder every 3 hours (with a 5-minute initial delay upon boot):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Run LOGAR Edge Forwarder periodically
|
|
||||||
Requires=logar-forwarder.service
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=5min
|
|
||||||
OnUnitActiveSec=3h
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable and Start the Timer
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now logar-forwarder.timer
|
sudo systemctl enable --now logar-client.service
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Verify Timer & Service Status
|
### Step 3: Check Logs
|
||||||
```bash
|
```bash
|
||||||
# Check timer schedule
|
sudo journalctl -u logar-client.service -n 50 -f
|
||||||
sudo systemctl list-timers --all | grep logar
|
|
||||||
|
|
||||||
# Trigger an immediate manual execution
|
|
||||||
sudo systemctl start logar-forwarder.service
|
|
||||||
|
|
||||||
# View execution logs
|
|
||||||
sudo journalctl -u logar-forwarder.service -n 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Alternative: Cron Job Deployment
|
## 5. Uninstallation & Removal
|
||||||
|
|
||||||
If systemd timers are not preferred, configure a periodic cron job running every 3 hours:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Open root crontab
|
sudo systemctl disable --now logar-client.service
|
||||||
sudo crontab -e
|
sudo rm -f /etc/systemd/system/logar-client.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
# Add the following entry:
|
sudo rm -rf /opt/logar-client /opt/logar /etc/logar
|
||||||
0 */3 * * * cd /opt/logar && ./Linux_Client.bin --hours 24 >> /var/log/logar_forwarder.log 2>&1
|
|
||||||
```
|
```
|
||||||
|
|||||||
+91
-139
@@ -1,31 +1,47 @@
|
|||||||
# LOGAR Linux Server Hub
|
# LOGAR Linux Server Hub
|
||||||
|
|
||||||
Standalone compiled executable binary distribution for Linux server environments (`Server.bin`).
|
Standalone compiled binary and automated systemd service distribution for Linux server environments (`Server.bin`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`Server.bin` is a self-contained, pre-compiled Linux ELF executable that operates as the central coordination and log analysis hub of the LOGAR telemetry architecture.
|
`Server.bin` is a self-contained, pre-compiled Linux ELF executable that operates as the central coordination, log analysis, dynamic PKI, and reporting hub of the LOGAR telemetry architecture.
|
||||||
|
|
||||||
### Key Architecture & Capabilities
|
### Key Architecture & Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
|
||||||
- **Authenticated TCP Ingestion Socket (Port 9443)**: Accepts framed OpenPGP encrypted log batches streamed by edge forwarders (`Linux_Client.bin` and `Win_Client.exe`).
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a sliding 12-hour evaluation window before promotion from transient noise to `VERIFIED`.
|
- **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`.
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external scrapers, SIEM collectors, and alerting dashboards.
|
- **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).
|
||||||
- **Pure-Python OpenPGP Cryptography**: Zero dependency on external `gpg` binaries. Automatically generates RSA-2048 encryption keys and SHA-256 fingerprints on first launch.
|
- **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.
|
||||||
- **State Database**: Tracks anomaly lifecycles, run counters, and machine telemetry in a local SQLite state database (`logar_state.db`).
|
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||||
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Initializing & Generating Server Configuration
|
## 1. Automated Installation via Script (Recommended)
|
||||||
|
|
||||||
|
Deploy using the automated installer:
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_server.sh
|
||||||
|
```
|
||||||
|
This script:
|
||||||
|
1. Installs the server binary to `/opt/logar-server/Server`.
|
||||||
|
2. Creates `/etc/logar` and `/var/log/logar`.
|
||||||
|
3. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-server.service` with `LimitNOFILE=65536`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initializing & Generating Server Configuration
|
||||||
|
|
||||||
### Step 1: Automatic First-Run Generation
|
### Step 1: Automatic First-Run Generation
|
||||||
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
|
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
|
||||||
1. A fresh OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
|
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||||
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||||
3. A cryptographically random secret authentication token (`auth_token`).
|
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||||
4. Default network socket coordinates (TCP 9443, Hermes API 8443).
|
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||||
|
5. Default network socket coordinates (mTLS 9443, Hermes API 8443).
|
||||||
|
|
||||||
Run `Server.bin` once to initialize:
|
Run `Server.bin` once to initialize:
|
||||||
```bash
|
```bash
|
||||||
@@ -37,11 +53,20 @@ Output:
|
|||||||
[+] Successfully generated new server config and OpenPGP keypair.
|
[+] Successfully generated new server config and OpenPGP keypair.
|
||||||
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
[+] Saved to: server_config.json
|
[+] Saved to: server_config.json
|
||||||
|
============================================================
|
||||||
|
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||||
|
Transport Security: mTLS (TLS 1.3)
|
||||||
|
License Quota: 10 Active Seats
|
||||||
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
|
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||||
|
============================================================
|
||||||
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
|
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Configuration Fields Reference
|
### Step 2: Configuration Fields Reference
|
||||||
The generated `server_config.json` contains:
|
The generated `server_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_name": "LOGAR-Linux-Hub",
|
"server_name": "LOGAR-Linux-Hub",
|
||||||
@@ -50,159 +75,86 @@ The generated `server_config.json` contains:
|
|||||||
"hermes_host": "0.0.0.0",
|
"hermes_host": "0.0.0.0",
|
||||||
"hermes_port": 8443,
|
"hermes_port": 8443,
|
||||||
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
||||||
|
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||||
|
"max_seats": 10,
|
||||||
|
"cert_dir": "certs",
|
||||||
|
"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..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
---
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `server_name` | `"LOGAR-Linux-Hub"` | Human-readable identifier for this hub instance |
|
## 3. Exporting Client Configurations
|
||||||
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for edge client TCP ingestion |
|
|
||||||
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
|
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||||
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
|
```bash
|
||||||
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
|
./Server.bin --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||||
| `auth_token` | *(auto-generated)* | Pre-shared secret required in edge client envelopes |
|
```
|
||||||
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
|
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||||
| `evaluation_window_hours` | `12` | Sliding temporal window for warning persistence |
|
|
||||||
| `min_persistence_runs` | `4` | Number of distinct runs required to promote warnings to `VERIFIED` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Generating Client Configuration Bundles
|
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||||
|
|
||||||
Edge forwarders (`Linux_Client.bin` and `Win_Client.exe`) require a minimal, anonymous configuration bundle containing socket coordinates, the authentication token, and the server's public key (without sensitive server names or private keys).
|
### Accessing the WebUI
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://<SERVER_IP>:8443/webui
|
||||||
|
```
|
||||||
|
The WebUI provides:
|
||||||
|
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||||
|
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||||
|
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||||
|
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||||
|
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||||
|
|
||||||
Run the following command on the server:
|
### CLI Licensing Utilities
|
||||||
|
Export a `.lic` request file via CLI:
|
||||||
```bash
|
```bash
|
||||||
./Server.bin --create-client-config --server-host <SERVER_PUBLIC_OR_INTERNAL_IP> --server-port 9443 --client-out client_config.json
|
./Server.bin --export-lic server.lic
|
||||||
|
```
|
||||||
|
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||||
|
```bash
|
||||||
|
./Server.bin --import-lic activated.lic
|
||||||
|
# Or alternatively with --import-ca:
|
||||||
|
./Server.bin --import-ca ca.crt
|
||||||
|
```
|
||||||
|
Synchronize license and check for seat quota increases against the master authority:
|
||||||
|
```bash
|
||||||
|
./Server.bin --sync-license
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_PUBLIC_OR_INTERNAL_IP>` with the reachable IP or FQDN of your LOGAR server.
|
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||||
- The output `client_config.json` can be distributed directly to Linux and Windows edge forwarder nodes.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Running Interactively
|
## 5. Manual Systemd Service Management
|
||||||
|
|
||||||
|
Check service status:
|
||||||
```bash
|
```bash
|
||||||
./Server.bin --config /path/to/server_config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command-Line Arguments
|
|
||||||
| Argument | Description |
|
|
||||||
| :--- | :--- |
|
|
||||||
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
|
|
||||||
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
|
|
||||||
| `--server-host` | Hostname/IP to embed in the exported client configuration |
|
|
||||||
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
|
|
||||||
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Installing as a Systemd Service (Recommended)
|
|
||||||
|
|
||||||
Running `Server.bin` as a native systemd background service ensures continuous execution, automatic restart upon reboot or crash, and centralized log management via `journalctl`.
|
|
||||||
|
|
||||||
### Step 1: Create Deployment Directory and User
|
|
||||||
```bash
|
|
||||||
# Create dedicated system group and user
|
|
||||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin logar
|
|
||||||
|
|
||||||
# Prepare deployment folder
|
|
||||||
sudo mkdir -p /opt/logar-server
|
|
||||||
sudo cp Server.bin server_config.json /opt/logar-server/
|
|
||||||
sudo chmod +x /opt/logar-server/Server.bin
|
|
||||||
sudo chown -R logar:logar /opt/logar-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create Systemd Service File
|
|
||||||
Create `/etc/systemd/system/logar-server.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=LOGAR Central Server Hub Service
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=logar
|
|
||||||
Group=logar
|
|
||||||
WorkingDirectory=/opt/logar-server
|
|
||||||
ExecStart=/opt/logar-server/Server.bin --config /opt/logar-server/server_config.json
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
LimitNOFILE=65536
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable and Start Service
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now logar-server.service
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Status and Inspect Logs
|
|
||||||
```bash
|
|
||||||
# Check service status
|
|
||||||
sudo systemctl status logar-server.service
|
sudo systemctl status logar-server.service
|
||||||
|
```
|
||||||
|
|
||||||
# Stream live server logs
|
Inspect live service logs:
|
||||||
sudo journalctl -u logar-server.service -f
|
```bash
|
||||||
|
sudo journalctl -u logar-server.service -f -n 50
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Hermes Reporting API & Integration
|
## 6. Uninstallation & Removal
|
||||||
|
|
||||||
The server embeds a high-performance HTTP service on port `8443` providing real-time intelligence on promoted anomalies:
|
|
||||||
|
|
||||||
### Fetching Promoted Anomalies
|
|
||||||
```bash
|
|
||||||
curl -s http://127.0.0.1:8443/api/hermes/report | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Schema:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "prod-web-01.corp.internal:Out_Of_Memory",
|
|
||||||
"server": "prod-web-01.corp.internal",
|
|
||||||
"signature": "Out_Of_Memory",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"first_seen": "2026-09-04T08:00:00Z",
|
|
||||||
"last_seen": "2026-09-04T14:30:00Z",
|
|
||||||
"status": "VERIFIED",
|
|
||||||
"verified": true,
|
|
||||||
"os_type": "linux",
|
|
||||||
"sample_message": "kernel: Out of memory: Kill process 1824"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Firewall Configuration
|
|
||||||
|
|
||||||
Ensure the following inbound ports are open on your host firewall:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# UFW (Ubuntu / Debian)
|
sudo systemctl disable --now logar-server.service
|
||||||
sudo ufw allow 9443/tcp comment "LOGAR TCP Log Ingestion"
|
sudo rm -f /etc/systemd/system/logar-server.service
|
||||||
sudo ufw allow 8443/tcp comment "LOGAR Hermes Reporting API"
|
sudo systemctl daemon-reload
|
||||||
sudo ufw reload
|
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
||||||
|
|
||||||
# Firewalld (RHEL / CentOS / Rocky / Alma)
|
|
||||||
sudo firewall-cmd --permanent --add-port=9443/tcp
|
|
||||||
sudo firewall-cmd --permanent --add-port=8443/tcp
|
|
||||||
sudo firewall-cmd --reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+52
-45
@@ -1,40 +1,59 @@
|
|||||||
# LOGAR Windows Edge Forwarder
|
# LOGAR Windows Edge Forwarder
|
||||||
|
|
||||||
Standalone compiled executable distribution for Windows Server and workstation environments.
|
Standalone compiled executable and installer distribution for Windows Server and workstation environments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
`Win_Client.exe` is a self-contained, pre-compiled executable that queries the Windows Application Event Log, filters candidate events at the source, encrypts the payload using OpenPGP, and streams records over an authenticated TCP socket to the central LOGAR hub.
|
`Win_Client.exe` is a self-contained executable that queries the Windows Application Event Log, filters candidate events at the source, auto-enrolls with the central LOGAR hub to receive signed mTLS certificates, and streams records over mutual TLS 1.3 (**mTLS**) socket connection.
|
||||||
|
|
||||||
### Key Capabilities
|
### Key Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`) or full installer (`LOGAR-Client-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit success/failure events and debug noise, skipping events older than 24 hours.
|
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with hardware/machine-bound client certificates.
|
||||||
|
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `certs/`, and establishes secure mTLS streaming.
|
||||||
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
- **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.
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
- **Dynamic Seat Expansion & Automatic Hub Synchronization**: The forwarder benefits from the hub's dynamic seat quota expansion; as the hub's license seats are increased and synced with the master authority (`logar.eibl.tech`), edge capacity expands automatically without requiring forwarder configuration changes.
|
||||||
|
- **Master Authority High Availability (Portainer GitOps)**: The authoritative license server at `logar.eibl.tech` runs containerized via Portainer GitOps with automated pipeline builds, ensuring high availability for client seat verification and renewals.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
## 1. Automated Installation via Inno Setup (Recommended)
|
||||||
|
|
||||||
|
Run the self-contained installer built from `compilation/installer_client.iss`:
|
||||||
|
```powershell
|
||||||
|
.\LOGAR-Client-Setup.exe
|
||||||
|
```
|
||||||
|
This installer:
|
||||||
|
1. Installs `Win_Client.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR\`.
|
||||||
|
2. Sets up directory permissions in `C:\ProgramData\LOGAR\`.
|
||||||
|
3. Registers and starts the `LOGAR_Client` Windows service automatically via NSSM.
|
||||||
|
4. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR\client.log` and `client_err.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generating & Deploying the Configuration File
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
### Step 1: Generate `client_config.json` on the Server
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
Run the following command on your central LOGAR server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||||
- Default TCP port is `9443`.
|
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
### Step 2: Configuration Structure
|
||||||
The generated `client_config.json` contains:
|
The generated `client_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_host": "192.168.1.100",
|
"server_host": "192.168.1.100",
|
||||||
"server_port": 9443,
|
"server_port": 9443,
|
||||||
|
"hermes_host": "192.168.1.100",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||||
|
"cert_dir": "certs",
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
"auth_token": "a1b2c3d4e5f6..."
|
||||||
@@ -42,11 +61,10 @@ The generated `client_config.json` contains:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||||
|
|
||||||
### Step 3: Copy to Edge Node
|
### Step 3: Copy to Edge Node
|
||||||
Place `Win_Client.exe` and `client_config.json` in the target directory (recommended: `C:\LOGAR\`):
|
Place `client_config.json` next to `Win_Client.exe` (e.g. `C:\Program Files\LOGAR\` or `C:\LOGAR\`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
||||||
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
||||||
@@ -54,14 +72,14 @@ Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Running Manually
|
## 3. Running Manually
|
||||||
|
|
||||||
Test the forwarder interactively from PowerShell or Command Prompt:
|
Test the forwarder interactively from PowerShell or Command Prompt:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd C:\LOGAR
|
cd C:\LOGAR
|
||||||
.\Win_Client.exe --hours 24
|
.\Win_Client.exe --hours 24
|
||||||
```
|
```
|
||||||
|
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||||
|
|
||||||
### Command-Line Arguments
|
### Command-Line Arguments
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
@@ -73,22 +91,24 @@ cd C:\LOGAR
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Installing as a Background Service / Scheduled Task
|
## 4. Manual Service Installation (NSSM or Scheduled Task)
|
||||||
|
|
||||||
Edge forwarders run as episodic background processes (run, forward unsent candidate records, commit state, and terminate). On Windows, this is natively managed via Windows Task Scheduler running as a background service under `SYSTEM`.
|
|
||||||
|
|
||||||
### Method A: Windows Scheduled Task via PowerShell (Recommended)
|
|
||||||
Open an **Elevated PowerShell (Run as Administrator)** window and execute:
|
|
||||||
|
|
||||||
|
### Method A: Windows Service via Bundled NSSM
|
||||||
|
```powershell
|
||||||
|
# From the compilation directory or with bundled nssm.exe:
|
||||||
|
.\nssm.exe install LOGAR_Client "C:\LOGAR\Win_Client.exe" "--hours 24"
|
||||||
|
.\nssm.exe set LOGAR_Client AppDirectory "C:\LOGAR"
|
||||||
|
.\nssm.exe set LOGAR_Client AppStdout "C:\ProgramData\LOGAR\client.log"
|
||||||
|
.\nssm.exe set LOGAR_Client AppStderr "C:\ProgramData\LOGAR\client_err.log"
|
||||||
|
.\nssm.exe start LOGAR_Client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method B: Windows Scheduled Task via PowerShell
|
||||||
```powershell
|
```powershell
|
||||||
# Define action and periodic trigger (every 3 hours indefinitely)
|
|
||||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
||||||
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
||||||
|
|
||||||
# Configure task settings (wake on sleep, start when ready, run hidden)
|
|
||||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
||||||
|
|
||||||
# Register task running under the local SYSTEM account with highest privileges
|
|
||||||
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
||||||
-Action $Action `
|
-Action $Action `
|
||||||
-Trigger $Trigger `
|
-Trigger $Trigger `
|
||||||
@@ -97,31 +117,18 @@ Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
|||||||
-RunLevel Highest `
|
-RunLevel Highest `
|
||||||
-Description "LOGAR Windows Edge Log Forwarder Service"
|
-Description "LOGAR Windows Edge Log Forwarder Service"
|
||||||
|
|
||||||
# Verify task creation and trigger immediate execution
|
|
||||||
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
||||||
Get-ScheduledTask -TaskName "LOGAR_Forwarder"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method B: Continuous Windows Service via NSSM
|
|
||||||
If your organizational policy requires a formal Windows Service listed under `services.msc`:
|
|
||||||
|
|
||||||
1. Download [NSSM (Non-Sucking Service Manager)](https://nssm.cc/).
|
|
||||||
2. Install the service using NSSM:
|
|
||||||
```cmd
|
|
||||||
nssm.exe install LOGAR_Forwarder "C:\LOGAR\Win_Client.exe" "--hours 24"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppDirectory "C:\LOGAR"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppRestartDelay 10800000
|
|
||||||
nssm.exe start LOGAR_Forwarder
|
|
||||||
```
|
|
||||||
*(Note: `AppRestartDelay 10800000` pauses 3 hours between execution cycles).*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Uninstallation & Removal
|
## 5. Uninstallation
|
||||||
|
|
||||||
To remove the scheduled task:
|
If installed via the Inno Setup installer, use **Windows Add/Remove Programs** or run `unins000.exe` in `C:\Program Files\LOGAR\`.
|
||||||
|
|
||||||
|
If installed manually via NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
Unregister-ScheduledTask -TaskName "LOGAR_Forwarder" -Confirm:$false
|
.\nssm.exe stop LOGAR_Client
|
||||||
|
.\nssm.exe remove LOGAR_Client confirm
|
||||||
Remove-Item -Recurse -Force "C:\LOGAR"
|
Remove-Item -Recurse -Force "C:\LOGAR"
|
||||||
```
|
```
|
||||||
|
|||||||
+91
-169
@@ -1,31 +1,47 @@
|
|||||||
# LOGAR Windows Server Hub
|
# LOGAR Windows Server Hub
|
||||||
|
|
||||||
Standalone compiled executable distribution for Windows Server environments (`Server.exe`).
|
Standalone compiled executable and installer distribution for Windows Server environments (`Server.exe`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`Server.exe` is a self-contained, pre-compiled native Windows PE executable that serves as the central log aggregation, temporal persistence analyzer, and reporting hub of the LOGAR infrastructure.
|
`Server.exe` is a self-contained, pre-compiled native Windows PE executable that serves as the central log aggregation, temporal persistence analyzer, dynamic PKI certificate authority, and reporting hub of the LOGAR infrastructure.
|
||||||
|
|
||||||
### Key Architecture & Capabilities
|
### Key Architecture & Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
||||||
- **Authenticated TCP Ingestion Socket (Port 9443)**: Ingests framed OpenPGP encrypted log batches streamed from edge forwarder nodes (`Win_Client.exe` and `Linux_Client.bin`).
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Subordinate CA and verifies active licensing in SQLite.
|
||||||
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window before promotion to `VERIFIED`.
|
- **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`.
|
||||||
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external dashboards, monitoring agents, and scrapers.
|
- **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).
|
||||||
- **Pure-Python OpenPGP Cryptography**: Automatically generates RSA-2048 encryption keys and a SHA-256 fingerprint on first launch without external dependencies.
|
- **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.
|
||||||
- **State Database**: Stores issue lifecycle records, run counters, and machine telemetry in a local SQLite database (`logar_state.db`).
|
- **Direct Hermes Intelligence & Temporal Telemetry**: All ingested actionable anomalies (`WARNING`, `ERROR`, `INFO`) are immediately promoted to `VERIFIED` and surfaced directly to Hermes with rich temporal metadata (run counts, timestamps) across a rolling 12-hour evaluation window, allowing Hermes AI to perform all contextual reasoning and decisions.
|
||||||
|
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Initializing & Generating Server Configuration
|
## 1. Automated Installation via Inno Setup (Recommended)
|
||||||
|
|
||||||
|
Run the self-contained installer built from `compilation/installer_server.iss`:
|
||||||
|
```powershell
|
||||||
|
.\LOGAR-Server-Setup.exe
|
||||||
|
```
|
||||||
|
This installer:
|
||||||
|
1. Installs `Server.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR-Server\`.
|
||||||
|
2. Registers and starts the `LOGAR_Server` Windows service automatically via NSSM.
|
||||||
|
3. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR-Server\server.log` and `server_err.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initializing & Generating Server Configuration
|
||||||
|
|
||||||
### Step 1: Automatic First-Run Generation
|
### Step 1: Automatic First-Run Generation
|
||||||
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
|
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
|
||||||
1. An OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
|
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||||
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||||
3. A cryptographically random secret authentication token (`auth_token`).
|
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||||
4. Default network socket coordinates (TCP 9443, Hermes API 8443).
|
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||||
|
5. Default network socket coordinates (mTLS 9443, Hermes API 8443).
|
||||||
|
|
||||||
Open PowerShell and run:
|
Open PowerShell and run:
|
||||||
```powershell
|
```powershell
|
||||||
@@ -37,11 +53,20 @@ Output:
|
|||||||
[+] Successfully generated new server config and OpenPGP keypair.
|
[+] Successfully generated new server config and OpenPGP keypair.
|
||||||
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
[+] Saved to: server_config.json
|
[+] Saved to: server_config.json
|
||||||
|
============================================================
|
||||||
|
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||||
|
Transport Security: mTLS (TLS 1.3)
|
||||||
|
License Quota: 10 Active Seats
|
||||||
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
|
Evaluation Window: 12 hours | Direct Hermes Reasoning Enabled
|
||||||
|
============================================================
|
||||||
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
|
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Configuration Fields Reference
|
### Step 2: Configuration Fields Reference
|
||||||
The generated `server_config.json` contains:
|
The generated `server_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_name": "LOGAR-Windows-Hub",
|
"server_name": "LOGAR-Windows-Hub",
|
||||||
@@ -50,188 +75,85 @@ The generated `server_config.json` contains:
|
|||||||
"hermes_host": "0.0.0.0",
|
"hermes_host": "0.0.0.0",
|
||||||
"hermes_port": 8443,
|
"hermes_port": 8443,
|
||||||
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
|
||||||
|
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||||
|
"max_seats": 10,
|
||||||
|
"cert_dir": "certs",
|
||||||
|
"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..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Default | Description |
|
---
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| `server_name` | `"LOGAR-Windows-Hub"` | Identifier for this hub instance |
|
## 3. Exporting Client Configurations
|
||||||
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for incoming client socket traffic |
|
|
||||||
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
|
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||||
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
|
```powershell
|
||||||
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
|
.\Server.exe --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||||
| `auth_token` | *(auto-generated)* | Pre-shared authentication secret required in client envelopes |
|
```
|
||||||
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
|
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||||
| `evaluation_window_hours` | `12` | Rolling evaluation window in hours for warning persistence |
|
|
||||||
| `min_persistence_runs` | `4` | Consecutive runs required to promote warning issues to `VERIFIED` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Generating Client Configuration Bundles
|
## 4. Fleet Licensing, .lic Files & WebUI Dashboard
|
||||||
|
|
||||||
Edge forwarders (`Win_Client.exe` and `Linux_Client.bin`) require an anonymous client configuration bundle that includes the server socket target, authentication token, and encryption public key, without exposing sensitive server names or private keys.
|
### Accessing the WebUI
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:8443/webui
|
||||||
|
```
|
||||||
|
The WebUI provides:
|
||||||
|
1. **Real-time Seat Quota Meter**: Visual meter displaying active clients against approved seat quota.
|
||||||
|
2. **Export .lic Request**: 1-click download of `logar_server.lic` containing your Subordinate CA CSR and machine fingerprint for `logar.eibl.tech`.
|
||||||
|
3. **Import License (.lic / ca.crt)**: Modal to paste or upload the activated `.lic` package or `ca.crt` issued by `logar.eibl.tech`. Activating updates your seat limit immediately and reloads mTLS context in-flight.
|
||||||
|
4. **Sync & Renew via mTLS**: Button to test or trigger immediate mTLS renewal against `logar.eibl.tech` and pull seat quota increases without restart.
|
||||||
|
5. **Client Seat Management**: View all enrolled nodes; click **Revoke Seat** to immediately drop and block an edge node at the socket layer, or **Reactivate** to restore access.
|
||||||
|
|
||||||
Run the following command on the server:
|
### CLI Licensing Utilities
|
||||||
|
Export a `.lic` request file via CLI:
|
||||||
```powershell
|
```powershell
|
||||||
.\Server.exe --create-client-config --server-host <SERVER_IP_OR_FQDN> --server-port 9443 --client-out client_config.json
|
.\Server.exe --export-lic C:\LOGAR-Server\server.lic
|
||||||
|
```
|
||||||
|
Import an activated `.lic` license package or signed CA certificate via CLI:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --import-lic C:\LOGAR-Server\activated.lic
|
||||||
|
# Or alternatively with --import-ca:
|
||||||
|
.\Server.exe --import-ca C:\LOGAR-Server\ca.crt
|
||||||
|
```
|
||||||
|
Synchronize license and check for seat quota increases against the master authority:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --sync-license
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_FQDN>` with the reachable IP or DNS name of your LOGAR server.
|
### Upstream Authority Deployment (Portainer GitOps & Reverse Proxy)
|
||||||
- Distribute `client_config.json` to client forwarder nodes along with `Win_Client.exe` or `Linux_Client.bin`.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Running Interactively
|
## 5. Manual Windows Service Setup (via NSSM)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\Server.exe --config C:\LOGAR-Server\server_config.json
|
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
||||||
```
|
.\nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
||||||
|
.\nssm.exe set LOGAR_Server AppStdout "C:\ProgramData\LOGAR-Server\server.log"
|
||||||
### Command-Line Arguments
|
.\nssm.exe set LOGAR_Server AppStderr "C:\ProgramData\LOGAR-Server\server_err.log"
|
||||||
| Argument | Description |
|
.\nssm.exe start LOGAR_Server
|
||||||
| :--- | :--- |
|
|
||||||
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
|
|
||||||
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
|
|
||||||
| `--server-host` | Hostname/IP to embed in the exported client configuration |
|
|
||||||
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
|
|
||||||
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Installing as a Continuous Windows Service
|
|
||||||
|
|
||||||
Because `Server.exe` acts as a continuous server hub (listening for TCP connections and HTTP API queries), it should run persistently in the background.
|
|
||||||
|
|
||||||
### Method A: Native Windows Service via NSSM (Recommended)
|
|
||||||
[NSSM (Non-Sucking Service Manager)](https://nssm.cc/) is the industry standard for wrapping standalone executables into formal Windows services managed by `services.msc`.
|
|
||||||
|
|
||||||
1. Place `Server.exe` and `server_config.json` in `C:\LOGAR-Server\`.
|
|
||||||
2. Open **Elevated PowerShell (Run as Administrator)**:
|
|
||||||
```powershell
|
|
||||||
# Create deployment folder
|
|
||||||
New-Item -ItemType Directory -Path "C:\LOGAR-Server" -Force
|
|
||||||
Copy-Item "Server.exe", "server_config.json" -Destination "C:\LOGAR-Server\"
|
|
||||||
|
|
||||||
# Install Windows Service via NSSM
|
|
||||||
nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe" "--config C:\LOGAR-Server\server_config.json"
|
|
||||||
nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
|
||||||
nssm.exe set LOGAR_Server Description "LOGAR Central Aggregation Hub Service"
|
|
||||||
nssm.exe set LOGAR_Server Start SERVICE_AUTO_START
|
|
||||||
nssm.exe set LOGAR_Server AppStdout "C:\LOGAR-Server\server_out.log"
|
|
||||||
nssm.exe set LOGAR_Server AppStderr "C:\LOGAR-Server\server_err.log"
|
|
||||||
|
|
||||||
# Start the service
|
|
||||||
nssm.exe start LOGAR_Server
|
|
||||||
```
|
|
||||||
3. Verify status in PowerShell:
|
|
||||||
```powershell
|
|
||||||
Get-Service -Name "LOGAR_Server"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method B: Windows Task Scheduler (Startup Daemon)
|
|
||||||
If third-party service wrappers are restricted by organizational policy, configure a Task Scheduler job triggered at boot under the `SYSTEM` account:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Action: Launch Server.exe
|
|
||||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR-Server\Server.exe" `
|
|
||||||
-Argument "--config C:\LOGAR-Server\server_config.json" `
|
|
||||||
-WorkingDirectory "C:\LOGAR-Server"
|
|
||||||
|
|
||||||
# Trigger: At system startup
|
|
||||||
$Trigger = New-ScheduledTaskTrigger -AtStartup
|
|
||||||
|
|
||||||
# Settings: Restart on failure, no execution time limit
|
|
||||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
|
||||||
-DontStopIfGoingOnBatteries `
|
|
||||||
-StartWhenAvailable `
|
|
||||||
-RestartCount 3 `
|
|
||||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
|
||||||
-ExecutionTimeLimit ([TimeSpan]::Zero)
|
|
||||||
|
|
||||||
# Register task under SYSTEM with highest privileges
|
|
||||||
Register-ScheduledTask -TaskName "LOGAR_Server_Daemon" `
|
|
||||||
-Action $Action `
|
|
||||||
-Trigger $Trigger `
|
|
||||||
-Settings $Settings `
|
|
||||||
-User "NT AUTHORITY\SYSTEM" `
|
|
||||||
-RunLevel Highest `
|
|
||||||
-Description "LOGAR Central Hub Daemon"
|
|
||||||
|
|
||||||
# Start the task immediately
|
|
||||||
Start-ScheduledTask -TaskName "LOGAR_Server_Daemon"
|
|
||||||
Get-ScheduledTask -TaskName "LOGAR_Server_Daemon"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Hermes Reporting API & Health Checks
|
## 6. Uninstallation
|
||||||
|
|
||||||
Test the embedded Hermes REST endpoint locally using PowerShell:
|
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
||||||
|
|
||||||
|
If installed manually via NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
$report = Invoke-RestMethod -Uri "http://127.0.0.1:8443/api/hermes/report" -Method GET
|
.\nssm.exe stop LOGAR_Server
|
||||||
$report | Format-Table fingerprint, status, consecutive_runs, first_seen, last_seen
|
.\nssm.exe remove LOGAR_Server confirm
|
||||||
```
|
```
|
||||||
|
|
||||||
### Response Format:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"fingerprint": "win-dc-01.corp.internal:DiskCorruptionDetected",
|
|
||||||
"server": "win-dc-01.corp.internal",
|
|
||||||
"signature": "DiskCorruptionDetected",
|
|
||||||
"consecutive_runs": 4,
|
|
||||||
"first_seen": "2026-09-04T08:15:00Z",
|
|
||||||
"last_seen": "2026-09-04T15:00:00Z",
|
|
||||||
"status": "VERIFIED",
|
|
||||||
"verified": true,
|
|
||||||
"os_type": "windows",
|
|
||||||
"sample_message": "An error was detected on device \\Device\\Harddisk0\\DR0 during a paging operation."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Windows Defender Firewall Configuration
|
|
||||||
|
|
||||||
Open the necessary inbound firewall ports to allow incoming edge forwarder socket streams and HTTP API queries:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Allow TCP 9443 for edge log forwarding
|
|
||||||
New-NetFirewallRule -DisplayName "LOGAR TCP Log Ingestion" `
|
|
||||||
-Direction Inbound `
|
|
||||||
-LocalPort 9443 `
|
|
||||||
-Protocol TCP `
|
|
||||||
-Action Allow
|
|
||||||
|
|
||||||
# Allow TCP 8443 for Hermes Reporting REST API
|
|
||||||
New-NetFirewallRule -DisplayName "LOGAR Hermes Reporting API" `
|
|
||||||
-Direction Inbound `
|
|
||||||
-LocalPort 8443 `
|
|
||||||
-Protocol TCP `
|
|
||||||
-Action Allow
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Uninstallation & Removal
|
|
||||||
|
|
||||||
To remove the server service:
|
|
||||||
```powershell
|
|
||||||
# If installed via NSSM:
|
|
||||||
nssm.exe stop LOGAR_Server
|
|
||||||
nssm.exe remove LOGAR_Server confirm
|
|
||||||
|
|
||||||
# If installed via Task Scheduler:
|
|
||||||
Unregister-ScheduledTask -TaskName "LOGAR_Server_Daemon" -Confirm:$false
|
|
||||||
|
|
||||||
# Clean files
|
|
||||||
Remove-Item -Recurse -Force "C:\LOGAR-Server"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
LOGAR Master License Authority Proxy
|
||||||
|
Delegates directly to the standalone submodule: LOGAR-LICENSING/License_Authority.py
|
||||||
|
Repo: https://gitea.eibl.tech/me0nline/LOGAR-LICENSING.git
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
SUBMODULE_FILE = os.path.join(ROOT_DIR, "LOGAR-LICENSING", "License_Authority.py")
|
||||||
|
|
||||||
|
if not os.path.exists(SUBMODULE_FILE):
|
||||||
|
raise ImportError(f"LOGAR-LICENSING submodule not found at {SUBMODULE_FILE}. Run 'git submodule update --init --recursive'.")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("_submodule_authority", SUBMODULE_FILE)
|
||||||
|
_sub = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(_sub)
|
||||||
|
|
||||||
|
# Export public symbols from submodule into this module's namespace
|
||||||
|
for attr in dir(_sub):
|
||||||
|
if not attr.startswith("__"):
|
||||||
|
globals()[attr] = getattr(_sub, attr)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_sub.main()
|
||||||
+168
-19
@@ -2,12 +2,14 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional
|
||||||
|
|
||||||
# Suppress cryptography / pgpy deprecation notices
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
@@ -18,6 +20,97 @@ 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:
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
if custom_state_path:
|
if custom_state_path:
|
||||||
return custom_state_path
|
return custom_state_path
|
||||||
@@ -227,39 +320,98 @@ def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list
|
|||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
Streams logs to the LOGAR hub.
|
||||||
over an authenticated TCP socket.
|
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_host = config["server_host"]
|
||||||
server_port = int(config["server_port"])
|
server_port = int(config["server_port"])
|
||||||
auth_token = config["auth_token"]
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
pub_key_armored = config["server_public_key"]
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
machine_id = get_machine_identifier()
|
||||||
|
|
||||||
# Load and verify server public key
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux", force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
if expected_fp and actual_fp != expected_fp:
|
if expected_fp and actual_fp != expected_fp:
|
||||||
raise ValueError(
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare batch
|
|
||||||
payload = {
|
payload = {
|
||||||
"server": machine_id,
|
"server": machine_id,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload)
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
encrypted_armored = str(encrypted_msg)
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -267,17 +419,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
|||||||
}
|
}
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
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:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
sock.settimeout(15.0)
|
sock.settimeout(15.0)
|
||||||
sock.connect((server_host, server_port))
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
sock.sendall(frame)
|
sock.sendall(frame)
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
resp_len_bytes = sock.recv(4)
|
||||||
if not resp_len_bytes:
|
if not resp_len_bytes:
|
||||||
raise ConnectionError("Server closed connection without response.")
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|||||||
+2708
-136
File diff suppressed because it is too large
Load Diff
+252
-22
@@ -2,11 +2,13 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import argparse
|
import argparse
|
||||||
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional
|
||||||
|
|
||||||
# Suppress cryptography / pgpy deprecation notices
|
# Suppress cryptography / pgpy deprecation notices
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
@@ -22,10 +24,171 @@ 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 = "windows",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
):
|
||||||
|
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||||
|
return True
|
||||||
|
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||||
|
|
||||||
|
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||||
|
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||||
|
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||||
|
payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os": os_type,
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
enroll_endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_key"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||||
|
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||||
|
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||||
|
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.check_hostname = False
|
||||||
|
|
||||||
|
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||||
|
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||||
|
|
||||||
|
|
||||||
|
def is_path_writable(filepath: str) -> bool:
|
||||||
|
"""Checks if a file or directory path is actually writable by the current process."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
if os.path.isdir(filepath):
|
||||||
|
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
with open(filepath, "a") as f:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
parent = os.path.dirname(os.path.abspath(filepath)) or "."
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
test_f = os.path.join(parent, f".perm_{os.getpid()}")
|
||||||
|
with open(test_f, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
os.remove(test_f)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_data_dir(app_name: str = "LOGAR") -> str:
|
||||||
|
"""Returns a suitable writable application data directory."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# 1. ProgramData (C:\ProgramData\LOGAR)
|
||||||
|
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
|
||||||
|
target_prog = os.path.join(prog_data, app_name)
|
||||||
|
if is_path_writable(target_prog):
|
||||||
|
return target_prog
|
||||||
|
|
||||||
|
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
|
||||||
|
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
|
||||||
|
target_local = os.path.join(local_app, app_name)
|
||||||
|
if is_path_writable(target_local):
|
||||||
|
return target_local
|
||||||
|
|
||||||
|
# 3. Temp directory (%TEMP%\LOGAR)
|
||||||
|
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
|
||||||
|
if is_path_writable(temp_dir):
|
||||||
|
return temp_dir
|
||||||
|
|
||||||
|
cwd = os.getcwd()
|
||||||
|
if is_path_writable(cwd):
|
||||||
|
return cwd
|
||||||
|
return os.path.expanduser("~")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
|
||||||
|
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
|
||||||
|
if not path:
|
||||||
|
path = default_filename
|
||||||
|
|
||||||
|
if os.path.isabs(path) and is_path_writable(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
|
||||||
|
return os.path.abspath(path)
|
||||||
|
|
||||||
|
data_dir = get_default_data_dir(app_name)
|
||||||
|
fname = os.path.basename(path)
|
||||||
|
return os.path.join(data_dir, fname)
|
||||||
|
|
||||||
|
|
||||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
if custom_state_path:
|
if custom_state_path:
|
||||||
return custom_state_path
|
return custom_state_path
|
||||||
config_dir = os.path.dirname(os.path.abspath(config_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)
|
return os.path.join(config_dir, STATE_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +205,7 @@ def load_state(state_path: str) -> dict:
|
|||||||
|
|
||||||
def save_state(state_path: str, state: dict):
|
def save_state(state_path: str, state: dict):
|
||||||
try:
|
try:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
|
||||||
temp_path = f"{state_path}.tmp"
|
temp_path = f"{state_path}.tmp"
|
||||||
with open(temp_path, "w", encoding="utf-8") as f:
|
with open(temp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
@@ -61,13 +225,23 @@ def commit_state(state: dict, state_path: str):
|
|||||||
|
|
||||||
|
|
||||||
def load_config(config_path: str = CONFIG_FILE_NAME):
|
def load_config(config_path: str = CONFIG_FILE_NAME):
|
||||||
if not os.path.exists(config_path):
|
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(
|
raise FileNotFoundError(
|
||||||
f"Client configuration file not found at: {config_path}\n"
|
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}"
|
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:
|
with open(actual_config_path, "r", encoding="utf-8") as f:
|
||||||
return json.load(f)
|
config = json.load(f)
|
||||||
|
|
||||||
|
if "cert_dir" in config:
|
||||||
|
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR", "certs")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def get_machine_identifier() -> str:
|
def get_machine_identifier() -> str:
|
||||||
@@ -194,39 +368,98 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li
|
|||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
Streams logs to the LOGAR hub.
|
||||||
over an authenticated TCP socket. Zero local state is maintained on the client.
|
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_host = config["server_host"]
|
||||||
server_port = int(config["server_port"])
|
server_port = int(config["server_port"])
|
||||||
auth_token = config["auth_token"]
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
pub_key_armored = config["server_public_key"]
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
machine_id = get_machine_identifier()
|
||||||
|
|
||||||
# Load and verify server public key
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
if hermes_host in ("0.0.0.0", "::", None):
|
||||||
|
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows", force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
if expected_fp and actual_fp != expected_fp:
|
if expected_fp and actual_fp != expected_fp:
|
||||||
raise ValueError(
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare zero-state candidate batch
|
|
||||||
payload = {
|
payload = {
|
||||||
"server": machine_id,
|
"server": machine_id,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload)
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
encrypted_armored = str(encrypted_msg)
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -234,17 +467,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
|||||||
}
|
}
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
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:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
sock.settimeout(15.0)
|
sock.settimeout(15.0)
|
||||||
sock.connect((server_host, server_port))
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
sock.sendall(frame)
|
sock.sendall(frame)
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
resp_len_bytes = sock.recv(4)
|
||||||
if not resp_len_bytes:
|
if not resp_len_bytes:
|
||||||
raise ConnectionError("Server closed connection without response.")
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
|
# Suppress cryptography / pgpy deprecation notices during test runs
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
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
|
||||||
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||||
|
if ROOT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, ROOT_DIR)
|
||||||
|
if SRC_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
|
import server_enrollment as enrollment
|
||||||
|
import Server
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
self.test_dir = os.path.join(ROOT_DIR, "test_lic_tmp")
|
||||||
|
os.makedirs(self.test_dir, exist_ok=True)
|
||||||
|
self.server_cert_dir = os.path.join(self.test_dir, "server_certs")
|
||||||
|
self.server_db = os.path.join(self.test_dir, "server_state.db")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.test_dir):
|
||||||
|
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_ca_csr_and_lic_request_generation(self):
|
||||||
|
"""Verifies subordinate CA CSR and .lic request file generation on the server."""
|
||||||
|
lic_data, lic_json = enrollment.create_license_request_lic(
|
||||||
|
cert_dir=self.server_cert_dir,
|
||||||
|
license_server="https://logar.eibl.tech",
|
||||||
|
server_fingerprint="AABBCCDDEEFF00112233",
|
||||||
|
hostname="test-hub.corp.internal",
|
||||||
|
requested_seats=30
|
||||||
|
)
|
||||||
|
self.assertEqual(lic_data["format"], "LOGAR_LICENSE_REQUEST")
|
||||||
|
self.assertEqual(lic_data["license_server"], "https://logar.eibl.tech")
|
||||||
|
self.assertEqual(lic_data["requested_seats"], 30)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE REQUEST", lic_data["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")))
|
||||||
|
|
||||||
|
def test_license_info_extraction_and_custom_oids(self):
|
||||||
|
"""Verifies extraction of embedded custom seat OIDs and license attributes from certificates."""
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
sub_pem, _ = _create_mock_signed_license_cert(csr_pem, seats=42, license_id="LIC-ABC-4242")
|
||||||
|
|
||||||
|
info = enrollment.extract_license_info_from_cert(sub_pem)
|
||||||
|
self.assertTrue(info["valid"])
|
||||||
|
self.assertEqual(info["seats"], 42)
|
||||||
|
self.assertEqual(info["license_id"], "LIC-ABC-4242")
|
||||||
|
self.assertEqual(info["issuer_url"], "https://logar.eibl.tech")
|
||||||
|
self.assertFalse(info["is_expired"])
|
||||||
|
self.assertTrue(info["is_ca"])
|
||||||
|
|
||||||
|
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."""
|
||||||
|
# 1. Generate CSR on server side
|
||||||
|
csr_pem, _ = enrollment.generate_ca_csr(cert_dir=self.server_cert_dir)
|
||||||
|
|
||||||
|
# 2. Mock sign it with 50 seats
|
||||||
|
sub_pem, _ = _create_mock_signed_license_cert(csr_pem, seats=50, license_id="LIC-IMPORT-50")
|
||||||
|
|
||||||
|
# 3. Setup mock server state and DB
|
||||||
|
Server.init_db(self.server_db, max_seats=5)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 5,
|
||||||
|
"tcp_host": "127.0.0.1",
|
||||||
|
"tcp_port": 9443,
|
||||||
|
"hermes_host": "127.0.0.1",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"server_fingerprint": "MOCK_FP",
|
||||||
|
"server_name": "Test-Hub",
|
||||||
|
"license_server_url": "https://logar.eibl.tech"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Extract summary before import
|
||||||
|
summary_before = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary_before["max_seats"], 5)
|
||||||
|
|
||||||
|
# 5. Save the signed CA certificate to 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:
|
||||||
|
f.write(sub_pem)
|
||||||
|
|
||||||
|
# 6. Extract summary after import
|
||||||
|
summary_after = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary_after["max_seats"], 50)
|
||||||
|
self.assertEqual(summary_after["license_id"], "LIC-IMPORT-50")
|
||||||
|
self.assertEqual(summary_after["status"], "ACTIVE")
|
||||||
|
|
||||||
|
def test_server_client_revocation_and_reactivation(self):
|
||||||
|
"""Verifies revocation and reactivation of edge client seats on the server hub."""
|
||||||
|
Server.init_db(self.server_db, max_seats=2)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert active clients
|
||||||
|
conn = sqlite3.connect(self.server_db)
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c1', 'win1', 'windows', 'FP1', 'active')")
|
||||||
|
conn.execute("INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status) VALUES ('c2', 'lin1', 'linux', 'FP2', 'active')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Revoke c1
|
||||||
|
req = Server.ClientActionRequest(client_id="c1")
|
||||||
|
res = Server.revoke_client(req)
|
||||||
|
self.assertEqual(res["status"], "revoked")
|
||||||
|
|
||||||
|
summary = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary["active_seats"], 1)
|
||||||
|
self.assertEqual(summary["revoked_seats"], 1)
|
||||||
|
self.assertEqual(summary["available_seats"], 1)
|
||||||
|
|
||||||
|
# Reactivate c1
|
||||||
|
res_react = Server.reactivate_client(req)
|
||||||
|
self.assertEqual(res_react["status"], "active")
|
||||||
|
|
||||||
|
summary2 = Server.get_license_summary()
|
||||||
|
self.assertEqual(summary2["active_seats"], 2)
|
||||||
|
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):
|
||||||
|
"""Verifies that the WebUI HTML contains key components, stats, and download triggers."""
|
||||||
|
Server.init_db(self.server_db, max_seats=10)
|
||||||
|
Server.SERVER_STATE["config"] = {
|
||||||
|
"db_path": self.server_db,
|
||||||
|
"cert_dir": self.server_cert_dir,
|
||||||
|
"max_seats": 10,
|
||||||
|
"tcp_port": 9443,
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"server_name": "Test-Cloud-Hub",
|
||||||
|
"server_fingerprint": "1234567890ABCDEF",
|
||||||
|
"license_server_url": "https://logar.eibl.tech"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = Server.serve_webui()
|
||||||
|
html = response.body.decode("utf-8")
|
||||||
|
self.assertIn("LOGAR Fleet Hub", html)
|
||||||
|
self.assertNotIn("LOGAR Fleet Hub & License Management", html)
|
||||||
|
self.assertNotIn("Export .lic Request", html)
|
||||||
|
self.assertNotIn("Import License (.lic / ca.crt)", html)
|
||||||
|
self.assertIn("Sync & Renew via mTLS", html)
|
||||||
|
self.assertIn("logar.eibl.tech", html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__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 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
|
||||||
@@ -198,6 +204,52 @@ class TestLinuxClientComponent(unittest.TestCase):
|
|||||||
self.assertEqual(logs[0]["__CURSOR"], "c3")
|
self.assertEqual(logs[0]["__CURSOR"], "c3")
|
||||||
self.assertEqual(newest_cursor, "c4")
|
self.assertEqual(newest_cursor, "c4")
|
||||||
|
|
||||||
|
def test_mtls_client_certificate_handling(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_linux_mtls_certs"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, ca_pem, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("linux-client-test", ca_cert, ca_key)
|
||||||
|
|
||||||
|
with open(os.path.join(test_dir, "ca.crt"), "w") as f:
|
||||||
|
f.write(ca_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.crt"), "w") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.key"), "w") as f:
|
||||||
|
f.write(client_key_pem)
|
||||||
|
|
||||||
|
# Test missing certs exception
|
||||||
|
empty_dir = "test_empty_linux_certs"
|
||||||
|
os.makedirs(empty_dir, exist_ok=True)
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
Linux_Client.get_tls_socket("127.0.0.1", 9443, empty_dir)
|
||||||
|
shutil.rmtree(empty_dir, ignore_errors=True)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_client_certificate_validity_and_proactive_check(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_linux_client_validity"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, _, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("linux-validity-test", ca_cert, ca_key, days_valid=365)
|
||||||
|
cert_path = os.path.join(test_dir, "client.crt")
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
|
||||||
|
# Newly issued cert (365 days) is not expiring soon at 30 days
|
||||||
|
self.assertFalse(Linux_Client.is_cert_expiring_soon(cert_path, threshold_days=30))
|
||||||
|
# Large threshold (500 days) reports expiring soon
|
||||||
|
self.assertTrue(Linux_Client.is_cert_expiring_soon(cert_path, threshold_days=500))
|
||||||
|
# Non-existent file reports expiring / missing
|
||||||
|
self.assertTrue(Linux_Client.is_cert_expiring_soon(os.path.join(test_dir, "missing.crt")))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+129
-54
@@ -3,9 +3,11 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ sys.path.insert(0, ROOT_DIR)
|
|||||||
sys.path.insert(0, SRC_DIR)
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
import pgpy
|
|
||||||
|
import Win_Client
|
||||||
|
import Linux_Client
|
||||||
|
|
||||||
# Test server endpoints
|
# Test server endpoints
|
||||||
TCP_HOST = "127.0.0.1"
|
TCP_HOST = "127.0.0.1"
|
||||||
@@ -24,6 +28,7 @@ TCP_PORT = 9443
|
|||||||
HERMES_HOST = "127.0.0.1"
|
HERMES_HOST = "127.0.0.1"
|
||||||
HERMES_PORT = 8443
|
HERMES_PORT = 8443
|
||||||
|
|
||||||
|
|
||||||
def run_tests():
|
def run_tests():
|
||||||
print("=== [1] Verifying server_config.json & client_config.json ===")
|
print("=== [1] Verifying server_config.json & client_config.json ===")
|
||||||
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "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")
|
||||||
@@ -45,60 +50,123 @@ def run_tests():
|
|||||||
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
|
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
|
||||||
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
||||||
|
|
||||||
# Load public key
|
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(client_conf["server_public_key"])
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
client_id = "test-edge-node.corp.internal"
|
||||||
def send_socket_batch(logs, auth_token=client_conf["auth_token"]):
|
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
|
||||||
payload = {
|
|
||||||
"server": "test-edge-node.corp.internal",
|
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
|
||||||
"logs": logs
|
|
||||||
}
|
# 2a. Test rejection on invalid enrollment secret
|
||||||
pgp_msg = pgpy.PGPMessage.new(json.dumps(payload))
|
bad_enroll_payload = {
|
||||||
enc = pub_key.encrypt(pgp_msg)
|
"client_id": client_id,
|
||||||
|
"hostname": client_id,
|
||||||
envelope = {
|
"os": "linux",
|
||||||
"auth_token": auth_token,
|
"enrollment_secret": "invalid-secret-xyz"
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
}
|
||||||
"encrypted_payload": str(enc)
|
req_bad = urllib.request.Request(
|
||||||
}
|
enroll_url,
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
data=json.dumps(bad_enroll_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
)
|
||||||
s.settimeout(5.0)
|
try:
|
||||||
s.connect((TCP_HOST, TCP_PORT))
|
with urllib.request.urlopen(req_bad, timeout=5):
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
assert False, "Expected HTTP 403 on invalid secret"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 403, f"Expected HTTP 403, got {e.code}"
|
||||||
|
print("[OK] Invalid enrollment secret rejected with HTTP 403.")
|
||||||
|
|
||||||
|
# 2b. Test valid client enrollment
|
||||||
|
valid_enroll_payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": client_id,
|
||||||
|
"os": "linux",
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req_valid = urllib.request.Request(
|
||||||
|
enroll_url,
|
||||||
|
data=json.dumps(valid_enroll_payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req_valid, timeout=5) as resp:
|
||||||
|
assert resp.status == 200, f"Expected 200, got {resp.status}"
|
||||||
|
enroll_data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
assert "ca_cert" in enroll_data
|
||||||
|
assert "client_cert" in enroll_data
|
||||||
|
assert "client_key" in enroll_data
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(enroll_data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(enroll_data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(enroll_data["client_key"])
|
||||||
|
print(f"[OK] Client enrolled successfully. Certificates stored in {cert_dir}")
|
||||||
|
|
||||||
|
# 2c. Verify client shows in /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:
|
||||||
|
clients_data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
assert clients_data["active_seats"] >= 1
|
||||||
|
found_c = any(c["client_id"] == client_id for c in clients_data["clients"])
|
||||||
|
assert found_c, f"Client {client_id} should be listed in /api/clients"
|
||||||
|
print(f"[OK] Verified client in /api/clients: Active Seats: {clients_data['active_seats']}/{clients_data['max_seats']}")
|
||||||
|
|
||||||
|
def send_mtls_batch(logs):
|
||||||
|
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((TCP_HOST, TCP_PORT), timeout=10)
|
||||||
|
with ctx.wrap_socket(raw_sock, server_hostname=TCP_HOST) as s:
|
||||||
|
payload = {
|
||||||
|
"server": client_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
|
||||||
s.sendall(frame)
|
s.sendall(frame)
|
||||||
|
|
||||||
resp_len_bytes = s.recv(4)
|
resp_len_bytes = s.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed connection without response.")
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
resp_bytes = s.recv(resp_len)
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = s.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
return json.loads(resp_bytes.decode("utf-8"))
|
return json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
|
||||||
print("\n=== [2] Testing Socket Authentication Failure ===")
|
print("\n=== [3] Testing Temporal Persistence & Direct Anomaly Verification over mTLS ===")
|
||||||
bad_resp = send_socket_batch([], auth_token="invalid-token-12345")
|
test_signature = f"TestServiceDegraded_{int(time.time())}"
|
||||||
assert bad_resp.get("status") == "error", f"Expected error, got: {bad_resp}"
|
|
||||||
print(f"[OK] Bad auth rejected correctly: {bad_resp['message']}")
|
|
||||||
|
|
||||||
test_signature = "TestServiceDegraded"
|
|
||||||
candidate_log = [{
|
candidate_log = [{
|
||||||
"server": "test-edge-node",
|
"server": client_id,
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
"signature": test_signature,
|
"signature": test_signature,
|
||||||
"severity": "WARNING",
|
"severity": "WARNING",
|
||||||
"message": "Resource usage high warning"
|
"message": "Resource usage high warning"
|
||||||
}]
|
}]
|
||||||
|
|
||||||
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule for Warnings ===")
|
|
||||||
for run_num in range(1, 5):
|
for run_num in range(1, 5):
|
||||||
resp = send_socket_batch(candidate_log)
|
resp = send_mtls_batch(candidate_log)
|
||||||
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. 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"))
|
||||||
@@ -106,27 +174,27 @@ def run_tests():
|
|||||||
cursor.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", (test_signature,))
|
cursor.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", (test_signature,))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
assert row is not None, "Issue not found in SQLite"
|
assert row is not None, "Issue not found in SQLite"
|
||||||
run_count, status = row
|
run_count, status = row
|
||||||
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!")
|
print("[OK] Direct verification confirmed: Warning immediately verified for Hermes with run count tracking over mTLS!")
|
||||||
|
|
||||||
print("\n=== [3b] Testing Immediate Pass for Errors ===")
|
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": "test-edge-node",
|
"server": client_id,
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
"signature": error_signature,
|
"signature": error_signature,
|
||||||
"severity": "ERROR",
|
"severity": "ERROR",
|
||||||
"message": "Fatal process crash occurred"
|
"message": "Fatal process crash occurred"
|
||||||
}]
|
}]
|
||||||
err_resp = send_socket_batch(error_log)
|
err_resp = send_mtls_batch(error_log)
|
||||||
assert err_resp.get("status") == "success", f"Error run failed: {err_resp}"
|
assert err_resp.get("status") == "success", f"Error run failed: {err_resp}"
|
||||||
print(f"[Run 1/1] Error ingested successfully. Promoted to verified: {err_resp.get('promoted_verified')}")
|
print(f"[Run 1/1] Error ingested successfully. Promoted to verified: {err_resp.get('promoted_verified')}")
|
||||||
assert err_resp.get("promoted_verified") == 1, f"Expected error to be promoted to verified immediately on run 1, got {err_resp.get('promoted_verified')}"
|
assert err_resp.get("promoted_verified") == 1, f"Expected error to be promoted to verified immediately, got {err_resp.get('promoted_verified')}"
|
||||||
|
|
||||||
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -140,12 +208,14 @@ def run_tests():
|
|||||||
assert err_status == "VERIFIED", f"Expected status 'VERIFIED', got {err_status}"
|
assert err_status == "VERIFIED", f"Expected status 'VERIFIED', got {err_status}"
|
||||||
print("[OK] Immediate pass verified: Error promoted to VERIFIED anomaly immediately!")
|
print("[OK] Immediate pass verified: Error promoted to VERIFIED anomaly immediately!")
|
||||||
|
|
||||||
print("\n=== [4] 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"))
|
||||||
|
|
||||||
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
|
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
|
||||||
found_warning = False
|
found_warning = False
|
||||||
found_error = False
|
found_error = False
|
||||||
@@ -164,19 +234,24 @@ def run_tests():
|
|||||||
assert found_error, f"Error issue {error_signature} should be in Hermes report"
|
assert found_error, f"Error issue {error_signature} should be in Hermes report"
|
||||||
print("[OK] Hermes reporting validated!")
|
print("[OK] Hermes reporting validated!")
|
||||||
|
|
||||||
print("\n=== [5] Testing Windows Client Script Integration ===")
|
print("\n=== [6] Testing Windows Client Script Integration ===")
|
||||||
from Win_Client import get_recent_windows_logs
|
from Win_Client import get_recent_windows_logs
|
||||||
win_logs = get_recent_windows_logs(hours=24)
|
win_logs = get_recent_windows_logs(hours=24)
|
||||||
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
|
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
|
||||||
|
|
||||||
print("\n=== [6] Testing Linux Client Script Integration ===")
|
print("\n=== [7] Testing Linux Client Script Integration ===")
|
||||||
from Linux_Client import get_recent_linux_logs
|
from Linux_Client import get_recent_linux_logs
|
||||||
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.")
|
||||||
|
|
||||||
print("\n==========================================")
|
import shutil
|
||||||
print(" ALL VERIFICATION TESTS PASSED SUCCESSFULLY! ")
|
if os.path.exists(cert_dir):
|
||||||
print("==========================================")
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
print("\n=======================================================")
|
||||||
|
print(" ALL VERIFICATION TESTS (mTLS + PKI + PIPELINE) PASSED! ")
|
||||||
|
print("=======================================================")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_tests()
|
run_tests()
|
||||||
|
|||||||
+282
-16
@@ -75,7 +75,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 +89,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 +131,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 +157,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,11 +172,276 @@ 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):
|
||||||
|
secret = "test-secret-12345"
|
||||||
|
Server.init_db(self.test_db, enrollment_secret=secret, max_seats=5)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT max_seats, enrollment_secret FROM license_config WHERE id = 1")
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row[0], 5)
|
||||||
|
self.assertEqual(row[1], secret)
|
||||||
|
|
||||||
|
# Test Dynamic PKI
|
||||||
|
test_cert_dir = "test_certs_pki"
|
||||||
|
try:
|
||||||
|
ca_cert, ca_key, ca_pem, ca_key_pem = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", ca_pem)
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", ca_key_pem)
|
||||||
|
|
||||||
|
srv_cert, srv_key, srv_pem, srv_key_pem = Server.enrollment.generate_server_cert_if_needed(
|
||||||
|
ca_cert, ca_key, hostnames=["127.0.0.1", "localhost"], cert_dir=test_cert_dir
|
||||||
|
)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", srv_pem)
|
||||||
|
|
||||||
|
client_cert_pem, client_key_pem = Server.enrollment.issue_client_cert("node-test-1", ca_cert, ca_key)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", client_cert_pem)
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", client_key_pem)
|
||||||
|
|
||||||
|
fp = Server.enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||||
|
self.assertEqual(len(fp), 64)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_enrollment_endpoint_and_seat_quota(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
secret = "super-secret-enrollment"
|
||||||
|
max_seats = 2
|
||||||
|
Server.init_db(self.test_db, enrollment_secret=secret, max_seats=max_seats)
|
||||||
|
|
||||||
|
test_cert_dir = "test_certs_enroll"
|
||||||
|
try:
|
||||||
|
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["ca_cert"] = ca_cert
|
||||||
|
Server.SERVER_STATE["ca_key"] = ca_key
|
||||||
|
Server.SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||||
|
|
||||||
|
# 1. Invalid secret should raise 403
|
||||||
|
bad_req = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-1",
|
||||||
|
hostname="host-1",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret="wrong-secret"
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(bad_req)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
|
||||||
|
# 2. Valid enrollment for client 1
|
||||||
|
req1 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-1",
|
||||||
|
hostname="host-1",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
resp1 = Server.enroll_client(req1)
|
||||||
|
self.assertIn("client_cert", resp1)
|
||||||
|
self.assertIn("client_key", resp1)
|
||||||
|
self.assertEqual(resp1["ca_cert"], ca_pem)
|
||||||
|
|
||||||
|
# 3. Valid enrollment for client 2
|
||||||
|
req2 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-2",
|
||||||
|
hostname="host-2",
|
||||||
|
os="windows",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
resp2 = Server.enroll_client(req2)
|
||||||
|
self.assertIn("client_cert", resp2)
|
||||||
|
|
||||||
|
# 4. Seat quota exhausted: client 3 should raise 403
|
||||||
|
req3 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-3",
|
||||||
|
hostname="host-3",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(req3)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
self.assertIn("License seat limit reached", cm.exception.detail)
|
||||||
|
|
||||||
|
# 5. Re-enrollment for existing client 1 should succeed
|
||||||
|
resp1_re = Server.enroll_client(req1)
|
||||||
|
self.assertIn("client_cert", resp1_re)
|
||||||
|
|
||||||
|
# 6. Revoked client should be rejected
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
conn.execute("UPDATE clients SET status = 'revoked' WHERE client_id = 'client-1'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(req1)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
self.assertIn("revoked", cm.exception.detail)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_cert_validity_and_hub_pki_renewal(self):
|
||||||
|
test_cert_dir = "test_certs_renew"
|
||||||
|
try:
|
||||||
|
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
|
srv_cert, srv_key, srv_pem, _ = Server.enrollment.generate_server_cert_if_needed(
|
||||||
|
ca_cert, ca_key, hostnames=["127.0.0.1"], cert_dir=test_cert_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Freshly generated certificates should NOT be expiring soon with standard 30-day threshold
|
||||||
|
self.assertFalse(Server.enrollment.is_cert_expiring_soon(ca_pem, threshold_days=30))
|
||||||
|
self.assertFalse(Server.enrollment.is_cert_expiring_soon(srv_pem, threshold_days=30))
|
||||||
|
|
||||||
|
# 2. Huge threshold (e.g. 5000 days) should flag expiration
|
||||||
|
self.assertTrue(Server.enrollment.is_cert_expiring_soon(srv_pem, threshold_days=5000))
|
||||||
|
|
||||||
|
# 3. check_and_renew_hub_pki with standard threshold should report no renewal needed
|
||||||
|
ca_renewed, srv_renewed = Server.enrollment.check_and_renew_hub_pki(cert_dir=test_cert_dir, threshold_days=30)
|
||||||
|
self.assertFalse(ca_renewed)
|
||||||
|
self.assertFalse(srv_renewed)
|
||||||
|
|
||||||
|
# 4. In-flight reload of SSLContext
|
||||||
|
ssl_ctx = Server.init_mtls_server_context(cert_dir=test_cert_dir)
|
||||||
|
Server.SERVER_STATE["ssl_ctx"] = ssl_ctx
|
||||||
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir, "tcp_host": "127.0.0.1"}
|
||||||
|
|
||||||
|
# Trigger rotation using high threshold
|
||||||
|
rotated = Server.check_and_rotate_server_certs(cert_dir=test_cert_dir, hostnames=["127.0.0.1"], threshold_days=5000)
|
||||||
|
self.assertTrue(rotated)
|
||||||
|
|
||||||
|
# Check that backup files were generated
|
||||||
|
bak_files = [f for f in os.listdir(test_cert_dir) if f.endswith(".bak")]
|
||||||
|
self.assertGreater(len(bak_files), 0)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
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")
|
||||||
|
just_now = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
self.assertIn(Server.format_relative_time(just_now), ("0s ago", "1s ago", "Just now"))
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -184,6 +190,52 @@ class TestWinClientComponent(unittest.TestCase):
|
|||||||
# Only rec 103 and 102 should be processed (101 is already sent, <= 100 breaks early)
|
# Only rec 103 and 102 should be processed (101 is already sent, <= 100 breaks early)
|
||||||
self.assertEqual(logs, [103, 102])
|
self.assertEqual(logs, [103, 102])
|
||||||
|
|
||||||
|
def test_mtls_client_certificate_handling(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_win_mtls_certs"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, ca_pem, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("win-client-test", ca_cert, ca_key)
|
||||||
|
|
||||||
|
with open(os.path.join(test_dir, "ca.crt"), "w") as f:
|
||||||
|
f.write(ca_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.crt"), "w") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.key"), "w") as f:
|
||||||
|
f.write(client_key_pem)
|
||||||
|
|
||||||
|
# Test missing certs exception
|
||||||
|
empty_dir = "test_empty_certs"
|
||||||
|
os.makedirs(empty_dir, exist_ok=True)
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
Win_Client.get_tls_socket("127.0.0.1", 9443, empty_dir)
|
||||||
|
shutil.rmtree(empty_dir, ignore_errors=True)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_client_certificate_validity_and_proactive_check(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_win_client_validity"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, _, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("win-validity-test", ca_cert, ca_key, days_valid=365)
|
||||||
|
cert_path = os.path.join(test_dir, "client.crt")
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
|
||||||
|
# Newly issued cert (365 days) is not expiring soon at 30 days
|
||||||
|
self.assertFalse(Win_Client.is_cert_expiring_soon(cert_path, threshold_days=30))
|
||||||
|
# Large threshold (500 days) reports expiring soon
|
||||||
|
self.assertTrue(Win_Client.is_cert_expiring_soon(cert_path, threshold_days=500))
|
||||||
|
# Non-existent file reports expiring / missing
|
||||||
|
self.assertTrue(Win_Client.is_cert_expiring_soon(os.path.join(test_dir, "missing.crt")))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user