53 Commits
Author SHA1 Message Date
me0nline 249b754423 test(pipeline): update end-to-end integration test for mTLS and dynamic PKI enrollment in test_pipeline.py
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m17s
2026-09-04 20:55:12 +02:00
me0nline 16faad063d test(linux_client): add mTLS client certificate handling test in test_linux_client.py 2026-09-04 20:55:07 +02:00
me0nline c705be57eb test(win_client): add mTLS client certificate handling test in test_win_client.py 2026-09-04 20:55:01 +02:00
me0nline 3d6a2b86d6 test(server): add PKI, enrollment, and license quota tests in test_server.py 2026-09-04 20:54:54 +02:00
me0nline 1d0845b548 ci: include src/server_enrollment.py in syntax verification step 2026-09-04 20:54:49 +02:00
me0nline 11f16ed8e1 ci(windows): update release-windows.yml to trigger on v* tags and compile Inno Setup installers 2026-09-04 20:54:40 +02:00
me0nline 8cb3089e8e feat(installer): add Inno Setup script for LOGAR Server 2026-09-04 20:54:35 +02:00
me0nline 5bbb56b4c3 feat(installer): add Inno Setup script for LOGAR Client 2026-09-04 20:54:30 +02:00
me0nline a04d9bac9f chore(deps): bundle NSSM 64-bit binary for Windows service management 2026-09-04 20:54:24 +02:00
me0nline 8813d2d865 chore(git): unignore compilation/nssm.exe in .gitignore 2026-09-04 20:54:18 +02:00
me0nline 7e9f7a56f8 feat(install): add Linux server systemd installation script 2026-09-04 20:54:12 +02:00
me0nline 99920ef395 feat(install): add Linux client systemd installation script 2026-09-04 20:54:06 +02:00
me0nline ccb23d65e5 feat(linux_client): add mTLS connection and automatic enrollment in src/Linux_Client.py 2026-09-04 20:54:00 +02:00
me0nline fb9ef6e6cb feat(win_client): add mTLS connection and automatic enrollment in src/Win_Client.py 2026-09-04 20:53:54 +02:00
me0nline 491d2b1194 feat(server): add mTLS listener, client licensing schema, and enrollment endpoint in src/Server.py 2026-09-04 20:53:48 +02:00
me0nline e83a5b3e0f feat(pki): add dynamic PKI and client certificate generation in src/server_enrollment.py 2026-09-04 20:53:42 +02:00
me0nline 0901ccb3eb Merge branch 'origin/main' into main: adopt restructured repository layout 2026-09-04 20:29:29 +02:00
me0nline cb4c763e0e Add hierarchical wildcard to tags trigger in release-windows.yml
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m41s
2026-09-04 17:54:57 +02:00
me0nline 84579d8719 Add hierarchical wildcard to tags trigger in release-linux.yml 2026-09-04 17:54:50 +02:00
me0nline 08aa4edfb1 Update ci.yml tags-ignore to ignore all tags
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m38s
2026-09-04 17:46:09 +02:00
me0nline e4f6a9295b De-hardcode workflow initiator in release-windows.yml to support release events and all tags 2026-09-04 17:46:00 +02:00
me0nline 7f6bf4442f De-hardcode workflow initiator in release-linux.yml to support release events and all tags 2026-09-04 17:45:48 +02:00
me0nline db80e7f5a2 Update README.md release examples to reference v1.0.2
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m16s
2026-09-04 17:34:14 +02:00
me0nline 1562285034 Update release-windows.yml default tag to v1.0.2 2026-09-04 17:34:03 +02:00
me0nline 74942d4c00 Update release-linux.yml default tag to v1.0.2 2026-09-04 17:33:57 +02:00
me0nline 874d693dac Update RELEASE_NOTES.md for v1.0.2 release 2026-09-04 17:33:51 +02:00
me0nline 15bfb4940b Update Windows server deployment guide for warning persistence and immediate error pass
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m34s
2026-09-04 16:12:04 +02:00
me0nline 364fefea47 Update Linux server deployment guide for warning persistence and immediate error pass 2026-09-04 16:11:51 +02:00
me0nline 9624935a81 Document warning persistence and immediate error pass in release notes 2026-09-04 16:11:37 +02:00
me0nline 867271e8b7 Update README to document 4-run rule for warnings and immediate pass for errors 2026-09-04 16:11:30 +02:00
me0nline 205d0cfbad Update integration pipeline to test warning persistence and error immediate pass 2026-09-04 16:10:59 +02:00
me0nline d37191e302 Update unit tests to verify 4-run rule on warnings and immediate pass on errors 2026-09-04 16:10:36 +02:00
me0nline 35a736dacb Restrict 4-run rule to warnings and pass errors immediately as verified 2026-09-04 16:10:01 +02:00
me0nline f871344da4 Update README.md to document out/linux_server and out/win_server deployment packages
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m33s
2026-09-04 16:05:04 +02:00
me0nline e1dbe32063 Add Windows server deployment guide and sample configuration to out/win_server 2026-09-04 16:04:51 +02:00
me0nline 98a227a234 Add Linux server deployment guide and sample configuration to out/linux_server 2026-09-04 16:04:20 +02:00
me0nline 355c6e1bc0 Update README.md documentation and commands to reflect compilation/ and tests/ paths
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m34s
2026-09-04 15:58:03 +02:00
me0nline 907616511b Update release-windows.yml workflow to use compilation/ directory 2026-09-04 15:57:14 +02:00
me0nline 939fa4270a Update release-linux.yml workflow to use compilation/ directory 2026-09-04 15:57:02 +02:00
me0nline 1f4bf3219b Update CI workflow with compilation/ and tests/ paths 2026-09-04 15:56:50 +02:00
me0nline df03c52a05 Update tests/test_pipeline.py to resolve paths relative to repository root and src/ directory 2026-09-04 15:56:37 +02:00
me0nline d63a623763 Update compilation/upload_release.py to resolve paths relative to repo root 2026-09-04 15:56:24 +02:00
me0nline 7ed264db5a Update compilation/package_dist.py to resolve paths relative to repo root 2026-09-04 15:56:01 +02:00
me0nline 6fec838344 Move test_pipeline.py from repository root to tests/ directory 2026-09-04 15:55:51 +02:00
me0nline 4c160924f7 Move build and release tools (package_dist.py, upload_release.py, requirements.txt) into compilation/ directory 2026-09-04 15:55:44 +02:00
me0nline 99be50ebc6 Update Server.py reference to src/Server.py in forwarder deployment READMEs
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m32s
2026-09-04 15:51:37 +02:00
me0nline 85f0d94805 Update README.md documentation and repo structure to reflect src/ directory 2026-09-04 15:51:23 +02:00
me0nline 1a18c4c079 Update CI workflow to execute server and client scripts from src/ directory 2026-09-04 15:50:34 +02:00
me0nline a770e24f26 Update test_pipeline.py to import client forwarders from src/ directory 2026-09-04 15:50:26 +02:00
me0nline f7ebc6c0a1 Update unit test suites to import components from src/ directory 2026-09-04 15:50:14 +02:00
me0nline 86649f796d Update package_dist.py to resolve source files from src/ directory 2026-09-04 15:49:52 +02:00
me0nline 78fc2ac8c5 Move source files Server.py, Win_Client.py, and Linux_Client.py into src/ directory 2026-09-04 15:49:32 +02:00
me0nline 4e2242e0ae Name checksum files distinctively as SHA256SUMS-windows.txt and SHA256SUMS-linux.txt 2026-09-03 22:13:36 +02:00
30 changed files with 2058 additions and 340 deletions
+6 -6
View File
@@ -5,7 +5,7 @@ on:
branches: branches:
- '**' - '**'
tags-ignore: tags-ignore:
- 'v*' - '*'
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -24,11 +24,11 @@ jobs:
apt-get install -y python3 python3-pip python3-venv curl apt-get install -y python3 python3-pip python3-venv curl
fi fi
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
pip3 install -r requirements.txt --break-system-packages || pip3 install -r requirements.txt 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 Server.py Win_Client.py Linux_Client.py package_dist.py upload_release.py 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: |
@@ -40,10 +40,10 @@ jobs:
rm -f server_config.json client_config.json logar_state.db client_state.json rm -f server_config.json client_config.json logar_state.db client_state.json
# 1. Initialize server config and export client configuration # 1. Initialize server config and export client configuration
python3 Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json python3 src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
# 2. Launch LOGAR server in the background # 2. Launch LOGAR server in the background
python3 Server.py & python3 src/Server.py &
SERVER_PID=$! SERVER_PID=$!
echo "[*] Server launched in background with PID $SERVER_PID" echo "[*] Server launched in background with PID $SERVER_PID"
@@ -65,7 +65,7 @@ jobs:
fi fi
# 4. Execute end-to-end integration test # 4. Execute end-to-end integration test
python3 test_pipeline.py python3 tests/test_pipeline.py
# 5. Cleanly terminate background server # 5. Cleanly terminate background server
kill $SERVER_PID || true kill $SERVER_PID || true
+9 -7
View File
@@ -1,15 +1,17 @@
name: Release Linux Binaries name: Release Linux Binaries
on: on:
release:
types: [published, created]
push: push:
tags: tags:
- 'v*' - '*'
- '**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Release tag (e.g. v1.0.1)' description: 'Release tag (optional)'
required: false required: false
default: 'v1.0.1'
jobs: jobs:
release-linux: release-linux:
@@ -26,17 +28,17 @@ jobs:
apt-get install -y python3 python3-pip python3-venv binutils zip apt-get install -y python3 python3-pip python3-venv binutils zip
fi fi
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
pip3 install pyinstaller -r requirements.txt --break-system-packages || pip3 install pyinstaller -r requirements.txt pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -r compilation/requirements.txt
- name: Compile Standalone Linux Binaries - name: Compile Standalone Linux Binaries
run: | run: |
python3 package_dist.py --target linux python3 compilation/package_dist.py --target linux
- name: Publish Linux Release Assets - name: Publish Linux Release Assets
env: env:
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }} GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
GITEA_SERVER_URL: ${{ github.server_url }} GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }} GITEA_REPOSITORY: ${{ github.repository }}
GITEA_REF_NAME: ${{ inputs.tag || github.ref_name }} GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
run: | run: |
python3 upload_release.py --skip-build python3 compilation/upload_release.py --skip-build
+29 -10
View File
@@ -1,4 +1,4 @@
name: Release Windows Binaries name: Release Windows Binaries & Installers
on: on:
push: push:
@@ -7,19 +7,18 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Release tag (e.g. v1.0.1)' description: 'Release tag (optional)'
required: false required: false
default: 'v1.0.1'
jobs: jobs:
release-windows: release-windows:
name: Build & Release Windows Binaries name: Build & Release Windows Binaries & Installers
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout Code - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python - name: Setup Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
@@ -35,7 +34,7 @@ jobs:
} }
} }
& $py -m pip install --upgrade pip & $py -m pip install --upgrade pip
& $py -m pip install pyinstaller -r requirements.txt & $py -m pip install pyinstaller cryptography -r compilation/requirements.txt
- name: Compile Standalone Windows Binaries - name: Compile Standalone Windows Binaries
shell: powershell shell: powershell
@@ -46,7 +45,27 @@ jobs:
$py = "py -3.12" $py = "py -3.12"
} }
} }
& $py package_dist.py --target windows & $py compilation/package_dist.py --target windows
- name: Compile Inno Setup Installers
shell: powershell
run: |
$iscc = $null
if (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") {
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
} elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") {
$iscc = "C:\Program Files\Inno Setup 6\ISCC.exe"
} elseif (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue) {
$iscc = "ISCC.exe"
}
if ($iscc) {
Write-Host "[*] Compiling Windows Inno Setup installers using $iscc..."
& $iscc compilation/installer_client.iss
& $iscc compilation/installer_server.iss
} else {
Write-Host "[!] Inno Setup compiler (ISCC.exe) not found on runner host. Skipping installer compilation."
}
- name: Publish Windows Release Assets - name: Publish Windows Release Assets
shell: powershell shell: powershell
@@ -54,7 +73,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }} GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
GITEA_SERVER_URL: ${{ github.server_url }} GITEA_SERVER_URL: ${{ github.server_url }}
GITEA_REPOSITORY: ${{ github.repository }} GITEA_REPOSITORY: ${{ github.repository }}
GITEA_REF_NAME: ${{ inputs.tag || github.ref_name }} GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
run: | run: |
$py = "python" $py = "python"
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
@@ -62,4 +81,4 @@ jobs:
$py = "py -3.12" $py = "py -3.12"
} }
} }
& $py upload_release.py --skip-build & $py compilation/upload_release.py --skip-build
+1
View File
@@ -11,6 +11,7 @@ build/
dist/ dist/
*.spec *.spec
*.exe *.exe
!compilation/nssm.exe
*.bin *.bin
*.dll *.dll
*.so *.so
+58 -43
View File
@@ -1,6 +1,6 @@
# LOGAR: Edge-Thin Log Analysis & Temporal Verification System # LOGAR: Edge-Thin Log Analysis & Temporal Verification System
**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, and an automated 4-run rule to filter out transient infrastructure blips before reporting verified anomalies to **Hermes**. **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**.
--- ---
@@ -27,14 +27,15 @@ Site agents running on Windows and Linux act strictly as lightweight forwarders:
- **End-to-End Encryption**: Logs are encrypted using the server's OpenPGP public key before leaving the edge node. - **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. - **Secure TCP Sockets**: Ingestion occurs over low-overhead authenticated TCP sockets rather than bulky HTTP/HTTPS endpoints.
### 2. Cloud-Side Temporal Persistence ### 2. Cloud-Side Temporal Persistence & Severity Routing
The central Python/TCP hub handles the heavy lifting: The central Python/TCP hub handles the heavy lifting:
- State tracking is managed centrally in SQLite (`logar_state.db`). - State tracking is managed centrally in SQLite (`logar_state.db`).
- Candidate issues are evaluated over a **12-hour temporal evaluation window**. - Candidate issues are evaluated over a **12-hour temporal evaluation window**.
- An issue must persist across **at least 4 consecutive runs / cycles** to be confirmed as a genuine system anomaly. Transient blips and sporadic spikes are filtered out automatically. - **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 ### 3. Agentic Integration with Hermes
Instead of human engineers manually diving through noisy logs, **Hermes** ingests pre-filtered, 4-run validated anomalies directly from the cloud hub (`GET /api/hermes/report`), treating them as verified system artifacts to trigger precise team notifications. 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.
--- ---
@@ -88,7 +89,7 @@ graph TB
- A cryptographically random authentication secret token (`auth_token`). - A cryptographically random authentication secret token (`auth_token`).
- **Client Configuration Exporter**: - **Client Configuration Exporter**:
```bash ```bash
python Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
``` ```
Produces an anonymous client config containing only the server socket coordinates, authentication token, and the encryption-only public key & fingerprint. Produces an anonymous client config containing only the server socket coordinates, authentication token, and the encryption-only public key & fingerprint.
- **Socket Protocol Framing**: - **Socket Protocol Framing**:
@@ -111,11 +112,15 @@ Incoming candidate logs are tracked in SQLite table `active_issues`:
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`. - **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
- **12-Hour Evaluation Window**: - **12-Hour Evaluation Window**:
- When an issue is observed, the hub compares `(now - last_seen)`. - 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` with status `TRANSIENT`. - 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**: - **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. - For each distinct run batch, `run_count` increments.
- Issues with `run_count < 4` are marked as `TRANSIENT` and ignored by downstream reporting. - 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 status transitions to `VERIFIED`. - 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.
--- ---
@@ -124,7 +129,7 @@ Incoming candidate logs are tracked in SQLite table `active_issues`:
The server hub serves a REST reporting API (default port `8443`): The server hub serves a REST reporting API (default port `8443`):
### `GET /api/hermes/report` ### `GET /api/hermes/report`
Returns exclusively **verified anomalies** that have satisfied the 4-run rule within the active 12-hour evaluation window: Returns all **verified anomalies** (immediate critical errors and warnings verified after 4 consecutive runs within the 12-hour evaluation window):
```json ```json
[ [
@@ -177,27 +182,37 @@ LOGAR/
│ ├── ci.yml # Continuous Integration automated test suite (runs on every push) │ ├── ci.yml # Continuous Integration automated test suite (runs on every push)
│ ├── release-linux.yml # Linux release workflow (compiles Server.bin, Linux_Client.bin, checksums) │ ├── release-linux.yml # Linux release workflow (compiles Server.bin, Linux_Client.bin, checksums)
│ └── release-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums) │ └── release-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums)
├── .gitignore # Ignore venv, caches, DBs, and private keys ├── compilation/ # Build, packaging, and release automation tools
├── requirements.txt # Unified dependencies │ ├── package_dist.py # Multi-platform standalone binary packaging script
├── README.md # Comprehensive documentation │ ├── requirements.txt # Unified project dependencies
├── Server.py # Central TCP server and Hermes API │ └── upload_release.py # Direct Gitea REST API release asset publisher
├── Win_Client.py # Windows edge forwarder ├── src/ # Core application source modules
├── Linux_Client.py # Linux edge forwarder │ ├── __init__.py
├── server_config.sample.json # Central server sample configuration ├── Server.py # Central TCP server and Hermes API
├── package_dist.py # Multi-platform standalone binary packaging script │ ├── Win_Client.py # Windows edge forwarder
├── upload_release.py # Direct Gitea REST API release asset publisher │ └── Linux_Client.py # Linux edge forwarder
├── test_pipeline.py # End-to-end integration test ├── tests/ # Automated test suites
├── tests/ # Unified unit test suites ├── test_linux_client.py # Linux client unit tests
│ ├── test_pipeline.py # End-to-end integration test
│ ├── test_server.py # Server unit tests │ ├── test_server.py # Server unit tests
── test_win_client.py # Windows client unit tests ── test_win_client.py # Windows client unit tests
│ └── test_linux_client.py # Linux client unit tests ├── .gitignore # Ignore venv, caches, DBs, and private keys
── out/ # Edge forwarder deployment packages ── README.md # Comprehensive documentation
├── win_client/ ├── RELEASE_NOTES.md # Release history and changelog
│ ├── client_config.sample.json # Reference client configuration ├── server_config.sample.json # Central server sample configuration
└── README.md # Windows service installation & configuration guide └── out/ # Standalone deployment documentation & sample configs
── linux_client/ ── linux_server/
├── client_config.sample.json # Reference client configuration ├── README.md # Linux systemd service installation & hub guide
└── README.md # Linux service installation & configuration 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
``` ```
--- ---
@@ -208,15 +223,15 @@ LOGAR/
1. **Install dependencies**: 1. **Install dependencies**:
```bash ```bash
pip install -r requirements.txt pip install -r compilation/requirements.txt
``` ```
2. **Start the server** (generates `server_config.json` and keypair on first run): 2. **Start the server** (generates `server_config.json` and keypair on first run):
```bash ```bash
python Server.py python src/Server.py
``` ```
3. **Export a client configuration**: 3. **Export a client configuration**:
```bash ```bash
python Server.py --create-client-config --server-host <SERVER_IP> --server-port 9443 --client-out client_config.json python src/Server.py --create-client-config --server-host <SERVER_IP> --server-port 9443 --client-out client_config.json
``` ```
### 2. Windows Client Deployment ### 2. Windows Client Deployment
@@ -287,23 +302,23 @@ The pipeline test exercises invalid token rejection, encrypted socket streaming,
1. **Start the server** in Shell 1 (creates `server_config.json` on first run): 1. **Start the server** in Shell 1 (creates `server_config.json` on first run):
```bash ```bash
python Server.py python src/Server.py
``` ```
2. **Export client configuration** in Shell 2 (required for testing): 2. **Export client configuration** in Shell 2 (required for testing):
```bash ```bash
python Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json 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: 3. **Execute the integration test** in Shell 2:
```bash ```bash
python test_pipeline.py python tests/test_pipeline.py
``` ```
## Continuous Integration via Gitea Actions ## 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: 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 (`Server.py`, `Win_Client.py`, `Linux_Client.py`, `package_dist.py`, `upload_release.py`, `test_pipeline.py`, and test suites). 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`). 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 `test_pipeline.py` (testing socket authentication, 4-run rule persistence, Hermes API report, and client integrations), and shuts down the test instance. 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.
--- ---
@@ -316,8 +331,8 @@ Release builds are automated via two dedicated Gitea Actions workflows running c
### Publishing a Release ### Publishing a Release
Whenever you want to release a new version with compiled standalone binaries: Whenever you want to release a new version with compiled standalone binaries:
```bash ```bash
git tag v1.0.1 git tag v1.0.2
git push origin v1.0.1 git push origin v1.0.2
``` ```
*(You can also trigger builds manually via the Gitea UI using the **Run workflow** button (`workflow_dispatch`) on either workflow).* *(You can also trigger builds manually via the Gitea UI using the **Run workflow** button (`workflow_dispatch`) on either workflow).*
@@ -360,13 +375,13 @@ git push origin v1.0.1
You can also compile and package binaries locally anytime: You can also compile and package binaries locally anytime:
```bash ```bash
# Windows # Windows
py -3.12 package_dist.py --target windows py -3.12 compilation/package_dist.py --target windows
# Linux # Linux
python3 package_dist.py --target linux python3 compilation/package_dist.py --target linux
``` ```
To upload local builds directly to Gitea: To upload local builds directly to Gitea:
```powershell ```powershell
python upload_release.py --tag v1.0.1 --token <YOUR_GITEA_TOKEN> 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).* *(Environment variables `GITEA_TOKEN`, `GITEA_SERVER_URL`, `GITEA_REPOSITORY`, and `GITEA_REF_NAME` are also supported automatically).*
+5 -2
View File
@@ -1,7 +1,10 @@
# LOGAR Release v1.0.1 # LOGAR Release v1.0.2
### Changes in this Release: ### Changes in this Release:
- **Dual Platform Gitea Release Automation**: Added 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). - **Warning Persistence & Immediate Error Routing**: Restructured temporal verification on the central hub so the 4-run persistence rule across the 12-hour evaluation window strictly governs `WARNING` and `INFO` events to suppress transient blips. High-severity `ERROR`, `CRITICAL`, and `FATAL` events are now promoted to `VERIFIED` immediately on their first occurrence and reported to Hermes without waiting for consecutive runs.
- **Server Deployment Packages in `out/`**: Added comprehensive deployment guides and configuration templates for both Linux Server hub (systemd service) and Windows Server hub (NSSM service / Task Scheduler) under `out/linux_server` and `out/win_server`.
- **Refactored Repository Layout**: Reorganized codebase by moving runtime forwarders and server hub into `src/`, compilation/release packaging utilities into `compilation/`, and all unit and pipeline verification tests into `tests/`.
- **Dual Platform Gitea Release Automation**: Dedicated Windows (`release-windows.yml`) and Linux (`release-linux.yml`) Gitea Actions to compile native platform binaries (`Win_Client.exe` and `Server.exe` on Windows; `Linux_Client.bin` and `Server.bin` on Linux).
- **Dedicated SHA-256 Checksums**: Release assets now include dedicated checksum files matching `[win/linux]_[client/agent]_sha256sum` (`win_client_sha256sum`, `win_agent_sha256sum`, `win_server_sha256sum`, `linux_client_sha256sum`, `linux_agent_sha256sum`, `linux_server_sha256sum`). - **Dedicated SHA-256 Checksums**: Release assets now include dedicated checksum files matching `[win/linux]_[client/agent]_sha256sum` (`win_client_sha256sum`, `win_agent_sha256sum`, `win_server_sha256sum`, `linux_client_sha256sum`, `linux_agent_sha256sum`, `linux_server_sha256sum`).
- **Removed Client Filter Logic**: Removed restrictive source-level noise filtering on edge forwarders. Clients now collect and stream all candidate events from `INFO` up to `ERROR` over the lookback window instead of discarding them at the source. - **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. - **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.
+71
View File
@@ -0,0 +1,71 @@
#!/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"
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
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="/opt/logar-server"
CONFIG_DIR="/etc/logar"
echo "[+] Installing LOGAR Server..."
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
+27
View File
@@ -0,0 +1,27 @@
[Setup]
AppName=LOGAR Client
AppVersion=1.0.3
DefaultDirName={autopf}\LOGAR
OutputDir=..\dist
OutputBaseFilename=LOGAR-Client-Setup
PrivilegesRequired=admin
Compression=lzma
SolidCompression=yes
[Files]
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
[Dirs]
Name: "{commonappdata}\LOGAR"; Permissions: users-modify
[Run]
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client.exe"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{commonappdata}\LOGAR\client.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{commonappdata}\LOGAR\client_err.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "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
+27
View File
@@ -0,0 +1,27 @@
[Setup]
AppName=LOGAR Server
AppVersion=1.0.3
DefaultDirName={autopf}\LOGAR-Server
OutputDir=..\dist
OutputBaseFilename=LOGAR-Server-Setup
PrivilegesRequired=admin
Compression=lzma
SolidCompression=yes
[Files]
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\compilation\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
[Dirs]
Name: "{commonappdata}\LOGAR-Server"; Permissions: users-modify
[Run]
Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Server ""{app}\Server.exe"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppDirectory ""{app}"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{commonappdata}\LOGAR-Server\server.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{commonappdata}\LOGAR-Server\server_err.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "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
Binary file not shown.
@@ -7,9 +7,10 @@ import platform
import subprocess import subprocess
import argparse import argparse
ROOT_DIR = os.path.abspath(os.path.dirname(__file__)) ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
DIST_DIR = os.path.abspath("dist") SRC_DIR = os.path.join(ROOT_DIR, "src")
BUILD_TEMP = os.path.abspath("build_temp") DIST_DIR = os.path.join(ROOT_DIR, "dist")
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):
@@ -55,7 +56,7 @@ def build_linux_zipapp_fallback():
# 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(ROOT_DIR, "Linux_Client.py"), os.path.join(app_dir, "Linux_Client.py")) shutil.copy(os.path.join(SRC_DIR, "Linux_Client.py"), os.path.join(app_dir, "Linux_Client.py"))
client_out = os.path.join(DIST_DIR, "Linux_Client.bin") client_out = os.path.join(DIST_DIR, "Linux_Client.bin")
zipapp.create_archive( zipapp.create_archive(
source=app_dir, source=app_dir,
@@ -66,7 +67,7 @@ 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(ROOT_DIR, "Server.py"), os.path.join(srv_dir, "Server.py")) shutil.copy(os.path.join(SRC_DIR, "Server.py"), os.path.join(srv_dir, "Server.py"))
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,
@@ -77,10 +78,10 @@ def build_linux_zipapp_fallback():
def build_windows(): def build_windows():
print("[*] Compiling Windows standalone executables...") print("[*] Compiling Windows standalone executables...")
win_client_script = os.path.join(ROOT_DIR, "Win_Client.py") win_client_script = os.path.join(SRC_DIR, "Win_Client.py")
build_pyinstaller_binary(win_client_script, "Win_Client") build_pyinstaller_binary(win_client_script, "Win_Client")
server_script = os.path.join(ROOT_DIR, "Server.py") server_script = os.path.join(SRC_DIR, "Server.py")
build_pyinstaller_binary(server_script, "Server") build_pyinstaller_binary(server_script, "Server")
client_bin = os.path.join(DIST_DIR, "Win_Client.exe") client_bin = os.path.join(DIST_DIR, "Win_Client.exe")
@@ -111,10 +112,10 @@ def build_linux():
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(ROOT_DIR, "Linux_Client.py") linux_client_script = os.path.join(SRC_DIR, "Linux_Client.py")
build_pyinstaller_binary(linux_client_script, "Linux_Client.bin") build_pyinstaller_binary(linux_client_script, "Linux_Client.bin")
server_script = os.path.join(ROOT_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")
# Normalize extensions in case PyInstaller dropped .bin # Normalize extensions in case PyInstaller dropped .bin
@@ -4,7 +4,8 @@ import json
import argparse import argparse
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import mimetypes ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.dirname(__file__))
import package_dist import package_dist
import time import time
@@ -162,8 +163,8 @@ def main():
if not notes and args.notes_file and os.path.exists(args.notes_file): if not notes and args.notes_file and os.path.exists(args.notes_file):
with open(args.notes_file, "r", encoding="utf-8") as nf: with open(args.notes_file, "r", encoding="utf-8") as nf:
notes = nf.read() notes = nf.read()
elif not notes and os.path.exists("RELEASE_NOTES.md"): elif not notes and os.path.exists(os.path.join(ROOT_DIR, "RELEASE_NOTES.md")):
with open("RELEASE_NOTES.md", "r", encoding="utf-8") as nf: with open(os.path.join(ROOT_DIR, "RELEASE_NOTES.md"), "r", encoding="utf-8") as nf:
notes = nf.read() notes = nf.read()
elif not notes: elif not notes:
notes = ( notes = (
@@ -184,9 +185,9 @@ def main():
print("[*] Assembling compiled binaries...") print("[*] Assembling compiled binaries...")
package_dist.main() package_dist.main()
dist_dir = os.path.abspath("dist") dist_dir = os.path.join(ROOT_DIR, "dist")
if not os.path.exists(dist_dir) or not os.listdir(dist_dir): if not os.path.exists(dist_dir) or not os.listdir(dist_dir):
print("[!] No binaries found in dist/. Run package_dist.py first.") print(f"[!] No binaries found in {dist_dir}. Run package_dist.py first.")
sys.exit(1) sys.exit(1)
print(f"[*] Connecting to Gitea: {args.url} (repo: {args.repo})...") print(f"[*] Connecting to Gitea: {args.url} (repo: {args.repo})...")
+1 -1
View File
@@ -22,7 +22,7 @@ Standalone compiled binary distribution for Linux edge servers running systemd.
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 to export a client bundle tailored for your environment:
```bash ```bash
python 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.
+208
View File
@@ -0,0 +1,208 @@
# LOGAR Linux Server Hub
Standalone compiled executable binary distribution for Linux server environments (`Server.bin`).
---
## Overview
`Server.bin` is a self-contained, pre-compiled Linux ELF executable that operates as the central coordination and log analysis hub of the LOGAR telemetry architecture.
### Key Architecture & Capabilities
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Linux ELF binary (`Server.bin`). No Python runtime, pip dependencies, or GnuPG binaries are required on the host system.
- **Authenticated TCP Ingestion Socket (Port 9443)**: Accepts framed OpenPGP encrypted log batches streamed by edge forwarders (`Linux_Client.bin` and `Win_Client.exe`).
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a sliding 12-hour evaluation window before promotion from transient noise to `VERIFIED`.
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external scrapers, SIEM collectors, and alerting dashboards.
- **Pure-Python OpenPGP Cryptography**: Zero dependency on external `gpg` binaries. Automatically generates RSA-2048 encryption keys and SHA-256 fingerprints on first launch.
- **State Database**: Tracks anomaly lifecycles, run counters, and machine telemetry in a local SQLite state database (`logar_state.db`).
---
## 1. Initializing & Generating Server Configuration
### Step 1: Automatic First-Run Generation
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
1. A fresh OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
3. A cryptographically random secret authentication token (`auth_token`).
4. Default network socket coordinates (TCP 9443, Hermes API 8443).
Run `Server.bin` once to initialize:
```bash
./Server.bin
```
Output:
```
[!] Config 'server_config.json' not found. Initializing first-run configuration...
[+] Successfully generated new server config and OpenPGP keypair.
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
[+] Saved to: server_config.json
```
### Step 2: Configuration Fields Reference
The generated `server_config.json` contains:
```json
{
"server_name": "LOGAR-Linux-Hub",
"tcp_host": "0.0.0.0",
"tcp_port": 9443,
"hermes_host": "0.0.0.0",
"hermes_port": 8443,
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
"db_path": "logar_state.db",
"evaluation_window_hours": 12,
"min_persistence_runs": 4,
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
}
```
| Parameter | Default | Description |
| :--- | :--- | :--- |
| `server_name` | `"LOGAR-Linux-Hub"` | Human-readable identifier for this hub instance |
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for edge client TCP ingestion |
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
| `auth_token` | *(auto-generated)* | Pre-shared secret required in edge client envelopes |
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
| `evaluation_window_hours` | `12` | Sliding temporal window for warning persistence |
| `min_persistence_runs` | `4` | Number of distinct runs required to promote warnings to `VERIFIED` |
---
## 2. Generating Client Configuration Bundles
Edge forwarders (`Linux_Client.bin` and `Win_Client.exe`) require a minimal, anonymous configuration bundle containing socket coordinates, the authentication token, and the server's public key (without sensitive server names or private keys).
Run the following command on the server:
```bash
./Server.bin --create-client-config --server-host <SERVER_PUBLIC_OR_INTERNAL_IP> --server-port 9443 --client-out client_config.json
```
- Replace `<SERVER_PUBLIC_OR_INTERNAL_IP>` with the reachable IP or FQDN of your LOGAR server.
- The output `client_config.json` can be distributed directly to Linux and Windows edge forwarder nodes.
---
## 3. Running Interactively
```bash
./Server.bin --config /path/to/server_config.json
```
### Command-Line Arguments
| Argument | Description |
| :--- | :--- |
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
| `--server-host` | Hostname/IP to embed in the exported client configuration |
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
---
## 4. Installing as a Systemd Service (Recommended)
Running `Server.bin` as a native systemd background service ensures continuous execution, automatic restart upon reboot or crash, and centralized log management via `journalctl`.
### Step 1: Create Deployment Directory and User
```bash
# Create dedicated system group and user
sudo useradd --system --no-create-home --shell /usr/sbin/nologin logar
# Prepare deployment folder
sudo mkdir -p /opt/logar-server
sudo cp Server.bin server_config.json /opt/logar-server/
sudo chmod +x /opt/logar-server/Server.bin
sudo chown -R logar:logar /opt/logar-server
```
### Step 2: Create Systemd Service File
Create `/etc/systemd/system/logar-server.service`:
```ini
[Unit]
Description=LOGAR Central Server Hub Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=logar
Group=logar
WorkingDirectory=/opt/logar-server
ExecStart=/opt/logar-server/Server.bin --config /opt/logar-server/server_config.json
Restart=always
RestartSec=5
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
### Step 3: Enable and Start Service
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now logar-server.service
```
### Step 4: Verify Status and Inspect Logs
```bash
# Check service status
sudo systemctl status logar-server.service
# Stream live server logs
sudo journalctl -u logar-server.service -f
```
---
## 5. Hermes Reporting API & Integration
The server embeds a high-performance HTTP service on port `8443` providing real-time intelligence on promoted anomalies:
### Fetching Promoted Anomalies
```bash
curl -s http://127.0.0.1:8443/api/hermes/report | jq .
```
### Response Schema:
```json
[
{
"fingerprint": "prod-web-01.corp.internal:Out_Of_Memory",
"server": "prod-web-01.corp.internal",
"signature": "Out_Of_Memory",
"consecutive_runs": 4,
"first_seen": "2026-09-04T08:00:00Z",
"last_seen": "2026-09-04T14:30:00Z",
"status": "VERIFIED",
"verified": true,
"os_type": "linux",
"sample_message": "kernel: Out of memory: Kill process 1824"
}
]
```
---
## 6. Firewall Configuration
Ensure the following inbound ports are open on your host firewall:
```bash
# UFW (Ubuntu / Debian)
sudo ufw allow 9443/tcp comment "LOGAR TCP Log Ingestion"
sudo ufw allow 8443/tcp comment "LOGAR Hermes Reporting API"
sudo ufw reload
# Firewalld (RHEL / CentOS / Rocky / Alma)
sudo firewall-cmd --permanent --add-port=9443/tcp
sudo firewall-cmd --permanent --add-port=8443/tcp
sudo firewall-cmd --reload
```
@@ -0,0 +1,14 @@
{
"server_name": "LOGAR-Linux-Hub",
"tcp_host": "0.0.0.0",
"tcp_port": 9443,
"hermes_host": "0.0.0.0",
"hermes_port": 8443,
"auth_token": "replace_with_secure_random_hex_token",
"db_path": "logar_state.db",
"evaluation_window_hours": 12,
"min_persistence_runs": 4,
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
}
+1 -1
View File
@@ -22,7 +22,7 @@ Standalone compiled executable distribution for Windows Server and workstation e
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 to export a client bundle tailored for your environment:
```bash ```bash
python 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.
+237
View File
@@ -0,0 +1,237 @@
# LOGAR Windows Server Hub
Standalone compiled executable distribution for Windows Server environments (`Server.exe`).
---
## Overview
`Server.exe` is a self-contained, pre-compiled native Windows PE executable that serves as the central log aggregation, temporal persistence analyzer, and reporting hub of the LOGAR infrastructure.
### Key Architecture & Capabilities
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
- **Authenticated TCP Ingestion Socket (Port 9443)**: Ingests framed OpenPGP encrypted log batches streamed from edge forwarder nodes (`Win_Client.exe` and `Linux_Client.bin`).
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events are evaluated against an episodic threshold, requiring persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window before promotion to `VERIFIED`.
- **Embedded Hermes Reporting API (Port 8443)**: Integrated REST API exposing `/api/hermes/report` for external dashboards, monitoring agents, and scrapers.
- **Pure-Python OpenPGP Cryptography**: Automatically generates RSA-2048 encryption keys and a SHA-256 fingerprint on first launch without external dependencies.
- **State Database**: Stores issue lifecycle records, run counters, and machine telemetry in a local SQLite database (`logar_state.db`).
---
## 1. Initializing & Generating Server Configuration
### Step 1: Automatic First-Run Generation
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
1. An OpenPGP RSA-2048 encryption keypair (`private_key` and `public_key`).
2. A SHA-256 public encryption fingerprint (`server_fingerprint`).
3. A cryptographically random secret authentication token (`auth_token`).
4. Default network socket coordinates (TCP 9443, Hermes API 8443).
Open PowerShell and run:
```powershell
.\Server.exe
```
Output:
```
[!] Config 'server_config.json' not found. Initializing first-run configuration...
[+] Successfully generated new server config and OpenPGP keypair.
[+] Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
[+] Saved to: server_config.json
```
### Step 2: Configuration Fields Reference
The generated `server_config.json` contains:
```json
{
"server_name": "LOGAR-Windows-Hub",
"tcp_host": "0.0.0.0",
"tcp_port": 9443,
"hermes_host": "0.0.0.0",
"hermes_port": 8443,
"auth_token": "a1b2c3d4e5f67890abcdef1234567890...",
"db_path": "logar_state.db",
"evaluation_window_hours": 12,
"min_persistence_runs": 4,
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
"public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
"private_key": "-----BEGIN PGP PRIVATE KEY BLOCK-----\n..."
}
```
| Parameter | Default | Description |
| :--- | :--- | :--- |
| `server_name` | `"LOGAR-Windows-Hub"` | Identifier for this hub instance |
| `tcp_host` | `"0.0.0.0"` | Network interface to bind for incoming client socket traffic |
| `tcp_port` | `9443` | TCP port for incoming edge log batches |
| `hermes_host` | `"0.0.0.0"` | Network interface to bind for Hermes HTTP API |
| `hermes_port` | `8443` | HTTP port for the Hermes reporting endpoint |
| `auth_token` | *(auto-generated)* | Pre-shared authentication secret required in client envelopes |
| `db_path` | `"logar_state.db"` | Path to persistent SQLite issue database |
| `evaluation_window_hours` | `12` | Rolling evaluation window in hours for warning persistence |
| `min_persistence_runs` | `4` | Consecutive runs required to promote warning issues to `VERIFIED` |
---
## 2. Generating Client Configuration Bundles
Edge forwarders (`Win_Client.exe` and `Linux_Client.bin`) require an anonymous client configuration bundle that includes the server socket target, authentication token, and encryption public key, without exposing sensitive server names or private keys.
Run the following command on the server:
```powershell
.\Server.exe --create-client-config --server-host <SERVER_IP_OR_FQDN> --server-port 9443 --client-out client_config.json
```
- Replace `<SERVER_IP_OR_FQDN>` with the reachable IP or DNS name of your LOGAR server.
- Distribute `client_config.json` to client forwarder nodes along with `Win_Client.exe` or `Linux_Client.bin`.
---
## 3. Running Interactively
```powershell
.\Server.exe --config C:\LOGAR-Server\server_config.json
```
### Command-Line Arguments
| Argument | Description |
| :--- | :--- |
| `--config` | Path to server configuration JSON file (default: `server_config.json`) |
| `--create-client-config` | Exports an anonymous client configuration bundle and exits |
| `--server-host` | Hostname/IP to embed in the exported client configuration |
| `--server-port` | Port to embed in the exported client configuration (default: `9443`) |
| `--client-out` | Destination path for exported client configuration (default: `client_config.json`) |
---
## 4. Installing as a Continuous Windows Service
Because `Server.exe` acts as a continuous server hub (listening for TCP connections and HTTP API queries), it should run persistently in the background.
### Method A: Native Windows Service via NSSM (Recommended)
[NSSM (Non-Sucking Service Manager)](https://nssm.cc/) is the industry standard for wrapping standalone executables into formal Windows services managed by `services.msc`.
1. Place `Server.exe` and `server_config.json` in `C:\LOGAR-Server\`.
2. Open **Elevated PowerShell (Run as Administrator)**:
```powershell
# Create deployment folder
New-Item -ItemType Directory -Path "C:\LOGAR-Server" -Force
Copy-Item "Server.exe", "server_config.json" -Destination "C:\LOGAR-Server\"
# Install Windows Service via NSSM
nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe" "--config C:\LOGAR-Server\server_config.json"
nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
nssm.exe set LOGAR_Server Description "LOGAR Central Aggregation Hub Service"
nssm.exe set LOGAR_Server Start SERVICE_AUTO_START
nssm.exe set LOGAR_Server AppStdout "C:\LOGAR-Server\server_out.log"
nssm.exe set LOGAR_Server AppStderr "C:\LOGAR-Server\server_err.log"
# Start the service
nssm.exe start LOGAR_Server
```
3. Verify status in PowerShell:
```powershell
Get-Service -Name "LOGAR_Server"
```
### Method B: Windows Task Scheduler (Startup Daemon)
If third-party service wrappers are restricted by organizational policy, configure a Task Scheduler job triggered at boot under the `SYSTEM` account:
```powershell
# Action: Launch Server.exe
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR-Server\Server.exe" `
-Argument "--config C:\LOGAR-Server\server_config.json" `
-WorkingDirectory "C:\LOGAR-Server"
# Trigger: At system startup
$Trigger = New-ScheduledTaskTrigger -AtStartup
# Settings: Restart on failure, no execution time limit
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero)
# Register task under SYSTEM with highest privileges
Register-ScheduledTask -TaskName "LOGAR_Server_Daemon" `
-Action $Action `
-Trigger $Trigger `
-Settings $Settings `
-User "NT AUTHORITY\SYSTEM" `
-RunLevel Highest `
-Description "LOGAR Central Hub Daemon"
# Start the task immediately
Start-ScheduledTask -TaskName "LOGAR_Server_Daemon"
Get-ScheduledTask -TaskName "LOGAR_Server_Daemon"
```
---
## 5. Hermes Reporting API & Health Checks
Test the embedded Hermes REST endpoint locally using PowerShell:
```powershell
$report = Invoke-RestMethod -Uri "http://127.0.0.1:8443/api/hermes/report" -Method GET
$report | Format-Table fingerprint, status, consecutive_runs, first_seen, last_seen
```
### Response Format:
```json
[
{
"fingerprint": "win-dc-01.corp.internal:DiskCorruptionDetected",
"server": "win-dc-01.corp.internal",
"signature": "DiskCorruptionDetected",
"consecutive_runs": 4,
"first_seen": "2026-09-04T08:15:00Z",
"last_seen": "2026-09-04T15:00:00Z",
"status": "VERIFIED",
"verified": true,
"os_type": "windows",
"sample_message": "An error was detected on device \\Device\\Harddisk0\\DR0 during a paging operation."
}
]
```
---
## 6. Windows Defender Firewall Configuration
Open the necessary inbound firewall ports to allow incoming edge forwarder socket streams and HTTP API queries:
```powershell
# Allow TCP 9443 for edge log forwarding
New-NetFirewallRule -DisplayName "LOGAR TCP Log Ingestion" `
-Direction Inbound `
-LocalPort 9443 `
-Protocol TCP `
-Action Allow
# Allow TCP 8443 for Hermes Reporting REST API
New-NetFirewallRule -DisplayName "LOGAR Hermes Reporting API" `
-Direction Inbound `
-LocalPort 8443 `
-Protocol TCP `
-Action Allow
```
---
## 7. Uninstallation & Removal
To remove the server service:
```powershell
# If installed via NSSM:
nssm.exe stop LOGAR_Server
nssm.exe remove LOGAR_Server confirm
# If installed via Task Scheduler:
Unregister-ScheduledTask -TaskName "LOGAR_Server_Daemon" -Confirm:$false
# Clean files
Remove-Item -Recurse -Force "C:\LOGAR-Server"
```
+14
View File
@@ -0,0 +1,14 @@
{
"server_name": "LOGAR-Windows-Hub",
"tcp_host": "0.0.0.0",
"tcp_port": 9443,
"hermes_host": "0.0.0.0",
"hermes_port": 8443,
"auth_token": "replace_with_secure_random_hex_token",
"db_path": "logar_state.db",
"evaluation_window_hours": 12,
"min_persistence_runs": 4,
"server_fingerprint": "AUTO_GENERATED_ON_FIRST_RUN",
"public_key": "AUTO_GENERATED_ON_FIRST_RUN",
"private_key": "AUTO_GENERATED_ON_FIRST_RUN"
}
+120 -18
View File
@@ -2,9 +2,11 @@ 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, Dict, Any, List
@@ -18,6 +20,68 @@ CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json" STATE_FILE_NAME = "client_state.json"
def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "linux"):
"""Bootstraps client enrollment if certificates are missing."""
os.makedirs(cert_dir, exist_ok=True)
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
return True
print(f"[*] Bootstrapping client enrollment with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
payload = {
"client_id": client_id,
"hostname": hostname,
"os": os_type,
"enrollment_secret": enrollment_secret
}
req = urllib.request.Request(
enroll_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
data = json.loads(resp.read().decode("utf-8"))
with open(ca_path, "w", encoding="utf-8") as f:
f.write(data["ca_cert"])
with open(cert_path, "w", encoding="utf-8") as f:
f.write(data["client_cert"])
with open(key_path, "w", encoding="utf-8") as f:
f.write(data["client_key"])
try:
os.chmod(key_path, 0o600)
except Exception:
pass
print(f"[+] Client enrolled successfully! Certificates saved to {os.path.abspath(cert_dir)}")
return True
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.check_hostname = False
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str: 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 +291,80 @@ 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
if enrollment_secret:
hermes_host = config.get("hermes_host", server_host)
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
except Exception as e:
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
with get_tls_socket(server_host, server_port, cert_dir) as sock:
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed mTLS connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
# Fallback to OpenPGP envelope over plain TCP socket
auth_token = config.get("auth_token", "")
pub_key_armored = config.get("server_public_key")
if not pub_key_armored:
raise ValueError("No server public key or mTLS certificates available for connection.")
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored) 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 +372,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.")
+293 -53
View File
@@ -23,9 +23,16 @@ from pgpy.constants import (
SymmetricKeyAlgorithm, SymmetricKeyAlgorithm,
CompressionAlgorithm CompressionAlgorithm
) )
import ssl
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
import uvicorn import uvicorn
try:
from src import server_enrollment as enrollment
except ImportError:
import server_enrollment as enrollment
CONFIG_FILE_NAME = "server_config.json" CONFIG_FILE_NAME = "server_config.json"
DEFAULT_DB_FILE = "logar_state.db" DEFAULT_DB_FILE = "logar_state.db"
EVALUATION_WINDOW_HOURS = 12 EVALUATION_WINDOW_HOURS = 12
@@ -37,6 +44,13 @@ app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="2.0.0")
SERVER_STATE: Dict[str, Any] = {} SERVER_STATE: Dict[str, Any] = {}
class ClientEnrollRequest(BaseModel):
client_id: str
hostname: str
os: str
enrollment_secret: str
def generate_server_keypair(server_name: str): def generate_server_keypair(server_name: str):
"""Generates an OpenPGP RSA 2048 key with encryption capability.""" """Generates an OpenPGP RSA 2048 key with encryption capability."""
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048) key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
@@ -60,12 +74,21 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}") print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f) config = json.load(f)
if "enrollment_secret" not in config:
config["enrollment_secret"] = secrets.token_hex(24)
if "max_seats" not in config:
config["max_seats"] = 10
if "cert_dir" not in config:
config["cert_dir"] = "certs"
if "tls_enabled" not in config:
config["tls_enabled"] = True
return config return config
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...") print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
server_name = "LOGAR-Cloud-Hub" server_name = "LOGAR-Cloud-Hub"
private_key, public_key, fingerprint = generate_server_keypair(server_name) private_key, public_key, fingerprint = generate_server_keypair(server_name)
auth_token = secrets.token_hex(24) auth_token = secrets.token_hex(24)
enrollment_secret = secrets.token_hex(24)
config = { config = {
"server_name": server_name, "server_name": server_name,
@@ -74,6 +97,10 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
"hermes_host": "0.0.0.0", "hermes_host": "0.0.0.0",
"hermes_port": 8443, "hermes_port": 8443,
"auth_token": auth_token, "auth_token": auth_token,
"enrollment_secret": enrollment_secret,
"max_seats": 10,
"cert_dir": "certs",
"tls_enabled": True,
"db_path": DEFAULT_DB_FILE, "db_path": DEFAULT_DB_FILE,
"evaluation_window_hours": EVALUATION_WINDOW_HOURS, "evaluation_window_hours": EVALUATION_WINDOW_HOURS,
"min_persistence_runs": RUN_THRESHOLD, "min_persistence_runs": RUN_THRESHOLD,
@@ -95,7 +122,9 @@ def create_client_config(
server_host: str, server_host: str,
server_port: int, server_port: int,
output_path: str, output_path: str,
config_path: str = CONFIG_FILE_NAME config_path: str = CONFIG_FILE_NAME,
hermes_host: Optional[str] = None,
hermes_port: Optional[int] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint.""" """Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
server_conf = load_or_init_config(config_path) server_conf = load_or_init_config(config_path)
@@ -103,6 +132,10 @@ def create_client_config(
client_conf = { client_conf = {
"server_host": server_host, "server_host": server_host,
"server_port": server_port, "server_port": server_port,
"hermes_host": hermes_host or server_conf.get("hermes_host", "127.0.0.1"),
"hermes_port": hermes_port or server_conf.get("hermes_port", 8443),
"enrollment_secret": server_conf.get("enrollment_secret"),
"cert_dir": "certs",
"server_fingerprint": server_conf["server_fingerprint"], "server_fingerprint": server_conf["server_fingerprint"],
"server_public_key": server_conf["public_key"], "server_public_key": server_conf["public_key"],
"auth_token": server_conf["auth_token"] "auth_token": server_conf["auth_token"]
@@ -121,8 +154,8 @@ def create_client_config(
return client_conf return client_conf
def init_db(db_path: str): def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10):
"""Initializes the SQLite schema for multi-run temporal tracking.""" """Initializes the SQLite schema for multi-run temporal tracking, client tracking, and license quota."""
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS active_issues ( CREATE TABLE IF NOT EXISTS active_issues (
@@ -149,6 +182,29 @@ def init_db(db_path: str):
log_count INTEGER log_count INTEGER
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS license_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_seats INTEGER NOT NULL DEFAULT 10,
enrollment_secret TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS clients (
client_id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
os_type TEXT NOT NULL,
cert_fingerprint TEXT NOT NULL,
status TEXT DEFAULT 'active',
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
if enrollment_secret:
conn.execute("""
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
VALUES (1, ?, ?)
""", (max_seats, enrollment_secret))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -183,6 +239,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
if severity in ["DEBUG", "TRACE"]: if severity in ["DEBUG", "TRACE"]:
continue continue
# Errors are always passed immediately; the 4-run rule only concerns warnings
is_error = severity in ["ERROR", "CRITICAL", "FATAL"]
signature = log.get("signature", "unknown") signature = log.get("signature", "unknown")
server = log.get("server", client_server) server = log.get("server", client_server)
message = log.get("message", "") message = log.get("message", "")
@@ -207,7 +266,7 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
# Window elapsed: reset to new cycle # Window elapsed: reset to new cycle
new_runs = 1 new_runs = 1
new_first_seen = now_iso new_first_seen = now_iso
new_status = "TRANSIENT" new_status = "VERIFIED" if is_error else "TRANSIENT"
else: else:
# Same run guard: only increment count once per distinct run batch # Same run guard: only increment count once per distinct run batch
if last_run_id != run_id: if last_run_id != run_id:
@@ -215,8 +274,8 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
else: else:
new_runs = run_count new_runs = run_count
new_first_seen = first_seen_str new_first_seen = first_seen_str
# 4-run rule enforcement # 4-run rule applies to warnings; errors are always passed immediately as VERIFIED
new_status = "VERIFIED" if new_runs >= min_runs else "TRANSIENT" new_status = "VERIFIED" if (is_error or new_runs >= min_runs) else "TRANSIENT"
if new_status == "VERIFIED" and current_status != "VERIFIED": if new_status == "VERIFIED" and current_status != "VERIFIED":
promoted_to_verified += 1 promoted_to_verified += 1
@@ -227,7 +286,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
WHERE fingerprint = ? WHERE fingerprint = ?
""", (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp)) """, (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp))
else: else:
initial_status = "VERIFIED" if 1 >= min_runs else "TRANSIENT" initial_status = "VERIFIED" if (is_error or 1 >= min_runs) else "TRANSIENT"
if initial_status == "VERIFIED":
promoted_to_verified += 1
cursor.execute(""" cursor.execute("""
INSERT INTO active_issues INSERT INTO active_issues
(fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id)
@@ -247,15 +308,60 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
} }
def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext:
"""Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS)."""
ca_file = os.path.join(cert_dir, "ca.crt")
srv_cert = os.path.join(cert_dir, "server.crt")
srv_key = os.path.join(cert_dir, "server.key")
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
ctx.load_verify_locations(cafile=ca_file)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
return ctx
async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
""" """
Authenticated TCP socket handler. mTLS TCP socket handler.
Protocol: Extracts client CN (client_id) from the TLS handshake,
- 4-byte big-endian prefix: payload length validates active license status in SQLite, updates last_seen,
- Payload: JSON with auth_token and encrypted_payload (OpenPGP ASCII armored) reads 4-byte big-endian length-prefixed JSON payload,
- Response: 4-byte length + JSON confirmation and ingests candidate logs into the temporal evaluation engine.
""" """
addr = writer.get_extra_info("peername") client_id = None
ssl_obj = writer.get_extra_info("ssl_object")
if ssl_obj:
peercert = ssl_obj.getpeercert()
if peercert and "subject" in peercert:
for rdn in peercert["subject"]:
for key, val in rdn:
if key == "commonName":
client_id = val
break
# If mTLS is enforced, verify client in accounting database
if SERVER_STATE.get("tls_enabled", False):
if not client_id:
writer.close()
await writer.wait_closed()
return
db_path = SERVER_STATE["config"]["db_path"]
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,))
row = c.fetchone()
if not row or row[0] != "active":
conn.close()
writer.close()
await writer.wait_closed()
return
c.execute("UPDATE clients SET last_seen = CURRENT_TIMESTAMP WHERE client_id = ?", (client_id,))
conn.commit()
conn.close()
try: try:
# Read 4-byte length prefix # Read 4-byte length prefix
length_bytes = await reader.readexactly(4) length_bytes = await reader.readexactly(4)
@@ -264,26 +370,20 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
raise ValueError(f"Invalid frame size: {length}") raise ValueError(f"Invalid frame size: {length}")
payload_bytes = await reader.readexactly(length) payload_bytes = await reader.readexactly(length)
envelope = json.loads(payload_bytes.decode("utf-8")) raw_payload = json.loads(payload_bytes.decode("utf-8"))
# Authenticate socket client # Support both direct JSON payload over mTLS and legacy OpenPGP envelope
expected_token = SERVER_STATE["config"]["auth_token"] if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"):
provided_token = envelope.get("auth_token") pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"])
if not secrets.compare_digest(str(provided_token), str(expected_token)):
err_msg = json.dumps({"status": "error", "message": "Authentication failed"}).encode("utf-8")
writer.write(struct.pack(">I", len(err_msg)) + err_msg)
await writer.drain()
writer.close()
await writer.wait_closed()
return
# Decrypt payload using server's OpenPGP private key
encrypted_armored = envelope.get("encrypted_payload", "")
pgp_msg = pgpy.PGPMessage.from_blob(encrypted_armored)
priv_key = SERVER_STATE["private_key_obj"] priv_key = SERVER_STATE["private_key_obj"]
decrypted_obj = priv_key.decrypt(pgp_msg) decrypted_obj = priv_key.decrypt(pgp_msg)
decrypted_json_str = decrypted_obj.message log_payload = json.loads(decrypted_obj.message)
log_payload = json.loads(decrypted_json_str) else:
log_payload = raw_payload
# Attach authenticated client_id if not present
if client_id and "server" not in log_payload:
log_payload["server"] = client_id
# Ingest and apply 12h window / 4-run rule # Ingest and apply 12h window / 4-run rule
res = process_ingested_logs( res = process_ingested_logs(
@@ -312,32 +412,143 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
pass pass
@app.get("/api/hermes/report") @app.post("/api/client/enroll")
def get_hermes_report(): def enroll_client(req: ClientEnrollRequest):
""" """
Agentic Integration endpoint: Consumed by Hermes to fetch anomalies that have persisted Enrolls an edge client by validating the enrollment secret,
across the 12-hour evaluation window and satisfied the 4-run rule. checking license seat limits, issuing a signed client certificate + key,
and recording the client in the SQLite accounting database.
""" """
db_path = SERVER_STATE["config"]["db_path"] db_path = SERVER_STATE["config"]["db_path"]
window_hours = SERVER_STATE["config"]["evaluation_window_hours"] conn = sqlite3.connect(db_path)
min_runs = SERVER_STATE["config"]["min_persistence_runs"] c = conn.cursor()
now = datetime.now(timezone.utc)
# 1. Validate enrollment secret against license_config
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
row = c.fetchone()
if not row:
conn.close()
raise HTTPException(status_code=500, detail="License configuration not initialized")
expected_secret, max_seats = row
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
conn.close()
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
ca_cert = SERVER_STATE.get("ca_cert")
ca_key = SERVER_STATE.get("ca_key")
ca_cert_pem = SERVER_STATE.get("ca_cert_pem")
if not ca_cert or not ca_key:
conn.close()
raise HTTPException(status_code=500, detail="Root CA not loaded on server")
# 2. Check if client_id already registered
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
client_row = c.fetchone()
if client_row:
if client_row[0] == "revoked":
conn.close()
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
# Re-issue for existing active client
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = CURRENT_TIMESTAMP
WHERE client_id = ?
""", (req.hostname, req.os, fp, req.client_id))
conn.commit()
conn.close()
print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})")
return {
"ca_cert": ca_cert_pem,
"client_cert": client_cert_pem,
"client_key": client_key_pem
}
# 3. New client: check seat limits
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
active_count = c.fetchone()[0]
if active_count >= max_seats:
conn.close()
raise HTTPException(status_code=403, detail="License seat limit reached")
# 4. Issue signed cert + key
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status)
VALUES (?, ?, ?, ?, 'active')
""", (req.client_id, req.hostname, req.os, fp))
conn.commit()
conn.close()
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
return {
"ca_cert": ca_cert_pem,
"client_cert": client_cert_pem,
"client_key": client_key_pem
}
@app.get("/api/clients")
def list_clients():
"""Returns all registered clients and license seat usage."""
db_path = SERVER_STATE["config"]["db_path"]
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
lic_row = c.fetchone()
max_seats = lic_row[0] if lic_row else 10
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients")
rows = c.fetchall()
conn.close()
clients = [
{
"client_id": r[0],
"hostname": r[1],
"os_type": r[2],
"cert_fingerprint": r[3],
"status": r[4],
"first_seen": r[5],
"last_seen": r[6]
}
for r in rows
]
active_count = sum(1 for cl in clients if cl["status"] == "active")
return {
"active_seats": active_count,
"max_seats": max_seats,
"clients": clients
}
@app.get("/api/hermes/report")
def get_verified_anomalies_for_hermes():
"""
Ingestion endpoint for Hermes agentic workflows.
Returns only verified anomalies that have satisfied the 4-run persistence rule
within the active 12-hour evaluation window. Transient blips (< 4 runs) are excluded.
"""
db_path = SERVER_STATE["config"]["db_path"]
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
FROM active_issues FROM active_issues
WHERE status = 'VERIFIED' AND run_count >= ? WHERE status = 'VERIFIED'
""", (min_runs,)) ORDER BY last_seen DESC
""")
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
report = [] report = []
for r in rows: for r in rows:
last_seen_dt = datetime.fromisoformat(r[8])
# Only return anomalies active within the evaluation window
if (now - last_seen_dt) <= timedelta(hours=window_hours):
report.append({ report.append({
"fingerprint": r[0], "fingerprint": r[0],
"site": r[1], "site": r[1],
@@ -349,7 +560,7 @@ def get_hermes_report():
"first_seen": r[7], "first_seen": r[7],
"last_seen": r[8], "last_seen": r[8],
"consecutive_runs": r[9], "consecutive_runs": r[9],
"evaluation_window": f"{window_hours}h", "evaluation_window": f"{SERVER_STATE['config']['evaluation_window_hours']}h",
"verified": True, "verified": True,
"status": r[10] "status": r[10]
}) })
@@ -395,26 +606,30 @@ def health_check():
"server_name": SERVER_STATE["config"]["server_name"], "server_name": SERVER_STATE["config"]["server_name"],
"fingerprint": SERVER_STATE["config"]["server_fingerprint"], "fingerprint": SERVER_STATE["config"]["server_fingerprint"],
"tcp_port": SERVER_STATE["config"]["tcp_port"], "tcp_port": SERVER_STATE["config"]["tcp_port"],
"hermes_port": SERVER_STATE["config"]["hermes_port"] "hermes_port": SERVER_STATE["config"]["hermes_port"],
"tls_enabled": SERVER_STATE.get("tls_enabled", False)
} }
async def run_server(): async def run_server():
"""Runs the TCP socket listener and the Hermes REST API concurrently.""" """Runs the mTLS TCP socket listener and the Hermes REST API concurrently."""
config = SERVER_STATE["config"] config = SERVER_STATE["config"]
tcp_host = config["tcp_host"] tcp_host = config["tcp_host"]
tcp_port = int(config["tcp_port"]) tcp_port = int(config["tcp_port"])
hermes_host = config["hermes_host"] hermes_host = config["hermes_host"]
hermes_port = int(config["hermes_port"]) hermes_port = int(config["hermes_port"])
ssl_ctx = SERVER_STATE.get("ssl_ctx")
# Start TCP Socket Server # Start mTLS / TCP Socket Server
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port) tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx)
print(f"[*] LOGAR TCP Socket Server listening on {tcp_host}:{tcp_port}") mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP"
print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}")
# Start FastAPI / Uvicorn server for Hermes # Start FastAPI / Uvicorn server for Hermes & Enrollment
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning") uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
uv_server = uvicorn.Server(uv_config) uv_server = uvicorn.Server(uv_config)
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report") print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
await asyncio.gather( await asyncio.gather(
tcp_server.serve_forever(), tcp_server.serve_forever(),
@@ -432,12 +647,35 @@ def main():
args = parser.parse_args() args = parser.parse_args()
config = load_or_init_config(args.config) config = load_or_init_config(args.config)
init_db(config["db_path"]) init_db(
config["db_path"],
enrollment_secret=config.get("enrollment_secret"),
max_seats=config.get("max_seats", 10)
)
# Load OpenPGP private key into memory # Initialize dynamic PKI (Root CA and Server TLS Certificate)
cert_dir = config.get("cert_dir", "certs")
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
ca_cert, ca_key,
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
cert_dir=cert_dir
)
# Initialize mTLS SSLContext if enabled
ssl_ctx = None
if config.get("tls_enabled", True):
ssl_ctx = init_mtls_server_context(cert_dir=cert_dir)
# Load OpenPGP private key into memory (legacy fallback)
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"]) priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
SERVER_STATE["config"] = config SERVER_STATE["config"] = config
SERVER_STATE["private_key_obj"] = priv_key_obj SERVER_STATE["private_key_obj"] = priv_key_obj
SERVER_STATE["ca_cert"] = ca_cert
SERVER_STATE["ca_key"] = ca_key
SERVER_STATE["ca_cert_pem"] = ca_pem
SERVER_STATE["ssl_ctx"] = ssl_ctx
SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True)
if args.create_client_config: if args.create_client_config:
port = args.server_port or config["tcp_port"] port = args.server_port or config["tcp_port"]
@@ -451,8 +689,10 @@ def main():
print("=" * 60) print("=" * 60)
print(f" LOGAR Server Hub: {config['server_name']}") print(f" LOGAR Server Hub: {config['server_name']}")
print(f" Encryption Fingerprint: {config['server_fingerprint']}") print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}")
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | Rule: {config['min_persistence_runs']}+ consecutive runs") print(f" License Quota: {config.get('max_seats', 10)} Active Seats")
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | 4-Run Rule: Warnings | Immediate Pass: Errors")
print("=" * 60) print("=" * 60)
try: try:
+120 -18
View File
@@ -2,8 +2,10 @@ 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, Dict, Any, List
@@ -22,6 +24,68 @@ CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json" STATE_FILE_NAME = "client_state.json"
def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "windows"):
"""Bootstraps client enrollment if certificates are missing."""
os.makedirs(cert_dir, exist_ok=True)
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
return True
print(f"[*] Bootstrapping client enrollment with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
payload = {
"client_id": client_id,
"hostname": hostname,
"os": os_type,
"enrollment_secret": enrollment_secret
}
req = urllib.request.Request(
enroll_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
data = json.loads(resp.read().decode("utf-8"))
with open(ca_path, "w", encoding="utf-8") as f:
f.write(data["ca_cert"])
with open(cert_path, "w", encoding="utf-8") as f:
f.write(data["client_cert"])
with open(key_path, "w", encoding="utf-8") as f:
f.write(data["client_key"])
try:
os.chmod(key_path, 0o600)
except Exception:
pass
print(f"[+] Client enrolled successfully! Certificates saved to {os.path.abspath(cert_dir)}")
return True
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.check_hostname = False
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str: 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
@@ -194,39 +258,80 @@ 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
if enrollment_secret:
hermes_host = config.get("hermes_host", server_host)
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
except Exception as e:
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
with get_tls_socket(server_host, server_port, cert_dir) as sock:
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed mTLS connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
# Fallback to OpenPGP envelope over plain TCP socket
auth_token = config.get("auth_token", "")
pub_key_armored = config.get("server_public_key")
if not pub_key_armored:
raise ValueError("No server public key or mTLS certificates available for connection.")
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored) 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 +339,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.")
View File
+267
View File
@@ -0,0 +1,267 @@
import os
import datetime
import ipaddress
from typing import Tuple, List, Optional
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def calculate_cert_fingerprint(cert_pem: str) -> str:
"""Computes SHA-256 fingerprint for a PEM-encoded X.509 certificate."""
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
return cert.fingerprint(hashes.SHA256()).hex().upper()
def generate_ca_if_needed(cert_dir: str = "certs", common_name: str = "LOGAR-Root-CA") -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]:
"""
Loads an existing Root CA or generates a self-signed Root CA certificate and private key.
Returns (ca_cert_obj, ca_key_obj, ca_cert_pem, ca_key_pem).
"""
os.makedirs(cert_dir, exist_ok=True)
ca_cert_path = os.path.join(cert_dir, "ca.crt")
ca_key_path = os.path.join(cert_dir, "ca.key")
if os.path.exists(ca_cert_path) and os.path.exists(ca_key_path):
with open(ca_cert_path, "r", encoding="utf-8") as f:
ca_cert_pem = f.read()
with open(ca_key_path, "r", encoding="utf-8") as f:
ca_key_pem = f.read()
ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(ca_key_pem.encode("utf-8"), password=None)
return ca_cert, ca_key, ca_cert_pem, ca_key_pem
# Generate RSA 4096 private key for Root CA
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"),
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
])
now = datetime.datetime.now(datetime.timezone.utc)
ca_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=False,
key_cert_sign=True,
crl_sign=True,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()),
critical=False
)
.sign(ca_key, hashes.SHA256())
)
ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
ca_key_pem = ca_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")
with open(ca_cert_path, "w", encoding="utf-8") as f:
f.write(ca_cert_pem)
with open(ca_key_path, "w", encoding="utf-8") as f:
f.write(ca_key_pem)
try:
os.chmod(ca_key_path, 0o600)
except Exception:
pass
return ca_cert, ca_key, ca_cert_pem, ca_key_pem
def generate_server_cert_if_needed(
ca_cert: x509.Certificate,
ca_key: rsa.RSAPrivateKey,
hostnames: Optional[List[str]] = None,
cert_dir: str = "certs",
days_valid: int = 825
) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]:
"""
Loads an existing server certificate or generates a new server TLS certificate signed by the Root CA.
Includes SANs for localhost, 127.0.0.1, and specified hostnames.
"""
os.makedirs(cert_dir, exist_ok=True)
server_cert_path = os.path.join(cert_dir, "server.crt")
server_key_path = os.path.join(cert_dir, "server.key")
if os.path.exists(server_cert_path) and os.path.exists(server_key_path):
with open(server_cert_path, "r", encoding="utf-8") as f:
server_cert_pem = f.read()
with open(server_key_path, "r", encoding="utf-8") as f:
server_key_pem = f.read()
srv_cert = x509.load_pem_x509_certificate(server_cert_pem.encode("utf-8"))
srv_key = serialization.load_pem_private_key(server_key_pem.encode("utf-8"), password=None)
return srv_cert, srv_key, server_cert_pem, server_key_pem
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"),
x509.NameAttribute(NameOID.COMMON_NAME, "LOGAR-Server-Hub"),
])
san_list = [
x509.DNSName("localhost"),
x509.DNSName("LOGAR-Server-Hub"),
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
x509.IPAddress(ipaddress.IPv6Address("::1")),
]
if hostnames:
for host in hostnames:
if not host:
continue
try:
ip_obj = ipaddress.ip_address(host)
san_list.append(x509.IPAddress(ip_obj))
except ValueError:
san_list.append(x509.DNSName(host))
now = datetime.datetime.now(datetime.timezone.utc)
server_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(server_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()),
critical=False
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
critical=False
)
.add_extension(x509.SubjectAlternativeName(san_list), critical=False)
.sign(ca_key, hashes.SHA256())
)
server_cert_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
server_key_pem = server_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")
with open(server_cert_path, "w", encoding="utf-8") as f:
f.write(server_cert_pem)
with open(server_key_path, "w", encoding="utf-8") as f:
f.write(server_key_pem)
try:
os.chmod(server_key_path, 0o600)
except Exception:
pass
return server_cert, server_key, server_cert_pem, server_key_pem
def issue_client_cert(
client_id: str,
ca_cert: x509.Certificate,
ca_key: rsa.RSAPrivateKey,
days_valid: int = 365
) -> Tuple[str, str]:
"""
Generates a 2048-bit RSA private key and signs an X.509 client certificate
with Common Name set to client_id.
Returns (cert_pem, key_pem).
"""
client_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "AT"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "LOGAR"),
x509.NameAttribute(NameOID.COMMON_NAME, client_id),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(client_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=days_valid))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False
),
critical=True
)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]),
critical=False
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()),
critical=False
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
critical=False
)
.sign(ca_key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
key_pem = client_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")
return cert_pem, key_pem
-136
View File
@@ -1,136 +0,0 @@
import os
import sys
import json
import time
import socket
import struct
import sqlite3
import urllib.request
import warnings
from datetime import datetime, timezone, timedelta
warnings.filterwarnings("ignore")
import pgpy
# Test server endpoints
TCP_HOST = "127.0.0.1"
TCP_PORT = 9443
HERMES_HOST = "127.0.0.1"
HERMES_PORT = 8443
def run_tests():
print("=== [1] Verifying server_config.json & client_config.json ===")
assert os.path.exists("server_config.json"), "server_config.json must exist"
assert os.path.exists("client_config.json"), "client_config.json must exist"
with open("client_config.json", "r", encoding="utf-8") as f:
client_conf = json.load(f)
with open("server_config.json", "r", encoding="utf-8") as f:
server_conf = json.load(f)
assert "server_name" not in client_conf, "client_config.json must NOT contain server_name"
assert "name" not in client_conf, "client_config.json must NOT contain name"
assert "site_name" not in client_conf, "client_config.json must NOT contain site_name"
assert client_conf["server_fingerprint"] == server_conf["server_fingerprint"], "Fingerprints must match"
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
# Load public key
pub_key, _ = pgpy.PGPKey.from_blob(client_conf["server_public_key"])
def send_socket_batch(logs, auth_token=client_conf["auth_token"]):
payload = {
"server": "test-edge-node.corp.internal",
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
pgp_msg = pgpy.PGPMessage.new(json.dumps(payload))
enc = pub_key.encrypt(pgp_msg)
envelope = {
"auth_token": auth_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
"encrypted_payload": str(enc)
}
envelope_bytes = json.dumps(envelope).encode("utf-8")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(5.0)
s.connect((TCP_HOST, TCP_PORT))
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
s.sendall(frame)
resp_len_bytes = s.recv(4)
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = s.recv(resp_len)
return json.loads(resp_bytes.decode("utf-8"))
print("\n=== [2] Testing Socket Authentication Failure ===")
bad_resp = send_socket_batch([], auth_token="invalid-token-12345")
assert bad_resp.get("status") == "error", f"Expected error, got: {bad_resp}"
print(f"[OK] Bad auth rejected correctly: {bad_resp['message']}")
test_signature = "TestServiceCrash"
candidate_log = [{
"server": "test-edge-node",
"os_type": "linux",
"signature": test_signature,
"severity": "ERROR",
"message": "Out of memory killer triggered"
}]
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule ===")
for run_num in range(1, 5):
resp = send_socket_batch(candidate_log)
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
print(f"[Run {run_num}/4] Ingested successfully. Promoted to verified: {resp.get('promoted_verified')}")
# Inspect SQLite database directly
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
cursor = conn.cursor()
cursor.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", (test_signature,))
row = cursor.fetchone()
conn.close()
assert row is not None, "Issue not found in SQLite"
run_count, status = row
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 status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
print("[OK] 4-Run Rule verified: Transient issue promoted to VERIFIED anomaly!")
print("\n=== [4] Testing Hermes Reporting Endpoint (/api/hermes/report) ===")
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report")
with urllib.request.urlopen(req, timeout=5) as response:
assert response.status == 200, f"Expected 200, got {response.status}"
hermes_data = json.loads(response.read().decode("utf-8"))
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
found_issue = False
for issue in hermes_data:
print(f" - Fingerprint: {issue['fingerprint']} | Consecutive Runs: {issue['consecutive_runs']} | Status: {issue['status']}")
if issue["signature"] == test_signature:
found_issue = True
assert issue["verified"] is True
assert issue["consecutive_runs"] >= 4
assert found_issue, f"Test issue {test_signature} should be in Hermes report"
print("[OK] Hermes reporting validated!")
print("\n=== [5] Testing Windows Client Script Integration ===")
from Win_Client import get_recent_windows_logs
win_logs = get_recent_windows_logs(hours=24)
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
print("\n=== [6] Testing Linux Client Script Integration ===")
from Linux_Client import get_recent_linux_logs
linux_logs = get_recent_linux_logs(hours=24)
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
print("\n==========================================")
print(" ALL VERIFICATION TESTS PASSED SUCCESSFULLY! ")
print("==========================================")
if __name__ == "__main__":
run_tests()
+26
View File
@@ -8,6 +8,7 @@ import warnings
warnings.filterwarnings("ignore") warnings.filterwarnings("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")))
import Linux_Client import Linux_Client
import pgpy import pgpy
@@ -197,6 +198,31 @@ 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)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+253
View File
@@ -0,0 +1,253 @@
import os
import sys
import json
import time
import socket
import ssl
import struct
import sqlite3
import urllib.request
import urllib.error
import warnings
from datetime import datetime, timezone, timedelta
# Ensure repository root and src/ directory are in sys.path
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SRC_DIR = os.path.join(ROOT_DIR, "src")
sys.path.insert(0, ROOT_DIR)
sys.path.insert(0, SRC_DIR)
warnings.filterwarnings("ignore")
import Win_Client
import Linux_Client
# Test server endpoints
TCP_HOST = "127.0.0.1"
TCP_PORT = 9443
HERMES_HOST = "127.0.0.1"
HERMES_PORT = 8443
def run_tests():
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")
client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json")
assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"
assert os.path.exists(client_cfg_path), f"{client_cfg_path} must exist"
with open(client_cfg_path, "r", encoding="utf-8") as f:
client_conf = json.load(f)
with open(server_cfg_path, "r", encoding="utf-8") as f:
server_conf = json.load(f)
assert "server_name" not in client_conf, "client_config.json must NOT contain server_name"
assert "name" not in client_conf, "client_config.json must NOT contain name"
assert "site_name" not in client_conf, "client_config.json must NOT contain site_name"
assert client_conf["server_fingerprint"] == server_conf["server_fingerprint"], "Fingerprints must match"
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
os.makedirs(cert_dir, exist_ok=True)
client_id = "test-edge-node.corp.internal"
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
# 2a. Test rejection on invalid enrollment secret
bad_enroll_payload = {
"client_id": client_id,
"hostname": client_id,
"os": "linux",
"enrollment_secret": "invalid-secret-xyz"
}
req_bad = urllib.request.Request(
enroll_url,
data=json.dumps(bad_enroll_payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_bad, timeout=5):
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
clients_req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/clients")
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)
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_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"))
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule for Warnings over mTLS ===")
test_signature = "TestServiceDegraded"
candidate_log = [{
"server": client_id,
"os_type": "linux",
"signature": test_signature,
"severity": "WARNING",
"message": "Resource usage high warning"
}]
for run_num in range(1, 5):
resp = send_mtls_batch(candidate_log)
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
promoted = resp.get("promoted_verified", 0)
print(f"[Run {run_num}/4] Ingested successfully via mTLS. Promoted to verified: {promoted}")
if run_num < 4:
assert promoted == 0, f"Expected 0 promoted on run {run_num} for warning, got {promoted}"
else:
assert promoted == 1, f"Expected 1 promoted on run 4 for warning, got {promoted}"
# Inspect SQLite database directly
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
cursor = conn.cursor()
cursor.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", (test_signature,))
row = cursor.fetchone()
conn.close()
assert row is not None, "Issue not found in SQLite"
run_count, status = row
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 status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
print("[OK] 4-Run Rule verified: Warning promoted to VERIFIED anomaly on 4th run over mTLS!")
print("\n=== [4] Testing Immediate Pass for Errors over mTLS ===")
error_signature = "TestServiceCrashImmediate"
error_log = [{
"server": client_id,
"os_type": "linux",
"signature": error_signature,
"severity": "ERROR",
"message": "Fatal process crash occurred"
}]
err_resp = send_mtls_batch(error_log)
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')}")
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"))
cursor = conn.cursor()
cursor.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", (error_signature,))
err_row = cursor.fetchone()
conn.close()
assert err_row is not None, "Error issue not found in SQLite"
err_run_count, err_status = err_row
print(f"[DB Verification] Issue '{error_signature}' -> run_count: {err_run_count}, status: {err_status}")
assert err_run_count == 1, f"Expected run_count == 1, got {err_run_count}"
assert err_status == "VERIFIED", f"Expected status 'VERIFIED', got {err_status}"
print("[OK] Immediate pass verified: Error promoted to VERIFIED anomaly immediately!")
print("\n=== [5] Testing Hermes Reporting Endpoint (/api/hermes/report) ===")
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report")
with urllib.request.urlopen(req, timeout=5) as response:
assert response.status == 200, f"Expected 200, got {response.status}"
hermes_data = json.loads(response.read().decode("utf-8"))
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
found_warning = False
found_error = False
for issue in hermes_data:
print(f" - Fingerprint: {issue['fingerprint']} | Consecutive Runs: {issue['consecutive_runs']} | Status: {issue['status']}")
if issue["signature"] == test_signature:
found_warning = True
assert issue["verified"] is True
assert issue["consecutive_runs"] >= 4
if issue["signature"] == error_signature:
found_error = True
assert issue["verified"] is True
assert issue["consecutive_runs"] == 1
assert found_warning, f"Warning issue {test_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("\n=== [6] Testing Windows Client Script Integration ===")
from Win_Client import get_recent_windows_logs
win_logs = get_recent_windows_logs(hours=24)
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
print("\n=== [7] Testing Linux Client Script Integration ===")
from Linux_Client import get_recent_linux_logs
linux_logs = get_recent_linux_logs(hours=24)
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
import shutil
if os.path.exists(cert_dir):
shutil.rmtree(cert_dir, ignore_errors=True)
print("\n=======================================================")
print(" ALL VERIFICATION TESTS (mTLS + PKI + PIPELINE) PASSED! ")
print("=======================================================")
if __name__ == "__main__":
run_tests()
+165 -15
View File
@@ -11,8 +11,9 @@ from datetime import datetime, timezone, timedelta
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
# Ensure parent directory is in path to import Server # Ensure parent directory and src directory are in path to import Server
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")))
import Server import Server
import pgpy import pgpy
@@ -78,9 +79,9 @@ class TestServerComponent(unittest.TestCase):
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",
"signature": "PostgresConnTimeout", "signature": "PostgresConnWarning",
"severity": "ERROR", "severity": "WARNING",
"message": "Connection to database pool timed out after 30s", "message": "Connection to database pool near capacity: 85%",
"os_type": "linux" "os_type": "linux"
} }
payload = { payload = {
@@ -88,7 +89,7 @@ class TestServerComponent(unittest.TestCase):
"logs": [log_entry] "logs": [log_entry]
} }
# Runs 1 to 3: should remain TRANSIENT # Runs 1 to 3: WARNING should remain TRANSIENT
for run_idx in range(1, 4): for run_idx in range(1, 4):
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=4)
self.assertEqual(res["status"], "success") self.assertEqual(res["status"], "success")
@@ -96,24 +97,53 @@ class TestServerComponent(unittest.TestCase):
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 = ?", ("PostgresConnTimeout",)) 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], 3)
self.assertEqual(row[1], "TRANSIENT") self.assertEqual(row[1], "TRANSIENT")
# Run 4: promotes to VERIFIED! # Run 4: promotes WARNING to VERIFIED!
res4 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4) res4 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
self.assertEqual(res4["promoted_verified"], 1) self.assertEqual(res4["promoted_verified"], 1)
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 = ?", ("PostgresConnTimeout",)) 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], 4) self.assertEqual(row[0], 4)
self.assertEqual(row[1], "VERIFIED") self.assertEqual(row[1], "VERIFIED")
def test_error_immediate_pass(self):
Server.init_db(self.test_db)
log_entry = {
"server": "app-worker-01.corp.local",
"signature": "KernelPanicCritical",
"severity": "ERROR",
"message": "Kernel panic - not syncing: Fatal hardware error",
"os_type": "linux"
}
payload = {
"server": "app-worker-01.corp.local",
"logs": [log_entry]
}
# Run 1: ERROR must immediately promote to VERIFIED
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
self.assertEqual(res["status"], "success")
self.assertEqual(res["promoted_verified"], 1)
conn = sqlite3.connect(self.test_db)
c = conn.cursor()
c.execute("SELECT run_count, status, severity FROM active_issues WHERE signature = ?", ("KernelPanicCritical",))
row = c.fetchone()
conn.close()
self.assertIsNotNone(row)
self.assertEqual(row[0], 1)
self.assertEqual(row[1], "VERIFIED")
self.assertEqual(row[2], "ERROR")
def test_server_severity_filtering(self): def test_server_severity_filtering(self):
Server.init_db(self.test_db) Server.init_db(self.test_db)
payload = { payload = {
@@ -131,15 +161,135 @@ class TestServerComponent(unittest.TestCase):
conn = sqlite3.connect(self.test_db) conn = sqlite3.connect(self.test_db)
c = conn.cursor() c = conn.cursor()
c.execute("SELECT signature FROM active_issues ORDER BY signature") c.execute("SELECT signature, status FROM active_issues ORDER BY signature")
sigs = [r[0] for r in c.fetchall()] rows = dict(c.fetchall())
conn.close() conn.close()
self.assertIn("SigInfo", sigs) self.assertIn("SigInfo", rows)
self.assertIn("SigWarn", sigs) self.assertIn("SigWarn", rows)
self.assertIn("SigErr", sigs) self.assertIn("SigErr", rows)
self.assertNotIn("SigDebug", sigs) self.assertNotIn("SigDebug", rows)
self.assertNotIn("SigTrace", sigs) self.assertNotIn("SigTrace", rows)
# SigErr is immediately VERIFIED; SigWarn and SigInfo are TRANSIENT on run 1
self.assertEqual(rows["SigErr"], "VERIFIED")
self.assertEqual(rows["SigWarn"], "TRANSIENT")
self.assertEqual(rows["SigInfo"], "TRANSIENT")
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)
if __name__ == "__main__": if __name__ == "__main__":
+26
View File
@@ -8,6 +8,7 @@ import warnings
warnings.filterwarnings("ignore") warnings.filterwarnings("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")))
import Win_Client import Win_Client
import pgpy import pgpy
@@ -183,6 +184,31 @@ 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)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()