Compare commits
53
Commits
v1.0.1
..
249b754423
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249b754423 | ||
|
|
16faad063d | ||
|
|
c705be57eb | ||
|
|
3d6a2b86d6 | ||
|
|
1d0845b548 | ||
|
|
11f16ed8e1 | ||
|
|
8cb3089e8e | ||
|
|
5bbb56b4c3 | ||
|
|
a04d9bac9f | ||
|
|
8813d2d865 | ||
|
|
7e9f7a56f8 | ||
|
|
99920ef395 | ||
|
|
ccb23d65e5 | ||
|
|
fb9ef6e6cb | ||
|
|
491d2b1194 | ||
|
|
e83a5b3e0f | ||
|
|
0901ccb3eb | ||
|
|
cb4c763e0e | ||
|
|
84579d8719 | ||
|
|
08aa4edfb1 | ||
|
|
e4f6a9295b | ||
|
|
7f6bf4442f | ||
|
|
db80e7f5a2 | ||
|
|
1562285034 | ||
|
|
74942d4c00 | ||
|
|
874d693dac | ||
|
|
15bfb4940b | ||
|
|
364fefea47 | ||
|
|
9624935a81 | ||
|
|
867271e8b7 | ||
|
|
205d0cfbad | ||
|
|
d37191e302 | ||
|
|
35a736dacb | ||
|
|
f871344da4 | ||
|
|
e1dbe32063 | ||
|
|
98a227a234 | ||
|
|
355c6e1bc0 | ||
|
|
907616511b | ||
|
|
939fa4270a | ||
|
|
1f4bf3219b | ||
|
|
df03c52a05 | ||
|
|
d63a623763 | ||
|
|
7ed264db5a | ||
|
|
6fec838344 | ||
|
|
4c160924f7 | ||
|
|
99be50ebc6 | ||
|
|
85f0d94805 | ||
|
|
1a18c4c079 | ||
|
|
a770e24f26 | ||
|
|
f7ebc6c0a1 | ||
|
|
86649f796d | ||
|
|
78fc2ac8c5 | ||
|
|
4e2242e0ae |
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- 'v*'
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
apt-get install -y python3 python3-pip python3-venv curl
|
||||
fi
|
||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
||||
pip3 install -r 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
|
||||
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
|
||||
run: |
|
||||
@@ -40,10 +40,10 @@ jobs:
|
||||
rm -f server_config.json client_config.json logar_state.db client_state.json
|
||||
|
||||
# 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
|
||||
python3 Server.py &
|
||||
python3 src/Server.py &
|
||||
SERVER_PID=$!
|
||||
echo "[*] Server launched in background with PID $SERVER_PID"
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# 4. Execute end-to-end integration test
|
||||
python3 test_pipeline.py
|
||||
python3 tests/test_pipeline.py
|
||||
|
||||
# 5. Cleanly terminate background server
|
||||
kill $SERVER_PID || true
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
name: Release Linux Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, created]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '*'
|
||||
- '**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v1.0.1)'
|
||||
description: 'Release tag (optional)'
|
||||
required: false
|
||||
default: 'v1.0.1'
|
||||
|
||||
jobs:
|
||||
release-linux:
|
||||
@@ -26,17 +28,17 @@ jobs:
|
||||
apt-get install -y python3 python3-pip python3-venv binutils zip
|
||||
fi
|
||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
||||
pip3 install pyinstaller -r 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
|
||||
run: |
|
||||
python3 package_dist.py --target linux
|
||||
python3 compilation/package_dist.py --target linux
|
||||
|
||||
- name: Publish Linux Release Assets
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
GITEA_REF_NAME: ${{ inputs.tag || github.ref_name }}
|
||||
GITEA_REF_NAME: ${{ github.event.release.tag_name || inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
python3 upload_release.py --skip-build
|
||||
python3 compilation/upload_release.py --skip-build
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release Windows Binaries
|
||||
name: Release Windows Binaries & Installers
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,19 +7,18 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v1.0.1)'
|
||||
description: 'Release tag (optional)'
|
||||
required: false
|
||||
default: 'v1.0.1'
|
||||
|
||||
jobs:
|
||||
release-windows:
|
||||
name: Build & Release Windows Binaries
|
||||
name: Build & Release Windows Binaries & Installers
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
@@ -35,7 +34,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
& $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
|
||||
shell: powershell
|
||||
@@ -46,7 +45,27 @@ jobs:
|
||||
$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
|
||||
shell: powershell
|
||||
@@ -54,7 +73,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||
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: |
|
||||
$py = "python"
|
||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||
@@ -62,4 +81,4 @@ jobs:
|
||||
$py = "py -3.12"
|
||||
}
|
||||
}
|
||||
& $py upload_release.py --skip-build
|
||||
& $py compilation/upload_release.py --skip-build
|
||||
|
||||
@@ -11,6 +11,7 @@ build/
|
||||
dist/
|
||||
*.spec
|
||||
*.exe
|
||||
!compilation/nssm.exe
|
||||
*.bin
|
||||
*.dll
|
||||
*.so
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
- **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:
|
||||
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
||||
- 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
|
||||
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`).
|
||||
- **Client Configuration Exporter**:
|
||||
```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.
|
||||
- **Socket Protocol Framing**:
|
||||
@@ -106,16 +107,20 @@ graph TB
|
||||
---
|
||||
|
||||
## Cloud-Side Temporal Persistence & 4-Run Rule
|
||||
|
||||
|
||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
||||
- **12-Hour Evaluation Window**:
|
||||
- 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`.
|
||||
- **4-Run Rule**:
|
||||
- If more than 12 hours have passed since the issue was last recorded, the previous window is expired and the cycle resets to `run_count = 1`.
|
||||
- **4-Run Rule for Warnings**:
|
||||
- The 4-run persistence threshold specifically applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
||||
- For each distinct run batch, `run_count` increments.
|
||||
- Issues with `run_count < 4` are marked as `TRANSIENT` and ignored by downstream reporting.
|
||||
- When `run_count >= 4` within the active 12-hour window, the status transitions to `VERIFIED`.
|
||||
- Warnings with `run_count < 4` are marked as `TRANSIENT` and excluded from Hermes reports.
|
||||
- When `run_count >= 4` within the active 12-hour window, the warning transitions to `VERIFIED`.
|
||||
- **Immediate Verification for Errors**:
|
||||
- High-severity events (`ERROR`, `CRITICAL`, `FATAL`) **always pass immediately**.
|
||||
- On their very first ingestion (`run_count = 1`), errors are promoted directly to `VERIFIED` and surfaced to Hermes without waiting for 4 runs.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`):
|
||||
|
||||
### `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
|
||||
[
|
||||
@@ -177,27 +182,37 @@ LOGAR/
|
||||
│ ├── 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-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums)
|
||||
├── .gitignore # Ignore venv, caches, DBs, and private keys
|
||||
├── requirements.txt # Unified dependencies
|
||||
├── README.md # Comprehensive documentation
|
||||
├── Server.py # Central TCP server and Hermes API
|
||||
├── Win_Client.py # Windows edge forwarder
|
||||
├── Linux_Client.py # Linux edge forwarder
|
||||
├── server_config.sample.json # Central server sample configuration
|
||||
├── package_dist.py # Multi-platform standalone binary packaging script
|
||||
├── upload_release.py # Direct Gitea REST API release asset publisher
|
||||
├── test_pipeline.py # End-to-end integration test
|
||||
├── tests/ # Unified unit test suites
|
||||
├── compilation/ # Build, packaging, and release automation tools
|
||||
│ ├── package_dist.py # Multi-platform standalone binary packaging script
|
||||
│ ├── requirements.txt # Unified project dependencies
|
||||
│ └── upload_release.py # Direct Gitea REST API release asset publisher
|
||||
├── src/ # Core application source modules
|
||||
│ ├── __init__.py
|
||||
│ ├── Server.py # Central TCP server and Hermes API
|
||||
│ ├── Win_Client.py # Windows edge forwarder
|
||||
│ └── Linux_Client.py # Linux edge forwarder
|
||||
├── tests/ # Automated 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_win_client.py # Windows client unit tests
|
||||
│ └── test_linux_client.py # Linux client unit tests
|
||||
└── out/ # Edge forwarder deployment packages
|
||||
├── win_client/
|
||||
│ ├── client_config.sample.json # Reference client configuration
|
||||
│ └── README.md # Windows service installation & configuration guide
|
||||
└── linux_client/
|
||||
├── client_config.sample.json # Reference client configuration
|
||||
└── README.md # Linux service installation & configuration guide
|
||||
│ └── test_win_client.py # Windows client unit tests
|
||||
├── .gitignore # Ignore venv, caches, DBs, and private keys
|
||||
├── README.md # Comprehensive documentation
|
||||
├── RELEASE_NOTES.md # Release history and changelog
|
||||
├── server_config.sample.json # Central server sample configuration
|
||||
└── out/ # Standalone deployment documentation & sample configs
|
||||
├── linux_server/
|
||||
│ ├── README.md # Linux systemd service installation & hub guide
|
||||
│ └── server_config.sample.json # Reference server configuration
|
||||
├── win_server/
|
||||
│ ├── README.md # Windows service (NSSM/Task Scheduler) installation guide
|
||||
│ └── server_config.sample.json # Reference server configuration
|
||||
├── linux_client/
|
||||
│ ├── README.md # Linux service & timer installation guide
|
||||
│ └── client_config.sample.json # Reference client configuration
|
||||
└── win_client/
|
||||
├── README.md # Windows service installation & configuration guide
|
||||
└── client_config.sample.json # Reference client configuration
|
||||
```
|
||||
|
||||
---
|
||||
@@ -208,15 +223,15 @@ LOGAR/
|
||||
|
||||
1. **Install dependencies**:
|
||||
```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):
|
||||
```bash
|
||||
python Server.py
|
||||
python src/Server.py
|
||||
```
|
||||
3. **Export a client configuration**:
|
||||
```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
|
||||
@@ -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):
|
||||
```bash
|
||||
python Server.py
|
||||
python src/Server.py
|
||||
```
|
||||
2. **Export client configuration** in Shell 2 (required for testing):
|
||||
```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:
|
||||
```bash
|
||||
python test_pipeline.py
|
||||
python tests/test_pipeline.py
|
||||
```
|
||||
|
||||
## Continuous Integration via Gitea Actions
|
||||
|
||||
Continuous integration is automated via [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) and triggers automatically on **every push** and pull request:
|
||||
1. **Syntax Compilation**: Validates all Python scripts (`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`).
|
||||
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
|
||||
Whenever you want to release a new version with compiled standalone binaries:
|
||||
```bash
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
git tag v1.0.2
|
||||
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).*
|
||||
|
||||
@@ -360,13 +375,13 @@ git push origin v1.0.1
|
||||
You can also compile and package binaries locally anytime:
|
||||
```bash
|
||||
# Windows
|
||||
py -3.12 package_dist.py --target windows
|
||||
py -3.12 compilation/package_dist.py --target windows
|
||||
|
||||
# Linux
|
||||
python3 package_dist.py --target linux
|
||||
python3 compilation/package_dist.py --target linux
|
||||
```
|
||||
To upload local builds directly to Gitea:
|
||||
```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).*
|
||||
|
||||
+5
-2
@@ -1,7 +1,10 @@
|
||||
# LOGAR Release v1.0.1
|
||||
# LOGAR Release v1.0.2
|
||||
|
||||
### 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`).
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 argparse
|
||||
|
||||
ROOT_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
DIST_DIR = os.path.abspath("dist")
|
||||
BUILD_TEMP = os.path.abspath("build_temp")
|
||||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
SRC_DIR = os.path.join(ROOT_DIR, "src")
|
||||
DIST_DIR = os.path.join(ROOT_DIR, "dist")
|
||||
BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
|
||||
|
||||
def clean_and_prep():
|
||||
if os.path.exists(DIST_DIR):
|
||||
@@ -55,7 +56,7 @@ def build_linux_zipapp_fallback():
|
||||
# Linux Client zipapp
|
||||
app_dir = os.path.join(BUILD_TEMP, "linux_app")
|
||||
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")
|
||||
zipapp.create_archive(
|
||||
source=app_dir,
|
||||
@@ -66,7 +67,7 @@ def build_linux_zipapp_fallback():
|
||||
# Server zipapp
|
||||
srv_dir = os.path.join(BUILD_TEMP, "linux_srv")
|
||||
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")
|
||||
zipapp.create_archive(
|
||||
source=srv_dir,
|
||||
@@ -77,10 +78,10 @@ def build_linux_zipapp_fallback():
|
||||
|
||||
def build_windows():
|
||||
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")
|
||||
|
||||
server_script = os.path.join(ROOT_DIR, "Server.py")
|
||||
server_script = os.path.join(SRC_DIR, "Server.py")
|
||||
build_pyinstaller_binary(server_script, "Server")
|
||||
|
||||
client_bin = os.path.join(DIST_DIR, "Win_Client.exe")
|
||||
@@ -111,10 +112,10 @@ def build_linux():
|
||||
is_linux_host = platform.system() == "Linux"
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
# Normalize extensions in case PyInstaller dropped .bin
|
||||
@@ -4,7 +4,8 @@ import json
|
||||
import argparse
|
||||
import urllib.request
|
||||
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 time
|
||||
@@ -162,8 +163,8 @@ def main():
|
||||
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:
|
||||
notes = nf.read()
|
||||
elif not notes and os.path.exists("RELEASE_NOTES.md"):
|
||||
with open("RELEASE_NOTES.md", "r", encoding="utf-8") as nf:
|
||||
elif not notes and os.path.exists(os.path.join(ROOT_DIR, "RELEASE_NOTES.md")):
|
||||
with open(os.path.join(ROOT_DIR, "RELEASE_NOTES.md"), "r", encoding="utf-8") as nf:
|
||||
notes = nf.read()
|
||||
elif not notes:
|
||||
notes = (
|
||||
@@ -184,9 +185,9 @@ def main():
|
||||
print("[*] Assembling compiled binaries...")
|
||||
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):
|
||||
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)
|
||||
|
||||
print(f"[*] Connecting to Gitea: {args.url} (repo: {args.repo})...")
|
||||
@@ -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:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import argparse
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
@@ -18,6 +20,68 @@ CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "linux"):
|
||||
"""Bootstraps client enrollment if certificates are missing."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
return True
|
||||
|
||||
print(f"[*] Bootstrapping client enrollment with LOGAR Hub at {hub_url}...")
|
||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": os_type,
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
enroll_endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
with open(ca_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["ca_cert"])
|
||||
with open(cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_cert"])
|
||||
with open(key_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_key"])
|
||||
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[+] Client enrolled successfully! Certificates saved to {os.path.abspath(cert_dir)}")
|
||||
return True
|
||||
|
||||
|
||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
ctx.check_hostname = False
|
||||
|
||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||
|
||||
|
||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||
if custom_state_path:
|
||||
return custom_state_path
|
||||
@@ -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):
|
||||
"""
|
||||
Encrypts the payload using the server's OpenPGP public key and streams
|
||||
over an authenticated TCP socket.
|
||||
Streams logs to the LOGAR hub.
|
||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||
or falls back to OpenPGP encrypted envelope over TCP.
|
||||
"""
|
||||
server_host = config["server_host"]
|
||||
server_port = int(config["server_port"])
|
||||
auth_token = config["auth_token"]
|
||||
pub_key_armored = config["server_public_key"]
|
||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
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)
|
||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||
if expected_fp and actual_fp != expected_fp:
|
||||
raise ValueError(
|
||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
||||
)
|
||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||
|
||||
machine_id = get_machine_identifier()
|
||||
|
||||
# Prepare batch
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_json = json.dumps(payload)
|
||||
|
||||
# Encrypt payload with server's encryption-only key
|
||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||
encrypted_armored = str(encrypted_msg)
|
||||
|
||||
# Envelope with socket authentication header
|
||||
envelope = {
|
||||
"auth_token": auth_token,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -267,17 +372,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
}
|
||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||
|
||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(15.0)
|
||||
sock.connect((server_host, server_port))
|
||||
|
||||
# Send frame: length (4 bytes big-endian) + envelope
|
||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
# Receive response length
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed connection without response.")
|
||||
+308
-68
@@ -23,9 +23,16 @@ from pgpy.constants import (
|
||||
SymmetricKeyAlgorithm,
|
||||
CompressionAlgorithm
|
||||
)
|
||||
import ssl
|
||||
from pydantic import BaseModel
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import uvicorn
|
||||
|
||||
try:
|
||||
from src import server_enrollment as enrollment
|
||||
except ImportError:
|
||||
import server_enrollment as enrollment
|
||||
|
||||
CONFIG_FILE_NAME = "server_config.json"
|
||||
DEFAULT_DB_FILE = "logar_state.db"
|
||||
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] = {}
|
||||
|
||||
|
||||
class ClientEnrollRequest(BaseModel):
|
||||
client_id: str
|
||||
hostname: str
|
||||
os: str
|
||||
enrollment_secret: str
|
||||
|
||||
|
||||
def generate_server_keypair(server_name: str):
|
||||
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
||||
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
||||
@@ -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)}")
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if "enrollment_secret" not in config:
|
||||
config["enrollment_secret"] = secrets.token_hex(24)
|
||||
if "max_seats" not in config:
|
||||
config["max_seats"] = 10
|
||||
if "cert_dir" not in config:
|
||||
config["cert_dir"] = "certs"
|
||||
if "tls_enabled" not in config:
|
||||
config["tls_enabled"] = True
|
||||
return config
|
||||
|
||||
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
||||
server_name = "LOGAR-Cloud-Hub"
|
||||
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
||||
auth_token = secrets.token_hex(24)
|
||||
enrollment_secret = secrets.token_hex(24)
|
||||
|
||||
config = {
|
||||
"server_name": server_name,
|
||||
@@ -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_port": 8443,
|
||||
"auth_token": auth_token,
|
||||
"enrollment_secret": enrollment_secret,
|
||||
"max_seats": 10,
|
||||
"cert_dir": "certs",
|
||||
"tls_enabled": True,
|
||||
"db_path": DEFAULT_DB_FILE,
|
||||
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
||||
"min_persistence_runs": RUN_THRESHOLD,
|
||||
@@ -95,7 +122,9 @@ def create_client_config(
|
||||
server_host: str,
|
||||
server_port: int,
|
||||
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]:
|
||||
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
|
||||
server_conf = load_or_init_config(config_path)
|
||||
@@ -103,6 +132,10 @@ def create_client_config(
|
||||
client_conf = {
|
||||
"server_host": server_host,
|
||||
"server_port": server_port,
|
||||
"hermes_host": hermes_host or server_conf.get("hermes_host", "127.0.0.1"),
|
||||
"hermes_port": hermes_port or server_conf.get("hermes_port", 8443),
|
||||
"enrollment_secret": server_conf.get("enrollment_secret"),
|
||||
"cert_dir": "certs",
|
||||
"server_fingerprint": server_conf["server_fingerprint"],
|
||||
"server_public_key": server_conf["public_key"],
|
||||
"auth_token": server_conf["auth_token"]
|
||||
@@ -121,8 +154,8 @@ def create_client_config(
|
||||
return client_conf
|
||||
|
||||
|
||||
def init_db(db_path: str):
|
||||
"""Initializes the SQLite schema for multi-run temporal tracking."""
|
||||
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10):
|
||||
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, and license quota."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS active_issues (
|
||||
@@ -149,6 +182,29 @@ def init_db(db_path: str):
|
||||
log_count INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS license_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
max_seats INTEGER NOT NULL DEFAULT 10,
|
||||
enrollment_secret TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
os_type TEXT NOT NULL,
|
||||
cert_fingerprint TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
if enrollment_secret:
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
|
||||
VALUES (1, ?, ?)
|
||||
""", (max_seats, enrollment_secret))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -183,6 +239,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
||||
if severity in ["DEBUG", "TRACE"]:
|
||||
continue
|
||||
|
||||
# Errors are always passed immediately; the 4-run rule only concerns warnings
|
||||
is_error = severity in ["ERROR", "CRITICAL", "FATAL"]
|
||||
|
||||
signature = log.get("signature", "unknown")
|
||||
server = log.get("server", client_server)
|
||||
message = log.get("message", "")
|
||||
@@ -207,7 +266,7 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
||||
# Window elapsed: reset to new cycle
|
||||
new_runs = 1
|
||||
new_first_seen = now_iso
|
||||
new_status = "TRANSIENT"
|
||||
new_status = "VERIFIED" if is_error else "TRANSIENT"
|
||||
else:
|
||||
# Same run guard: only increment count once per distinct run batch
|
||||
if last_run_id != run_id:
|
||||
@@ -215,8 +274,8 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
||||
else:
|
||||
new_runs = run_count
|
||||
new_first_seen = first_seen_str
|
||||
# 4-run rule enforcement
|
||||
new_status = "VERIFIED" if new_runs >= min_runs else "TRANSIENT"
|
||||
# 4-run rule applies to warnings; errors are always passed immediately as VERIFIED
|
||||
new_status = "VERIFIED" if (is_error or new_runs >= min_runs) else "TRANSIENT"
|
||||
|
||||
if new_status == "VERIFIED" and current_status != "VERIFIED":
|
||||
promoted_to_verified += 1
|
||||
@@ -227,7 +286,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
||||
WHERE fingerprint = ?
|
||||
""", (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp))
|
||||
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("""
|
||||
INSERT INTO active_issues
|
||||
(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):
|
||||
"""
|
||||
Authenticated TCP socket handler.
|
||||
Protocol:
|
||||
- 4-byte big-endian prefix: payload length
|
||||
- Payload: JSON with auth_token and encrypted_payload (OpenPGP ASCII armored)
|
||||
- Response: 4-byte length + JSON confirmation
|
||||
mTLS TCP socket handler.
|
||||
Extracts client CN (client_id) from the TLS handshake,
|
||||
validates active license status in SQLite, updates last_seen,
|
||||
reads 4-byte big-endian length-prefixed JSON payload,
|
||||
and ingests candidate logs into the temporal evaluation engine.
|
||||
"""
|
||||
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:
|
||||
# Read 4-byte length prefix
|
||||
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}")
|
||||
|
||||
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
|
||||
expected_token = SERVER_STATE["config"]["auth_token"]
|
||||
provided_token = envelope.get("auth_token")
|
||||
if not secrets.compare_digest(str(provided_token), str(expected_token)):
|
||||
err_msg = json.dumps({"status": "error", "message": "Authentication failed"}).encode("utf-8")
|
||||
writer.write(struct.pack(">I", len(err_msg)) + err_msg)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
# Support both direct JSON payload over mTLS and legacy OpenPGP envelope
|
||||
if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"):
|
||||
pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"])
|
||||
priv_key = SERVER_STATE["private_key_obj"]
|
||||
decrypted_obj = priv_key.decrypt(pgp_msg)
|
||||
log_payload = json.loads(decrypted_obj.message)
|
||||
else:
|
||||
log_payload = raw_payload
|
||||
|
||||
# Decrypt payload using server's OpenPGP private key
|
||||
encrypted_armored = envelope.get("encrypted_payload", "")
|
||||
pgp_msg = pgpy.PGPMessage.from_blob(encrypted_armored)
|
||||
priv_key = SERVER_STATE["private_key_obj"]
|
||||
decrypted_obj = priv_key.decrypt(pgp_msg)
|
||||
decrypted_json_str = decrypted_obj.message
|
||||
log_payload = json.loads(decrypted_json_str)
|
||||
# Attach authenticated client_id if not present
|
||||
if client_id and "server" not in log_payload:
|
||||
log_payload["server"] = client_id
|
||||
|
||||
# Ingest and apply 12h window / 4-run rule
|
||||
res = process_ingested_logs(
|
||||
@@ -312,47 +412,158 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/hermes/report")
|
||||
def get_hermes_report():
|
||||
@app.post("/api/client/enroll")
|
||||
def enroll_client(req: ClientEnrollRequest):
|
||||
"""
|
||||
Agentic Integration endpoint: Consumed by Hermes to fetch anomalies that have persisted
|
||||
across the 12-hour evaluation window and satisfied the 4-run rule.
|
||||
Enrolls an edge client by validating the enrollment secret,
|
||||
checking license seat limits, issuing a signed client certificate + key,
|
||||
and recording the client in the SQLite accounting database.
|
||||
"""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
window_hours = SERVER_STATE["config"]["evaluation_window_hours"]
|
||||
min_runs = SERVER_STATE["config"]["min_persistence_runs"]
|
||||
now = datetime.now(timezone.utc)
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# 1. Validate enrollment secret against license_config
|
||||
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
||||
|
||||
expected_secret, max_seats = row
|
||||
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
||||
|
||||
ca_cert = SERVER_STATE.get("ca_cert")
|
||||
ca_key = SERVER_STATE.get("ca_key")
|
||||
ca_cert_pem = SERVER_STATE.get("ca_cert_pem")
|
||||
|
||||
if not ca_cert or not ca_key:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=500, detail="Root CA not loaded on server")
|
||||
|
||||
# 2. Check if client_id already registered
|
||||
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
|
||||
client_row = c.fetchone()
|
||||
if client_row:
|
||||
if client_row[0] == "revoked":
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
|
||||
|
||||
# Re-issue for existing active client
|
||||
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||
c.execute("""
|
||||
UPDATE clients
|
||||
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = CURRENT_TIMESTAMP
|
||||
WHERE client_id = ?
|
||||
""", (req.hostname, req.os, fp, req.client_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})")
|
||||
return {
|
||||
"ca_cert": ca_cert_pem,
|
||||
"client_cert": client_cert_pem,
|
||||
"client_key": client_key_pem
|
||||
}
|
||||
|
||||
# 3. New client: check seat limits
|
||||
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
|
||||
active_count = c.fetchone()[0]
|
||||
if active_count >= max_seats:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=403, detail="License seat limit reached")
|
||||
|
||||
# 4. Issue signed cert + key
|
||||
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||
c.execute("""
|
||||
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status)
|
||||
VALUES (?, ?, ?, ?, 'active')
|
||||
""", (req.client_id, req.hostname, req.os, fp))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
|
||||
|
||||
return {
|
||||
"ca_cert": ca_cert_pem,
|
||||
"client_cert": client_cert_pem,
|
||||
"client_key": client_key_pem
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/clients")
|
||||
def list_clients():
|
||||
"""Returns all registered clients and license seat usage."""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
|
||||
lic_row = c.fetchone()
|
||||
max_seats = lic_row[0] if lic_row else 10
|
||||
|
||||
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
|
||||
clients = [
|
||||
{
|
||||
"client_id": r[0],
|
||||
"hostname": r[1],
|
||||
"os_type": r[2],
|
||||
"cert_fingerprint": r[3],
|
||||
"status": r[4],
|
||||
"first_seen": r[5],
|
||||
"last_seen": r[6]
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
active_count = sum(1 for cl in clients if cl["status"] == "active")
|
||||
return {
|
||||
"active_seats": active_count,
|
||||
"max_seats": max_seats,
|
||||
"clients": clients
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/hermes/report")
|
||||
def get_verified_anomalies_for_hermes():
|
||||
"""
|
||||
Ingestion endpoint for Hermes agentic workflows.
|
||||
Returns only verified anomalies that have satisfied the 4-run persistence rule
|
||||
within the active 12-hour evaluation window. Transient blips (< 4 runs) are excluded.
|
||||
"""
|
||||
db_path = SERVER_STATE["config"]["db_path"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
||||
FROM active_issues
|
||||
WHERE status = 'VERIFIED' AND run_count >= ?
|
||||
""", (min_runs,))
|
||||
WHERE status = 'VERIFIED'
|
||||
ORDER BY last_seen DESC
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
report = []
|
||||
for r in rows:
|
||||
last_seen_dt = datetime.fromisoformat(r[8])
|
||||
# Only return anomalies active within the evaluation window
|
||||
if (now - last_seen_dt) <= timedelta(hours=window_hours):
|
||||
report.append({
|
||||
"fingerprint": r[0],
|
||||
"site": r[1],
|
||||
"server": r[2],
|
||||
"signature": r[3],
|
||||
"severity": r[4],
|
||||
"message": r[5],
|
||||
"os_type": r[6],
|
||||
"first_seen": r[7],
|
||||
"last_seen": r[8],
|
||||
"consecutive_runs": r[9],
|
||||
"evaluation_window": f"{window_hours}h",
|
||||
"verified": True,
|
||||
"status": r[10]
|
||||
})
|
||||
report.append({
|
||||
"fingerprint": r[0],
|
||||
"site": r[1],
|
||||
"server": r[2],
|
||||
"signature": r[3],
|
||||
"severity": r[4],
|
||||
"message": r[5],
|
||||
"os_type": r[6],
|
||||
"first_seen": r[7],
|
||||
"last_seen": r[8],
|
||||
"consecutive_runs": r[9],
|
||||
"evaluation_window": f"{SERVER_STATE['config']['evaluation_window_hours']}h",
|
||||
"verified": True,
|
||||
"status": r[10]
|
||||
})
|
||||
|
||||
return report
|
||||
|
||||
@@ -395,26 +606,30 @@ def health_check():
|
||||
"server_name": SERVER_STATE["config"]["server_name"],
|
||||
"fingerprint": SERVER_STATE["config"]["server_fingerprint"],
|
||||
"tcp_port": SERVER_STATE["config"]["tcp_port"],
|
||||
"hermes_port": SERVER_STATE["config"]["hermes_port"]
|
||||
"hermes_port": SERVER_STATE["config"]["hermes_port"],
|
||||
"tls_enabled": SERVER_STATE.get("tls_enabled", False)
|
||||
}
|
||||
|
||||
|
||||
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"]
|
||||
tcp_host = config["tcp_host"]
|
||||
tcp_port = int(config["tcp_port"])
|
||||
hermes_host = config["hermes_host"]
|
||||
hermes_port = int(config["hermes_port"])
|
||||
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
||||
|
||||
# Start TCP Socket Server
|
||||
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port)
|
||||
print(f"[*] LOGAR TCP Socket Server listening on {tcp_host}:{tcp_port}")
|
||||
# Start mTLS / TCP Socket Server
|
||||
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx)
|
||||
mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP"
|
||||
print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}")
|
||||
|
||||
# Start FastAPI / Uvicorn server for Hermes
|
||||
# Start FastAPI / Uvicorn server for Hermes & Enrollment
|
||||
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
|
||||
uv_server = uvicorn.Server(uv_config)
|
||||
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
|
||||
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
|
||||
|
||||
await asyncio.gather(
|
||||
tcp_server.serve_forever(),
|
||||
@@ -432,12 +647,35 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
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"])
|
||||
SERVER_STATE["config"] = config
|
||||
SERVER_STATE["private_key_obj"] = priv_key_obj
|
||||
SERVER_STATE["ca_cert"] = ca_cert
|
||||
SERVER_STATE["ca_key"] = ca_key
|
||||
SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||
SERVER_STATE["ssl_ctx"] = ssl_ctx
|
||||
SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True)
|
||||
|
||||
if args.create_client_config:
|
||||
port = args.server_port or config["tcp_port"]
|
||||
@@ -451,8 +689,10 @@ def main():
|
||||
|
||||
print("=" * 60)
|
||||
print(f" LOGAR Server Hub: {config['server_name']}")
|
||||
print(f" Encryption Fingerprint: {config['server_fingerprint']}")
|
||||
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | Rule: {config['min_persistence_runs']}+ consecutive runs")
|
||||
print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}")
|
||||
print(f" License Quota: {config.get('max_seats', 10)} Active Seats")
|
||||
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
|
||||
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | 4-Run Rule: Warnings | Immediate Pass: Errors")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
@@ -2,8 +2,10 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import argparse
|
||||
import urllib.request
|
||||
import warnings
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
@@ -22,6 +24,68 @@ CONFIG_FILE_NAME = "client_config.json"
|
||||
STATE_FILE_NAME = "client_state.json"
|
||||
|
||||
|
||||
def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "windows"):
|
||||
"""Bootstraps client enrollment if certificates are missing."""
|
||||
os.makedirs(cert_dir, exist_ok=True)
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||
return True
|
||||
|
||||
print(f"[*] Bootstrapping client enrollment with LOGAR Hub at {hub_url}...")
|
||||
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"hostname": hostname,
|
||||
"os": os_type,
|
||||
"enrollment_secret": enrollment_secret
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
enroll_endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
with open(ca_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["ca_cert"])
|
||||
with open(cert_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_cert"])
|
||||
with open(key_path, "w", encoding="utf-8") as f:
|
||||
f.write(data["client_key"])
|
||||
|
||||
try:
|
||||
os.chmod(key_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[+] Client enrolled successfully! Certificates saved to {os.path.abspath(cert_dir)}")
|
||||
return True
|
||||
|
||||
|
||||
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||
cert_path = os.path.join(cert_dir, "client.crt")
|
||||
key_path = os.path.join(cert_dir, "client.key")
|
||||
|
||||
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||
|
||||
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||
ctx.check_hostname = False
|
||||
|
||||
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||
|
||||
|
||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||
if custom_state_path:
|
||||
return custom_state_path
|
||||
@@ -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):
|
||||
"""
|
||||
Encrypts the payload using the server's OpenPGP public key and streams
|
||||
over an authenticated TCP socket. Zero local state is maintained on the client.
|
||||
Streams logs to the LOGAR hub.
|
||||
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||
or falls back to OpenPGP encrypted envelope over TCP.
|
||||
"""
|
||||
server_host = config["server_host"]
|
||||
server_port = int(config["server_port"])
|
||||
auth_token = config["auth_token"]
|
||||
pub_key_armored = config["server_public_key"]
|
||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||
cert_dir = config.get("cert_dir", "certs")
|
||||
enrollment_secret = config.get("enrollment_secret")
|
||||
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)
|
||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||
if expected_fp and actual_fp != expected_fp:
|
||||
raise ValueError(
|
||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
||||
)
|
||||
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||
|
||||
machine_id = get_machine_identifier()
|
||||
|
||||
# Prepare zero-state candidate batch
|
||||
payload = {
|
||||
"server": machine_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logs": logs
|
||||
}
|
||||
payload_json = json.dumps(payload)
|
||||
|
||||
# Encrypt payload with server's encryption-only key
|
||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||
encrypted_armored = str(encrypted_msg)
|
||||
|
||||
# Envelope with socket authentication header
|
||||
envelope = {
|
||||
"auth_token": auth_token,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -234,17 +339,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||
}
|
||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||
|
||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(15.0)
|
||||
sock.connect((server_host, server_port))
|
||||
|
||||
# Send frame: length (4 bytes big-endian) + envelope
|
||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||
sock.sendall(frame)
|
||||
|
||||
# Receive response length
|
||||
resp_len_bytes = sock.recv(4)
|
||||
if not resp_len_bytes:
|
||||
raise ConnectionError("Server closed connection without response.")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -8,6 +8,7 @@ import warnings
|
||||
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__), "..", "src")))
|
||||
|
||||
import Linux_Client
|
||||
import pgpy
|
||||
@@ -197,6 +198,31 @@ class TestLinuxClientComponent(unittest.TestCase):
|
||||
self.assertEqual(logs[0]["__CURSOR"], "c3")
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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
@@ -11,8 +11,9 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
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__), "..", "src")))
|
||||
|
||||
import Server
|
||||
import pgpy
|
||||
@@ -78,9 +79,9 @@ class TestServerComponent(unittest.TestCase):
|
||||
Server.init_db(self.test_db)
|
||||
log_entry = {
|
||||
"server": "app-worker-01.corp.local",
|
||||
"signature": "PostgresConnTimeout",
|
||||
"severity": "ERROR",
|
||||
"message": "Connection to database pool timed out after 30s",
|
||||
"signature": "PostgresConnWarning",
|
||||
"severity": "WARNING",
|
||||
"message": "Connection to database pool near capacity: 85%",
|
||||
"os_type": "linux"
|
||||
}
|
||||
payload = {
|
||||
@@ -88,7 +89,7 @@ class TestServerComponent(unittest.TestCase):
|
||||
"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):
|
||||
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
||||
self.assertEqual(res["status"], "success")
|
||||
@@ -96,24 +97,53 @@ class TestServerComponent(unittest.TestCase):
|
||||
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
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()
|
||||
conn.close()
|
||||
self.assertEqual(row[0], 3)
|
||||
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)
|
||||
self.assertEqual(res4["promoted_verified"], 1)
|
||||
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
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()
|
||||
conn.close()
|
||||
self.assertEqual(row[0], 4)
|
||||
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):
|
||||
Server.init_db(self.test_db)
|
||||
payload = {
|
||||
@@ -131,15 +161,135 @@ class TestServerComponent(unittest.TestCase):
|
||||
|
||||
conn = sqlite3.connect(self.test_db)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT signature FROM active_issues ORDER BY signature")
|
||||
sigs = [r[0] for r in c.fetchall()]
|
||||
c.execute("SELECT signature, status FROM active_issues ORDER BY signature")
|
||||
rows = dict(c.fetchall())
|
||||
conn.close()
|
||||
|
||||
self.assertIn("SigInfo", sigs)
|
||||
self.assertIn("SigWarn", sigs)
|
||||
self.assertIn("SigErr", sigs)
|
||||
self.assertNotIn("SigDebug", sigs)
|
||||
self.assertNotIn("SigTrace", sigs)
|
||||
self.assertIn("SigInfo", rows)
|
||||
self.assertIn("SigWarn", rows)
|
||||
self.assertIn("SigErr", rows)
|
||||
self.assertNotIn("SigDebug", rows)
|
||||
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__":
|
||||
|
||||
@@ -8,6 +8,7 @@ import warnings
|
||||
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__), "..", "src")))
|
||||
|
||||
import Win_Client
|
||||
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)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user