Compare commits
75
Commits
355c6e1bc0
..
v2.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ff8ab6cc | ||
|
|
fd6da560ed | ||
|
|
956dfc5d93 | ||
|
|
35e6a8df3e | ||
|
|
c121291dda | ||
|
|
d19bbb2ec1 | ||
|
|
e6dc82ff55 | ||
|
|
56d8516427 | ||
|
|
94ad3f9461 | ||
|
|
26a4509429 | ||
|
|
80ae42088f | ||
|
|
42f01addca | ||
|
|
a9f096ef1b | ||
|
|
26d3d59812 | ||
|
|
5fec32327f | ||
|
|
478807d873 | ||
|
|
149ba6dfec | ||
|
|
a11b05f0a9 | ||
|
|
722d2a1fec | ||
|
|
5883b78822 | ||
|
|
d79de301bf | ||
|
|
184fdc6bc6 | ||
|
|
4625b650e5 | ||
|
|
f38bbfb540 | ||
|
|
18f0286692 | ||
|
|
c9f769ef2b | ||
|
|
c01c09ecbd | ||
|
|
2afe94fb36 | ||
|
|
4650cbcafc | ||
|
|
0d613b3d22 | ||
|
|
916d3764a2 | ||
|
|
0e7d299594 | ||
|
|
2cff629e23 | ||
|
|
cfc633c398 | ||
|
|
d61e343fbe | ||
|
|
4a446a2e73 | ||
|
|
b24108a788 | ||
|
|
1c985c85c8 | ||
|
|
249b754423 | ||
|
|
16faad063d | ||
|
|
c705be57eb | ||
|
|
3d6a2b86d6 | ||
|
|
1d0845b548 | ||
|
|
11f16ed8e1 | ||
|
|
8cb3089e8e | ||
|
|
5bbb56b4c3 | ||
|
|
a04d9bac9f | ||
|
|
8813d2d865 | ||
|
|
7e9f7a56f8 | ||
|
|
99920ef395 | ||
|
|
ccb23d65e5 | ||
|
|
fb9ef6e6cb | ||
|
|
491d2b1194 | ||
|
|
e83a5b3e0f | ||
|
|
0901ccb3eb | ||
|
|
cb4c763e0e | ||
|
|
84579d8719 | ||
|
|
08aa4edfb1 | ||
|
|
e4f6a9295b | ||
|
|
7f6bf4442f | ||
|
|
db80e7f5a2 | ||
|
|
1562285034 | ||
|
|
74942d4c00 | ||
|
|
874d693dac | ||
|
|
15bfb4940b | ||
|
|
364fefea47 | ||
|
|
9624935a81 | ||
|
|
867271e8b7 | ||
|
|
205d0cfbad | ||
|
|
d37191e302 | ||
|
|
35a736dacb | ||
|
|
f871344da4 | ||
|
|
e1dbe32063 | ||
|
|
98a227a234 | ||
|
|
4e2242e0ae |
@@ -5,6 +5,8 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- '**'
|
- '**'
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
|
- '*'
|
||||||
|
- '**'
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -12,6 +14,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Component Tests & Pipeline Verification
|
name: Run Component Tests & Pipeline Verification
|
||||||
|
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
@@ -28,7 +31,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify Python Syntax
|
- name: Verify Python Syntax
|
||||||
run: |
|
run: |
|
||||||
python3 -m py_compile src/Server.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
python3 -m py_compile src/Server.py src/server_enrollment.py src/Win_Client.py src/Linux_Client.py compilation/package_dist.py compilation/upload_release.py tests/test_pipeline.py tests/*.py
|
||||||
|
|
||||||
- name: Run Component Unit Tests
|
- name: Run Component Unit Tests
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Release Linux Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (e.g. v1.0.1)'
|
|
||||||
required: false
|
|
||||||
default: 'v1.0.1'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-linux:
|
|
||||||
name: Build & Release Linux Binaries
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Python and Build Dependencies
|
|
||||||
run: |
|
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y python3 python3-pip python3-venv binutils zip
|
|
||||||
fi
|
|
||||||
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
|
||||||
pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -r compilation/requirements.txt
|
|
||||||
|
|
||||||
- name: Compile Standalone Linux Binaries
|
|
||||||
run: |
|
|
||||||
python3 compilation/package_dist.py --target linux
|
|
||||||
|
|
||||||
- name: Publish Linux Release Assets
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ inputs.tag || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
python3 compilation/upload_release.py --skip-build
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
name: Release Windows Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Release tag (e.g. v1.0.1)'
|
|
||||||
required: false
|
|
||||||
default: 'v1.0.1'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-windows:
|
|
||||||
name: Build & Release Windows Binaries
|
|
||||||
runs-on: windows-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py -m pip install --upgrade pip
|
|
||||||
& $py -m pip install pyinstaller -r compilation/requirements.txt
|
|
||||||
|
|
||||||
- name: Compile Standalone Windows Binaries
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
- name: Publish Windows Release Assets
|
|
||||||
shell: powershell
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
|
||||||
GITEA_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITEA_REF_NAME: ${{ inputs.tag || github.ref_name }}
|
|
||||||
run: |
|
|
||||||
$py = "python"
|
|
||||||
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
|
||||||
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
|
||||||
$py = "py -3.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& $py compilation/upload_release.py --skip-build
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
name: Release Binaries & Installers
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Release tag to publish assets to (default: v2.0.1)'
|
||||||
|
required: false
|
||||||
|
default: 'v2.0.1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-linux:
|
||||||
|
name: Build & Release Linux Binaries
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Python and Build Dependencies
|
||||||
|
run: |
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y python3 python3-pip python3-venv binutils zip
|
||||||
|
fi
|
||||||
|
python3 -m pip install --upgrade pip --break-system-packages || python3 -m pip install --upgrade pip || true
|
||||||
|
pip3 install pyinstaller -r compilation/requirements.txt --break-system-packages || pip3 install pyinstaller -r compilation/requirements.txt
|
||||||
|
|
||||||
|
- name: Compile Standalone Linux Binaries
|
||||||
|
run: |
|
||||||
|
python3 compilation/package_dist.py --target linux
|
||||||
|
|
||||||
|
- name: Publish Linux Release Assets
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
python3 compilation/upload_release.py --skip-build
|
||||||
|
|
||||||
|
release-windows:
|
||||||
|
name: Build & Release Windows Binaries & Installers
|
||||||
|
# Note: Requires a registered Gitea Act Runner with label 'windows-latest'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$server = "${{ github.server_url }}"
|
||||||
|
$token = "${{ secrets.TAG_TOKEN || github.token }}"
|
||||||
|
$repo = "${{ github.repository }}"
|
||||||
|
$cleanUrl = $server -replace "^https?://", ""
|
||||||
|
$authUrl = "https://${token}@${cleanUrl}/${repo}.git"
|
||||||
|
|
||||||
|
if (-not (Test-Path ".git")) {
|
||||||
|
git init
|
||||||
|
git remote add origin $authUrl
|
||||||
|
} else {
|
||||||
|
git remote set-url origin $authUrl
|
||||||
|
}
|
||||||
|
git fetch --depth 1 origin "${{ github.sha }}"
|
||||||
|
git checkout -f FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$py = "python"
|
||||||
|
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||||
|
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||||
|
$py = "py -3.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& $py -m pip install --upgrade pip
|
||||||
|
& $py -m pip install pyinstaller cryptography -r compilation/requirements.txt
|
||||||
|
|
||||||
|
- name: Compile Standalone Windows Binaries
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$py = "python"
|
||||||
|
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||||
|
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||||
|
$py = "py -3.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& $py compilation/package_dist.py --target windows
|
||||||
|
|
||||||
|
- name: Compile Inno Setup Installers
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$iscc = $null
|
||||||
|
if (Test-Path "C:\Program Files (x86)\Inno Setup 6\ISCC.exe") {
|
||||||
|
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||||
|
} elseif (Test-Path "C:\Program Files\Inno Setup 6\ISCC.exe") {
|
||||||
|
$iscc = "C:\Program Files\Inno Setup 6\ISCC.exe"
|
||||||
|
} elseif (Get-Command "ISCC.exe" -ErrorAction SilentlyContinue) {
|
||||||
|
$iscc = "ISCC.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($iscc) {
|
||||||
|
Write-Host "[*] Compiling Windows Inno Setup installers using $iscc..."
|
||||||
|
& $iscc compilation/installer_client.iss
|
||||||
|
& $iscc compilation/installer_server.iss
|
||||||
|
} else {
|
||||||
|
Write-Host "[!] Inno Setup compiler (ISCC.exe) not found on runner host. Skipping installer compilation."
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Publish Windows Release Assets
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.TAG_TOKEN || github.token }}
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ inputs.tag || github.event.release.tag_name || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
$py = "python"
|
||||||
|
if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) {
|
||||||
|
if (Get-Command "py" -ErrorAction SilentlyContinue) {
|
||||||
|
$py = "py -3.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& $py compilation/upload_release.py --skip-build
|
||||||
@@ -11,6 +11,7 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
*.exe
|
*.exe
|
||||||
|
!compilation/nssm.exe
|
||||||
*.bin
|
*.bin
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
# LOGAR: Edge-Thin Log Analysis & Temporal Verification System
|
# LOGAR: Edge-Thin Log Analysis & Temporal Verification System
|
||||||
|
|
||||||
**LOGAR** is an enterprise log aggregation, verification, and anomaly detection architecture designed for heterogeneous server fleets (Windows & Linux). It combines lightweight zero-state edge forwarders with a centralized cloud hub that applies OpenPGP encryption, authenticated TCP streaming, temporal persistence tracking across 12-hour evaluation windows, and an automated 4-run rule to filter out transient infrastructure blips before reporting verified anomalies to **Hermes**.
|
**LOGAR** is an enterprise log aggregation, verification, and anomaly detection architecture designed for heterogeneous server fleets (Windows & Linux). It combines lightweight zero-state edge forwarders with a centralized cloud hub that applies mutual TLS 1.3 (**mTLS**) authentication, dynamic PKI licensing and quota management, temporal persistence tracking across 12-hour evaluation windows, an automated 4-run rule to filter transient warnings, and immediate pass-through for critical errors before reporting verified anomalies to **Hermes**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
1. [Core Philosophy](#core-philosophy)
|
1. [Core Philosophy](#core-philosophy)
|
||||||
2. [Architecture & Data Flow](#architecture--data-flow)
|
2. [Architecture & Data Flow](#architecture--data-flow)
|
||||||
3. [Security & Cryptographic Model](#security--cryptographic-model)
|
3. [mTLS Security, Dynamic PKI & Licensing](#mtls-security-dynamic-pki--licensing)
|
||||||
4. [Cloud-Side Temporal Persistence & 4-Run Rule](#cloud-side-temporal-persistence--4-run-rule)
|
4. [Cloud-Side Temporal Persistence & 4-Run Rule](#cloud-side-temporal-persistence--4-run-rule)
|
||||||
5. [Agentic Hermes Integration](#agentic-hermes-integration)
|
5. [Agentic Hermes & Client Management API](#agentic-hermes--client-management-api)
|
||||||
6. [Dynamic Machine & Domain Identification](#dynamic-machine--domain-identification)
|
6. [Dynamic Machine & Domain Identification](#dynamic-machine--domain-identification)
|
||||||
7. [Repository & Shippables Structure](#repository--shippables-structure)
|
7. [Automated Service Installers (Linux & Windows)](#automated-service-installers-linux--windows)
|
||||||
8. [Getting Started & Installation](#getting-started--installation)
|
8. [Repository & Shippables Structure](#repository--shippables-structure)
|
||||||
9. [Running Tests](#running-tests)
|
9. [Getting Started & Deployment](#getting-started--deployment)
|
||||||
10. [Automated Releases via Gitea Actions](#automated-releases-via-gitea-actions)
|
10. [Running Tests](#running-tests)
|
||||||
|
11. [Automated Releases via Gitea Actions](#automated-releases-via-gitea-actions)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,19 +23,20 @@
|
|||||||
|
|
||||||
### 1. Edge Thinness & Zero State
|
### 1. Edge Thinness & Zero State
|
||||||
Site agents running on Windows and Linux act strictly as lightweight forwarders:
|
Site agents running on Windows and Linux act strictly as lightweight forwarders:
|
||||||
- **No Local Database**: Clients maintain zero state and no local SQLite or cache files.
|
- **No Local Database**: Clients maintain zero local SQLite or heavy cache files.
|
||||||
- **Source-Level Filtering**: Agents upload candidate entries spanning from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), while stripping verbose debugging noise (`DEBUG`, audit entries) and skipping any entries older than 24 hours.
|
- **Source-Level Filtering**: Agents stream candidate entries from informational events up to errors (`INFO`, `WARNING`, `ERROR`, `CRITICAL`), stripping verbose debugging noise (`DEBUG`, trace entries) and skipping events older than 24 hours.
|
||||||
- **End-to-End Encryption**: Logs are encrypted using the server's OpenPGP public key before leaving the edge node.
|
- **Transport Security (mTLS 1.3)**: Logs are streamed directly over mutual TLS 1.3 sockets with hardware-bound / machine-unique client certificates.
|
||||||
- **Secure TCP Sockets**: Ingestion occurs over low-overhead authenticated TCP sockets rather than bulky HTTP/HTTPS endpoints.
|
- **Zero Configuration Overhead**: Clients auto-bootstrap certificate enrollment on first run if configured with an enrollment secret.
|
||||||
|
|
||||||
### 2. Cloud-Side Temporal Persistence
|
### 2. Cloud-Side Temporal Persistence & Severity Routing
|
||||||
The central Python/TCP hub handles the heavy lifting:
|
The central Python hub handles state and verification:
|
||||||
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
- State tracking is managed centrally in SQLite (`logar_state.db`).
|
||||||
- Candidate issues are evaluated over a **12-hour temporal evaluation window**.
|
- Candidate issues are evaluated over a **12-hour temporal evaluation window**.
|
||||||
- An issue must persist across **at least 4 consecutive runs / cycles** to be confirmed as a genuine system anomaly. Transient blips and sporadic spikes are filtered out automatically.
|
- **Warning Persistence (4-Run Rule)**: `WARNING` issues must persist across **at least 4 consecutive runs / cycles** within the 12-hour window to be confirmed as genuine anomalies, automatically filtering transient infrastructure blips.
|
||||||
|
- **Immediate Error Pass**: Critical errors (`ERROR`, `CRITICAL`, `FATAL`) bypass the 4-run threshold and are promoted immediately to `VERIFIED` on their first occurrence.
|
||||||
|
|
||||||
### 3. Agentic Integration with Hermes
|
### 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 engineers manually sifting through logs, **Hermes** ingests pre-filtered anomalies directly from the cloud hub (`GET /api/hermes/report`), treating verified errors and 4-run validated warnings as actionable system artifacts to trigger precise notifications and remediations.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,34 +44,43 @@ Instead of human engineers manually diving through noisy logs, **Hermes** ingest
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
subgraph Edge Nodes [State-Tracking Edge Forwarders]
|
subgraph Edge Nodes [Lightweight Edge Forwarders]
|
||||||
W[Win_Client.py / Win_Client.exe / Win_Client.pyz<br/>Windows Event Log Application]
|
W[Win_Client.exe / Win_Client.py<br/>Windows Event Log Ingestion]
|
||||||
L[Linux_Client.py / Linux_Client.bin<br/>systemd journalctl -p info]
|
L[Linux_Client.bin / Linux_Client.py<br/>systemd journalctl -p warning]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Security Layer [Security & Framing]
|
subgraph Enrollment [Dynamic PKI & Licensing]
|
||||||
E[OpenPGP Payload Encryption<br/>Server Public Key & Fingerprint]
|
ENR[POST /api/client/enroll<br/>License Quota & Secret Validation]
|
||||||
S[Length-Prefixed Framing<br/>4-byte Big-Endian + Auth Envelope]
|
CA[Internal Root CA<br/>Signs RSA-2048 Client Cert]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Cloud Hub [LOGAR Central Server Hub]
|
subgraph Transport [mTLS 1.3 Security Layer]
|
||||||
TCP[Authenticated TCP Listener<br/>Port 9443]
|
MTLS[Mutual TLS 1.3 Handshake<br/>Port 9443 - Client Cert Required]
|
||||||
DEC[OpenPGP Decryption<br/>Server Private Key]
|
AUTH[Extract Client CN & Fingerprint<br/>Validate Active License in SQLite]
|
||||||
DB[(SQLite Persistence<br/>active_issues & ingest_runs)]
|
end
|
||||||
|
|
||||||
|
subgraph Hub [LOGAR Server Hub]
|
||||||
|
INGEST[Length-Prefixed Frame Ingestion]
|
||||||
|
DB[(SQLite Persistence<br/>active_issues, clients, license_config)]
|
||||||
RULE{12h Window &<br/>4-Run Rule}
|
RULE{12h Window &<br/>4-Run Rule}
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Agentic Reporting [Downstream Integration]
|
subgraph Downstream [Hermes Agent & Monitoring]
|
||||||
API[FastAPI / Uvicorn Reporting<br/>Port 8443]
|
API[FastAPI Reporting & Management<br/>Port 8443]
|
||||||
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
HERMES[Hermes Agent<br/>GET /api/hermes/report]
|
||||||
end
|
end
|
||||||
|
|
||||||
W --> E
|
W -->|Auto-Enrollment| ENR
|
||||||
L --> E
|
L -->|Auto-Enrollment| ENR
|
||||||
E --> S
|
ENR --> CA
|
||||||
S -->|TCP Stream| TCP
|
CA -->|ca.crt, client.crt, client.key| W
|
||||||
TCP --> DEC
|
CA -->|ca.crt, client.crt, client.key| L
|
||||||
DEC --> RULE
|
|
||||||
|
W -->|mTLS Stream| MTLS
|
||||||
|
L -->|mTLS Stream| MTLS
|
||||||
|
MTLS --> AUTH
|
||||||
|
AUTH --> INGEST
|
||||||
|
INGEST --> RULE
|
||||||
RULE --> DB
|
RULE --> DB
|
||||||
DB --> API
|
DB --> API
|
||||||
API --> HERMES
|
API --> HERMES
|
||||||
@@ -77,31 +88,40 @@ graph TB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security & Cryptographic Model
|
## mTLS Security, Dynamic PKI & Licensing
|
||||||
|
|
||||||
### Pure-Python OpenPGP (RFC 4880)
|
### 1. TLS 1.3 Mutual Authentication (mTLS)
|
||||||
- **Zero OS Binary Dependency**: Utilizes `pgpy` and `cryptography` in pure Python. **No native GnuPG or `gpg` binary installation is required** on the server, Windows nodes, or Linux nodes.
|
- **Port 9443**: Ingestion occurs exclusively over TLS 1.3 sockets with `ssl.CERT_REQUIRED`.
|
||||||
- **First-Run Automatic Key Generation**: On the first launch, if `server_config.json` is missing, `Server.py` automatically generates:
|
- Both the hub and edge clients verify each other's certificates:
|
||||||
- An OpenPGP RSA 2048 keypair with encryption-only usage flags.
|
- Client verifies server certificate against `ca.crt`.
|
||||||
- An armored private key (`private_key`) and public key (`public_key`).
|
- Server verifies client certificate against the Root CA.
|
||||||
- A SHA-256 public encryption fingerprint (`server_fingerprint`).
|
- **Client CN Identification**: In the TLS handshake, the server extracts the `commonName` attribute (`client_id`), validates that the client is marked `active` in the `clients` table, updates the `last_seen` timestamp, and drops unregistered or revoked certificates immediately.
|
||||||
- A cryptographically random authentication secret token (`auth_token`).
|
|
||||||
- **Client Configuration Exporter**:
|
### 2. Dynamic PKI Hub Engine (`src/server_enrollment.py`)
|
||||||
```bash
|
- **Root CA**: On first run, `Server.py` creates a self-signed Root CA (`ca.crt` / `ca.key`) valid for 10 years.
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
- **Server TLS Certificate**: Generated automatically with Subject Alternative Names (SANs) for `localhost`, `127.0.0.1`, server IP, and hostnames.
|
||||||
```
|
- **Authority Key Identifiers**: Full compliance with OpenSSL 3.x and Python 3.12–3.14 via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` extensions.
|
||||||
Produces an anonymous client config containing only the server socket coordinates, authentication token, and the encryption-only public key & fingerprint.
|
- **Dynamic Client Certificates**: RSA-2048 keys and X.509 client certificates are issued on the fly via the enrollment API.
|
||||||
- **Socket Protocol Framing**:
|
|
||||||
- `[4 bytes big-endian unsigned int]` : Total envelope length.
|
### 3. Seat Accounting & Licensing
|
||||||
- `[JSON Envelope]` :
|
- Stored in SQLite table `license_config`:
|
||||||
```json
|
- `max_seats`: Maximum concurrent active client licenses (default: 10).
|
||||||
{
|
- `enrollment_secret`: Cryptographic secret required for initial client enrollment.
|
||||||
"auth_token": "<SECRET_TOKEN>",
|
- Stored in SQLite table `clients`:
|
||||||
"timestamp": "2026-09-03T...",
|
- `client_id`: Unique client identifier (machine GUID or hardware hash).
|
||||||
"encrypted_payload": "-----BEGIN PGP MESSAGE-----\n..."
|
- `hostname`, `os_type`, `cert_fingerprint`, `status` (`active` / `revoked`), `first_seen`, `last_seen`.
|
||||||
}
|
- When a new client enrolls:
|
||||||
```
|
- If `active_seats >= max_seats`, the hub rejects registration with `HTTP 403 (License seat limit reached)`.
|
||||||
- Unauthorized clients or invalid authentication tokens are rejected immediately.
|
- Existing registered clients can re-enroll / renew seamlessly without consuming additional seats.
|
||||||
|
|
||||||
|
### 4. In-Flight Certificate Watchdog & Automated Self-Healing Renewal
|
||||||
|
- **Continuous Hub PKI Watchdog**:
|
||||||
|
- The server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, running every 12 hours) alongside startup checks.
|
||||||
|
- The hub automatically inspects expiration dates of both the Root CA (`ca.crt`) and the Server TLS certificate (`server.crt`).
|
||||||
|
- If either certificate is within 30 days of expiration, the server regenerates certificates (backing up previous keys as `ca.crt.<timestamp>.bak`) and dynamically reloads its active `ssl.SSLContext` in memory without dropping socket connections or restarting the service.
|
||||||
|
- **Client Proactive Check & Reactive Self-Healing**:
|
||||||
|
- **Proactive Renewal**: Edge clients inspect `client.crt` before every run cycle. If the certificate expires in less than 30 days, it automatically contacts `/api/client/enroll` to renew its certificate.
|
||||||
|
- **Reactive Self-Healing**: If the server hub Root CA rotates or a handshake fails with `ssl.SSLError` / `SSLCertVerificationError`, edge clients catch the verification exception, re-bootstrap certificate enrollment against the hub, and re-establish the connection cleanly without human intervention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,34 +130,77 @@ graph TB
|
|||||||
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
Incoming candidate logs are tracked in SQLite table `active_issues`:
|
||||||
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
- **Issue Fingerprint**: Formatted as `{site_name}:{server}:{signature}`.
|
||||||
- **12-Hour Evaluation Window**:
|
- **12-Hour Evaluation Window**:
|
||||||
- When an issue is observed, the hub compares `(now - last_seen)`.
|
- The hub compares `(now - last_seen)`.
|
||||||
- If more than 12 hours have passed since the issue was last recorded, the previous window is expired and the cycle resets to `run_count = 1` with status `TRANSIENT`.
|
- If more than 12 hours have elapsed since the issue was last recorded, the previous window expires and the cycle resets to `run_count = 1`.
|
||||||
- **4-Run Rule**:
|
- **4-Run Rule for Warnings**:
|
||||||
- For each distinct run batch, `run_count` increments.
|
- The 4-run persistence threshold applies to `WARNING` (and `INFO`) events to eliminate transient operational noise.
|
||||||
- Issues with `run_count < 4` are marked as `TRANSIENT` and ignored by downstream reporting.
|
- Each distinct run batch increments `run_count`.
|
||||||
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agentic Hermes Integration
|
## Agentic Hermes & Client Management API
|
||||||
|
|
||||||
The server hub serves a REST reporting API (default port `8443`):
|
The server hub exposes a management and reporting REST API (default port `8443`):
|
||||||
|
|
||||||
|
### `POST /api/client/enroll`
|
||||||
|
Client enrollment endpoint:
|
||||||
|
- **Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "web-worker-01.corp.internal",
|
||||||
|
"hostname": "web-worker-01",
|
||||||
|
"os": "linux",
|
||||||
|
"enrollment_secret": "<SECRET>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ca_cert": "-----BEGIN CERTIFICATE-----\n...",
|
||||||
|
"client_cert": "-----BEGIN CERTIFICATE-----\n...",
|
||||||
|
"client_key": "-----BEGIN RSA PRIVATE KEY-----\n..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/clients`
|
||||||
|
Returns seat quota status and registered client telemetry:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active_seats": 2,
|
||||||
|
"max_seats": 10,
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"client_id": "web-worker-01.corp.internal",
|
||||||
|
"hostname": "web-worker-01",
|
||||||
|
"os_type": "linux",
|
||||||
|
"cert_fingerprint": "7D5B660B...",
|
||||||
|
"status": "active",
|
||||||
|
"first_seen": "2026-09-04 18:00:00",
|
||||||
|
"last_seen": "2026-09-04 19:15:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### `GET /api/hermes/report`
|
### `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 window):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"fingerprint": "corp.internal:web-app-01.corp.internal:NginxWorkerCrash",
|
"fingerprint": "corp.internal:web-worker-01.corp.internal:PostgresPoolExhausted",
|
||||||
"site": "corp.internal",
|
"site": "corp.internal",
|
||||||
"server": "web-app-01.corp.internal",
|
"server": "web-worker-01.corp.internal",
|
||||||
"signature": "NginxWorkerCrash",
|
"signature": "PostgresPoolExhausted",
|
||||||
"severity": "ERROR",
|
"severity": "WARNING",
|
||||||
"message": "Worker process 4120 terminated with signal 11",
|
"message": "Connection pool saturated (>95%) across 4 runs",
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
"first_seen": "2026-09-03T09:00:00+00:00",
|
"first_seen": "2026-09-04T07:00:00+00:00",
|
||||||
"last_seen": "2026-09-03T21:00:00+00:00",
|
"last_seen": "2026-09-04T19:00:00+00:00",
|
||||||
"consecutive_runs": 4,
|
"consecutive_runs": 4,
|
||||||
"evaluation_window": "12h",
|
"evaluation_window": "12h",
|
||||||
"verified": true,
|
"verified": true,
|
||||||
@@ -147,19 +210,19 @@ Returns exclusively **verified anomalies** that have satisfied the 4-run rule wi
|
|||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/hermes/all`
|
### `GET /api/hermes/all`
|
||||||
Diagnostic endpoint listing all active issues (both `TRANSIENT` candidate blips and `VERIFIED` anomalies).
|
Diagnostic endpoint listing all candidate issues (`TRANSIENT` and `VERIFIED`).
|
||||||
|
|
||||||
### `GET /health`
|
### `GET /health`
|
||||||
Returns hub health, encryption fingerprint, and listener ports.
|
Returns hub health, encryption fingerprint, listener ports, and mTLS status.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dynamic Machine & Domain Identification
|
## Dynamic Machine & Domain Identification
|
||||||
|
|
||||||
Client configurations intentionally contain **no machine name or site name**. Both forwarders dynamically identify their host and domain at runtime via `get_machine_identifier()`:
|
Client configurations intentionally contain **no hardcoded machine name or site name**. Both forwarders dynamically identify their host and domain at runtime via `get_machine_identifier()`:
|
||||||
1. **Fully Qualified Domain Name (FQDN)**: Checked via `socket.getfqdn()`.
|
1. **Fully Qualified Domain Name (FQDN)**: Checked via `socket.getfqdn()`.
|
||||||
2. **OS-Specific Domain Discovery**:
|
2. **OS-Specific Domain Discovery**:
|
||||||
- **Windows**: Checks Active Directory environment variable `USERDNSDOMAIN` / `USERDOMAIN`.
|
- **Windows**: Checks Active Directory environment variables (`USERDNSDOMAIN`, `USERDOMAIN`).
|
||||||
- **Linux**: Parses `/etc/resolv.conf` `domain` and `search` directives.
|
- **Linux**: Parses `/etc/resolv.conf` `domain` and `search` directives.
|
||||||
3. **Reverse DNS Lookup**: Resolves canonical hostname via `socket.gethostbyaddr`.
|
3. **Reverse DNS Lookup**: Resolves canonical hostname via `socket.gethostbyaddr`.
|
||||||
4. **Fallback**: Local hostname `socket.gethostname()`.
|
4. **Fallback**: Local hostname `socket.gethostname()`.
|
||||||
@@ -168,45 +231,82 @@ The server automatically infers site attribution from domain qualifiers (e.g. `n
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Automated Service Installers (Linux & Windows)
|
||||||
|
|
||||||
|
LOGAR provides production-grade installation scripts and installer builders for automated service deployment:
|
||||||
|
|
||||||
|
### 1. Linux Service Installers
|
||||||
|
- **Client Installer (`compilation/install_linux_client.sh`)**:
|
||||||
|
- Non-interactive script deploying to `/opt/logar-client`.
|
||||||
|
- Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||||
|
- Installs and enables `logar-client.service` systemd unit.
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_client.sh "http://hub.example.com:8443" "<ENROLLMENT_SECRET>"
|
||||||
|
```
|
||||||
|
- **Server Installer (`compilation/install_linux_server.sh`)**:
|
||||||
|
- Deploys server to `/opt/logar-server`.
|
||||||
|
- Configures logging and installs `logar-server.service` with `LimitNOFILE=65536`.
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Windows Inno Setup Installers
|
||||||
|
- Built using **Inno Setup 6** and bundled with **NSSM** (`compilation/nssm.exe`):
|
||||||
|
- **Client Setup (`compilation/installer_client.iss`)**: Compiles `LOGAR-Client-Setup.exe`. Installs `Win_Client.exe` into `{autopf}\LOGAR`, sets up `LOGAR_Client` service via NSSM with stdout/stderr redirection to `{commonappdata}\LOGAR\client.log`, and starts the service. Clean uninstallation stops and removes the service.
|
||||||
|
- **Server Setup (`compilation/installer_server.iss`)**: Compiles `LOGAR-Server-Setup.exe`. Installs `Server.exe` and sets up `LOGAR_Server` Windows service via NSSM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Repository & Shippables Structure
|
## Repository & Shippables Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
LOGAR/
|
LOGAR/
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── ci.yml # Continuous Integration automated test suite (runs on every push)
|
│ ├── ci.yml # CI pipeline: syntax, 25 unit tests & mTLS pipeline test
|
||||||
│ ├── release-linux.yml # Linux release workflow (compiles Server.bin, Linux_Client.bin, checksums)
|
│ └── release.yml # Consolidated release workflow (Linux binaries & Windows installers)
|
||||||
│ └── release-windows.yml # Windows release workflow (compiles Server.exe, Win_Client.exe, checksums)
|
├── compilation/ # Packaging, installers, and release automation
|
||||||
├── compilation/ # Build, packaging, and release automation tools
|
│ ├── install_linux_client.sh # Automated Linux client systemd installation script
|
||||||
│ ├── package_dist.py # Multi-platform standalone binary packaging script
|
│ ├── install_linux_server.sh # Automated Linux server systemd installation script
|
||||||
│ ├── requirements.txt # Unified project dependencies
|
│ ├── installer_client.iss # Inno Setup Windows Client installer script
|
||||||
│ └── upload_release.py # Direct Gitea REST API release asset publisher
|
│ ├── installer_server.iss # Inno Setup Windows Server installer script
|
||||||
├── src/ # Core application source modules
|
│ ├── nssm.exe # Official 64-bit NSSM service manager binary
|
||||||
|
│ ├── package_dist.py # Standalone binary compiler & packager
|
||||||
|
│ ├── requirements.txt # Unified project dependencies
|
||||||
|
│ └── upload_release.py # Gitea REST API release asset publisher
|
||||||
|
├── src/ # Core application source modules
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── Server.py # Central TCP server and Hermes API
|
│ ├── Server.py # Central mTLS server, temporal engine, and Hermes REST API
|
||||||
│ ├── Win_Client.py # Windows edge forwarder
|
│ ├── server_enrollment.py # Dynamic PKI, Root CA, and client certificate generator
|
||||||
│ └── Linux_Client.py # Linux edge forwarder
|
│ ├── Win_Client.py # Windows edge forwarder with auto-enrollment
|
||||||
├── tests/ # Automated test suites
|
│ └── Linux_Client.py # Linux edge forwarder with auto-enrollment
|
||||||
│ ├── test_linux_client.py # Linux client unit tests
|
├── tests/ # Automated test suites
|
||||||
│ ├── test_pipeline.py # End-to-end integration test
|
│ ├── test_linux_client.py # Linux client unit tests & mTLS certificate validation
|
||||||
│ ├── test_server.py # Server unit tests
|
│ ├── test_pipeline.py # End-to-end mTLS integration & 4-run verification test
|
||||||
│ └── test_win_client.py # Windows client unit tests
|
│ ├── test_server.py # Server unit tests, PKI generation, and seat quota tests
|
||||||
├── .gitignore # Ignore venv, caches, DBs, and private keys
|
│ └── test_win_client.py # Windows client unit tests & mTLS certificate validation
|
||||||
├── README.md # Comprehensive documentation
|
├── .gitignore # Ignores venv, caches, DBs, and private keys
|
||||||
├── RELEASE_NOTES.md # Release history and changelog
|
├── README.md # Architecture and usage documentation
|
||||||
├── server_config.sample.json # Central server sample configuration
|
├── RELEASE_NOTES.md # Release history and changelog
|
||||||
└── out/ # Edge forwarder deployment packages
|
├── server_config.sample.json # Reference server configuration
|
||||||
├── win_client/
|
└── out/ # Component guides and sample configs
|
||||||
│ ├── client_config.sample.json # Reference client configuration
|
├── linux_server/
|
||||||
│ └── README.md # Windows service installation & configuration guide
|
│ ├── README.md
|
||||||
└── linux_client/
|
│ └── server_config.sample.json
|
||||||
├── client_config.sample.json # Reference client configuration
|
├── win_server/
|
||||||
└── README.md # Linux service installation & configuration guide
|
│ ├── README.md
|
||||||
|
│ └── server_config.sample.json
|
||||||
|
├── linux_client/
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── client_config.sample.json
|
||||||
|
└── win_client/
|
||||||
|
├── README.md
|
||||||
|
└── client_config.sample.json
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting Started & Installation
|
## Getting Started & Deployment
|
||||||
|
|
||||||
### 1. Central Server Hub
|
### 1. Central Server Hub
|
||||||
|
|
||||||
@@ -214,7 +314,7 @@ LOGAR/
|
|||||||
```bash
|
```bash
|
||||||
pip install -r compilation/requirements.txt
|
pip install -r compilation/requirements.txt
|
||||||
```
|
```
|
||||||
2. **Start the server** (generates `server_config.json` and keypair on first run):
|
2. **Start the server** (generates `server_config.json`, Root CA, and server certs on first run):
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py
|
python src/Server.py
|
||||||
```
|
```
|
||||||
@@ -224,153 +324,72 @@ LOGAR/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. Windows Client Deployment
|
### 2. Windows Client Deployment
|
||||||
|
1. Download `LOGAR-Client-Setup.exe` from releases and run it, or place `Win_Client.exe` and `client_config.json` in `C:\Program Files\LOGAR`.
|
||||||
#### Option A: Precompiled Standalone Executable (Recommended)
|
2. On first run with `client_config.json`, `Win_Client.exe` automatically enrolls with the hub, receives its mTLS certificates, and establishes secure streaming.
|
||||||
1. Download `Win_Client.exe` (or `Win_Client.pyz`) from the repository releases.
|
|
||||||
2. Place `client_config.json` (exported from the server) in the same directory.
|
|
||||||
3. Run manually or schedule via Task Scheduler (every 3 hours):
|
|
||||||
```powershell
|
|
||||||
.\Win_Client.exe --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Win_Client.py`, `requirements.txt`, and `client_config.json` to the target machine.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```powershell
|
|
||||||
python -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. Run manually or schedule via Task Scheduler:
|
|
||||||
```powershell
|
|
||||||
python Win_Client.py --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Linux Client Deployment
|
### 3. Linux Client Deployment
|
||||||
|
1. Run the automated installer:
|
||||||
#### Option A: Precompiled Standalone Binary (Recommended)
|
|
||||||
1. Download `Linux_Client.bin` from the repository releases.
|
|
||||||
2. Place `Linux_Client.bin` and `client_config.json` into `/opt/logar/` and make it executable:
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x /opt/logar/Linux_Client.bin
|
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||||
```
|
|
||||||
3. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * /opt/logar/Linux_Client.bin --hours 24
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Python Source Execution
|
|
||||||
1. Copy `Linux_Client.py`, `requirements.txt`, and `client_config.json` to `/opt/logar/`.
|
|
||||||
2. Install client dependencies:
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
3. (Optional) Run `out/linux_client/build_bin.sh` to compile a standalone ELF binary locally if desired.
|
|
||||||
4. Run via cron or systemd timer:
|
|
||||||
```bash
|
|
||||||
0 */3 * * * python3 /opt/logar/Linux_Client.py --hours 24
|
|
||||||
```
|
```
|
||||||
|
2. The installer enrolls the client, configures `/etc/logar/certs`, and activates `logar-client.service`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
### 1. Component-Specific Unit Tests
|
### 1. Component Unit Tests
|
||||||
The test suite is located in `tests/` and exercises all components:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all unit tests
|
python -m unittest discover -s tests -v
|
||||||
python -m unittest discover -s tests
|
|
||||||
|
|
||||||
# Or run component tests individually:
|
|
||||||
python -m unittest tests/test_server.py
|
|
||||||
python -m unittest tests/test_win_client.py
|
|
||||||
python -m unittest tests/test_linux_client.py
|
|
||||||
```
|
```
|
||||||
|
Runs all **25 unit tests**, covering:
|
||||||
|
- Dynamic Root CA generation and server TLS certificate issuance.
|
||||||
|
- Dynamic client certificate issuance with CN and authority key extensions.
|
||||||
|
- Enrollment secret authentication, seat limits, and certificate revocation.
|
||||||
|
- Proactive certificate validity checks, Root CA auto-renewal, and in-flight server SSLContext reload.
|
||||||
|
- Windows & Linux event log collection, deduplication, and mTLS certificate verification.
|
||||||
|
- 12-hour evaluation window and 4-run rule progression.
|
||||||
|
|
||||||
### 2. End-to-End Pipeline Integration Test
|
### 2. End-to-End Pipeline Integration Test
|
||||||
The pipeline test exercises invalid token rejection, encrypted socket streaming, database persistence, status promotion upon the 4th run, and the Hermes API report output.
|
```bash
|
||||||
|
# 1. Initialize test configuration
|
||||||
|
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
||||||
|
|
||||||
1. **Start the server** in Shell 1 (creates `server_config.json` on first run):
|
# 2. Launch server in background
|
||||||
```bash
|
python src/Server.py &
|
||||||
python src/Server.py
|
|
||||||
```
|
|
||||||
2. **Export client configuration** in Shell 2 (required for testing):
|
|
||||||
```bash
|
|
||||||
python src/Server.py --create-client-config --server-host 127.0.0.1 --server-port 9443 --client-out client_config.json
|
|
||||||
```
|
|
||||||
3. **Execute the integration test** in Shell 2:
|
|
||||||
```bash
|
|
||||||
python tests/test_pipeline.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Continuous Integration via Gitea Actions
|
# 3. Run integration test
|
||||||
|
python tests/test_pipeline.py
|
||||||
Continuous integration is automated via [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) and triggers automatically on **every push** and pull request:
|
```
|
||||||
1. **Syntax Compilation**: Validates all Python scripts (`src/Server.py`, `src/Win_Client.py`, `src/Linux_Client.py`, `compilation/package_dist.py`, `compilation/upload_release.py`, `tests/test_pipeline.py`, and test suites).
|
Tests client enrollment, secret rejection, mTLS TLS 1.3 socket handshake, warning 4-run rule promotion, immediate error promotion, and Hermes report output.
|
||||||
2. **Component Unit Tests**: Discovers and runs all unit tests in `tests/` (`test_server.py`, `test_win_client.py`, `test_linux_client.py`).
|
|
||||||
3. **End-to-End Pipeline Verification**: Automatically spins up the LOGAR server hub, generates test configs, runs `tests/test_pipeline.py` (testing socket authentication, 4-run rule persistence, Hermes API report, and client integrations), and shuts down the test instance.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Automated Releases via Gitea Actions
|
## Releases & Binary Distribution
|
||||||
|
|
||||||
Release builds are automated via two dedicated Gitea Actions workflows running concurrently on native platform runners:
|
Releases can be built and published through three complementary channels:
|
||||||
- [`.gitea/workflows/release-linux.yml`](.gitea/workflows/release-linux.yml) (`ubuntu-latest`)
|
|
||||||
- [`.gitea/workflows/release-windows.yml`](.gitea/workflows/release-windows.yml) (`windows-latest`)
|
|
||||||
|
|
||||||
### Publishing a Release
|
### 1. Tag Push Automation (Gitea Actions)
|
||||||
Whenever you want to release a new version with compiled standalone binaries:
|
Pushing a release tag automatically triggers the build workflows:
|
||||||
```bash
|
```bash
|
||||||
git tag v1.0.1
|
git tag v2.0.1
|
||||||
git push origin v1.0.1
|
git push origin v2.0.1
|
||||||
```
|
```
|
||||||
*(You can also trigger builds manually via the Gitea UI using the **Run workflow** button (`workflow_dispatch`) on either workflow).*
|
The consolidated workflow **`release.yml`** defines two parallel jobs:
|
||||||
|
- **`release-linux`** (`ubuntu-latest`): Compiles standalone native ELF binaries (`Linux_Client.bin`, `Server.bin`) and checksums.
|
||||||
|
- **`release-windows`** (`windows-latest`): Compiles Windows executables (`Win_Client.exe`, `Server.exe`), builds Inno Setup installers, and publishes checksums (requires self-hosted Windows Act Runner).
|
||||||
|
|
||||||
### Automated Multi-Platform Compilation:
|
### 2. Manual Workflow Dispatch (Gitea UI)
|
||||||
1. **Linux Runner** (`release-linux.yml`):
|
Workflows can be manually triggered on demand from the Gitea web interface:
|
||||||
- Compiles native Linux ELF executables: `Linux_Client.bin` and `Server.bin`.
|
1. Navigate to **Actions** $\rightarrow$ **Release Binaries & Installers** (`release.yml`).
|
||||||
- Generates dedicated SHA-256 checksum files:
|
2. Click **Run workflow**, set the release tag (defaults to `v2.0.1`), and run.
|
||||||
- `linux_client_sha256sum` (verification for `Linux_Client.bin`)
|
|
||||||
- `linux_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `linux_server_sha256sum` (verification for `Server.bin`)
|
|
||||||
- `SHA256SUMS_linux.txt` (summary manifest)
|
|
||||||
- Attaches all Linux assets to the Gitea release.
|
|
||||||
|
|
||||||
2. **Windows Runner** (`release-windows.yml`):
|
### 3. Native Local Windows Build & Direct Release Upload
|
||||||
- Compiles native Windows PE executables: `Win_Client.exe` and `Server.exe`.
|
For environments without a registered Windows CI runner, Windows executables can be built and published directly to Gitea releases:
|
||||||
- Generates dedicated SHA-256 checksum files:
|
|
||||||
- `win_client_sha256sum` (verification for `Win_Client.exe`)
|
|
||||||
- `win_agent_sha256sum` (alias for client/agent integrations)
|
|
||||||
- `win_server_sha256sum` (verification for `Server.exe`)
|
|
||||||
- `SHA256SUMS_windows.txt` (summary manifest)
|
|
||||||
- Attaches all Windows assets to the Gitea release.
|
|
||||||
|
|
||||||
3. **Concurrent Publishing & Conflict Handling**:
|
|
||||||
`upload_release.py` includes automatic retry and conflict resolution so concurrent Windows and Linux runners attach their respective assets to the release without collision.
|
|
||||||
|
|
||||||
### Verifying Checksums
|
|
||||||
- On Linux:
|
|
||||||
```bash
|
|
||||||
sha256sum -c linux_client_sha256sum
|
|
||||||
# or
|
|
||||||
sha256sum -c linux_server_sha256sum
|
|
||||||
```
|
|
||||||
- On Windows (PowerShell):
|
|
||||||
```powershell
|
|
||||||
Get-FileHash .\Win_Client.exe -Algorithm SHA256
|
|
||||||
Get-Content .\win_client_sha256sum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Packaging & Manual Upload
|
|
||||||
You can also compile and package binaries locally anytime:
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
py -3.12 compilation/package_dist.py --target windows
|
|
||||||
|
|
||||||
# Linux
|
|
||||||
python3 compilation/package_dist.py --target linux
|
|
||||||
```
|
|
||||||
To upload local builds directly to Gitea:
|
|
||||||
```powershell
|
```powershell
|
||||||
python compilation/upload_release.py --tag v1.0.1 --token <YOUR_GITEA_TOKEN>
|
# 1. Build Windows binaries locally
|
||||||
|
python compilation/package_dist.py --target windows
|
||||||
|
|
||||||
|
# 2. Upload assets and release notes directly to the Gitea release
|
||||||
|
python compilation/upload_release.py --tag v2.0.1 --token <GITEA_TOKEN> --skip-build
|
||||||
```
|
```
|
||||||
*(Environment variables `GITEA_TOKEN`, `GITEA_SERVER_URL`, `GITEA_REPOSITORY`, and `GITEA_REF_NAME` are also supported automatically).*
|
|
||||||
|
|||||||
+59
-9
@@ -1,10 +1,60 @@
|
|||||||
# LOGAR Release v1.0.1
|
# LOGAR Release v2.0.1
|
||||||
|
|
||||||
|
Maintenance and deployment release consolidating Gitea Actions release automation into a unified single workflow file, standardizing release dispatching across platforms, and bumping version definitions across server hub and Windows installers.
|
||||||
|
|
||||||
|
### Key Highlights & Changes in v2.0.1:
|
||||||
|
|
||||||
|
- **Consolidated Single Release Automation Workflow (`.gitea/workflows/release.yml`)**:
|
||||||
|
- Unified separate platform release files into a single, cohesive workflow (`release.yml`) running parallel jobs (`release-linux` on `ubuntu-latest` and `release-windows` on `windows-latest`).
|
||||||
|
- Standardized tag matching for Gitea Actions on `push: tags: ['v*']`.
|
||||||
|
- Added streamlined manual `workflow_dispatch` triggers with automated release tag defaulting (`v2.0.1`).
|
||||||
|
- Retired deprecated `release-linux.yml` and `release-windows.yml` files.
|
||||||
|
|
||||||
|
- **Installer & Engine Version Bump**:
|
||||||
|
- Updated FastAPI Hub engine version to `2.0.1` in `src/Server.py`.
|
||||||
|
- Bumped Inno Setup Windows Client Installer (`compilation/installer_client.iss`) `AppVersion` to `2.0.1`.
|
||||||
|
- Bumped Inno Setup Windows Server Installer (`compilation/installer_server.iss`) `AppVersion` to `2.0.1`.
|
||||||
|
|
||||||
|
- **Distribution & Release Documentation**:
|
||||||
|
- Updated root and distribution documentation across all 5 deployment targets to reflect the 25 passing unit tests and tripartite release options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# LOGAR Release v2.0.0
|
||||||
|
|
||||||
|
Major architectural release introducing Mutual TLS 1.3 (mTLS) transport security, built-in dynamic PKI & license accounting, in-flight certificate validity monitoring and auto-renewal, and automated Windows and Linux service installers.
|
||||||
|
|
||||||
|
### Key Highlights & Changes in v2.0.0:
|
||||||
|
|
||||||
|
- **mTLS 1.3 Transport Security & Runtime Licensing**:
|
||||||
|
- Replaced legacy plain TCP sockets with mutual TLS 1.3 authentication (`ssl.CERT_REQUIRED`, TLS 1.3 minimum version).
|
||||||
|
- Hub dynamically validates incoming client Common Name (`client_id`) against active license seats in SQLite during the TLS handshake.
|
||||||
|
- Drops unauthorized, un-enrolled, or revoked clients at the transport layer before payload reading.
|
||||||
|
|
||||||
|
- **Dynamic Hub PKI Engine (`src/server_enrollment.py`)**:
|
||||||
|
- Automatically initializes an internal RSA-4096 Root CA (`ca.crt` / `ca.key`).
|
||||||
|
- Generates RSA-2048 Server TLS certificates with SANs for localhost, loopback, and server hostnames.
|
||||||
|
- Full OpenSSL 3.x and Python 3.12–3.14 compatibility via `SubjectKeyIdentifier` and `AuthorityKeyIdentifier` certificate extensions.
|
||||||
|
- Generates and signs client certificates on demand via `POST /api/client/enroll`.
|
||||||
|
|
||||||
|
- **In-Flight Certificate Validity Watchdog & Dynamic SSLContext Reloading**:
|
||||||
|
- Server hub runs a continuous background watchdog coroutine (`cert_validity_watchdog`, evaluated every 12 hours) alongside startup checks.
|
||||||
|
- Automatically checks Root CA and server TLS certificate expiration against a 30-day threshold.
|
||||||
|
- Generates renewed certificates on disk with timestamped backups (`.bak`), and reloads active `ssl.SSLContext` in memory dynamically without dropping socket listeners or restarting the background service.
|
||||||
|
|
||||||
|
- **Client Proactive Expiry Check & Reactive Self-Healing Auto-Renewal**:
|
||||||
|
- **Proactive**: Forwarders (`Win_Client.py` and `Linux_Client.py`) evaluate `client.crt` validity before each run, auto-renewing via `/api/client/enroll` if expiring within 30 days.
|
||||||
|
- **Reactive**: If the hub rotates its Root CA or a TLS verification error (`ssl.SSLError` / `SSLCertVerificationError`) occurs, clients automatically catch the error, re-enroll with the hub using their enrollment secret, and reconnect cleanly.
|
||||||
|
|
||||||
|
- **Database Schema & License Quota Accounting**:
|
||||||
|
- SQLite tables `license_config` (`max_seats`, `enrollment_secret`) and `clients` (`client_id`, `hostname`, `os_type`, `cert_fingerprint`, `status`, timestamps).
|
||||||
|
- Enforces seat limits on enrollment (`HTTP 403 License seat limit reached`) while allowing active registered nodes to re-enroll/renew indefinitely.
|
||||||
|
- Added `GET /api/clients` endpoint for license auditing and telemetry tracking.
|
||||||
|
|
||||||
|
- **Automated Service Installers**:
|
||||||
|
- **Windows**: Self-contained Inno Setup installers (`LOGAR-Client-Setup.exe` and `LOGAR-Server-Setup.exe`) bundling `nssm.exe` to register, configure, and start Windows services automatically.
|
||||||
|
- **Linux**: Automated installer scripts (`compilation/install_linux_client.sh` and `install_linux_server.sh`) deploying systemd service units with auto-restart policies.
|
||||||
|
|
||||||
|
- **CI/CD Release Workflows**:
|
||||||
|
- Windows workflow (`.gitea/workflows/release-windows.yml`) and Linux workflow (`.gitea/workflows/release-linux.yml`) automated to build native executables, installers, and upload release assets on tag push.
|
||||||
|
|
||||||
### 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).
|
|
||||||
- **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.
|
|
||||||
- **24-Hour Lookback Window**: Forwarders now scan and upload events from the last 24 hours (default `--hours 24`), skipping older entries.
|
|
||||||
- **Lightweight Distribution Structure**: Cleaned `out/` to strictly contain deployment documentation and sample configurations.
|
|
||||||
- **Automated Gitea CI/CD**: Integrated push testing workflow (`ci.yml`) and multi-platform release asset packaging.
|
|
||||||
|
|||||||
@@ -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=2.0.1
|
||||||
|
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=2.0.1
|
||||||
|
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.
+46
-58
@@ -1,40 +1,57 @@
|
|||||||
# LOGAR Linux Edge Forwarder
|
# LOGAR Linux Edge Forwarder
|
||||||
|
|
||||||
Standalone compiled binary distribution for Linux edge servers running systemd.
|
Standalone compiled binary and automated systemd service distribution for Linux edge servers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, encrypts the payload using OpenPGP, and streams candidate events over an authenticated TCP socket to the central LOGAR hub.
|
`Linux_Client.bin` is a self-contained, pre-compiled executable that queries `systemd-journald` via `journalctl`, filters logs directly at the source, auto-enrolls with the central LOGAR hub, and streams candidate events over mutual TLS 1.3 (**mTLS**) to the central hub.
|
||||||
|
|
||||||
### Key Capabilities
|
### Key Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone executable binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native binary (`Linux_Client.bin`). No Python environment, pip packages, or GnuPG binaries are required on the host.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p info`). Drops debug noise (priority 7) and skips events older than 24 hours.
|
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with machine-bound client certificates.
|
||||||
|
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `/etc/logar/certs/`, and establishes secure mTLS streaming.
|
||||||
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR` (`journalctl -p warning`). Drops debug noise and skips events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking systemd journalctl cursors and microsecond timestamps) so every log record is forwarded exactly once without duplicates.
|
||||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
## 1. Automated Installation via Script (Recommended)
|
||||||
|
|
||||||
|
Run the automated installer script:
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_client.sh "http://<HUB_HOST>:8443" "<ENROLLMENT_SECRET>"
|
||||||
|
```
|
||||||
|
This script:
|
||||||
|
1. Installs the binary to `/opt/logar-client/Linux_Client`.
|
||||||
|
2. Creates `/etc/logar/certs` with strict permissions.
|
||||||
|
3. Automatically queries `/etc/machine-id` and enrolls with the hub via `curl`.
|
||||||
|
4. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-client.service`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generating & Deploying the Configuration File
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
### Step 1: Generate `client_config.json` on the Server
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
Run the following command on your central LOGAR server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||||
- Default TCP port is `9443`.
|
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
### Step 2: Configuration Structure
|
||||||
The generated `client_config.json` contains:
|
The generated `client_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_host": "192.168.1.100",
|
"server_host": "192.168.1.100",
|
||||||
"server_port": 9443,
|
"server_port": 9443,
|
||||||
|
"hermes_host": "192.168.1.100",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||||
|
"cert_dir": "certs",
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
"auth_token": "a1b2c3d4e5f6..."
|
||||||
@@ -42,11 +59,10 @@ The generated `client_config.json` contains:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||||
|
|
||||||
### Step 3: Copy to Edge Node
|
### Step 3: Copy to Edge Node
|
||||||
Place `Linux_Client.bin` and `client_config.json` into the target directory (recommended: `/opt/logar/`):
|
Place `Linux_Client.bin` and `client_config.json` into the target directory (e.g. `/opt/logar/`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/logar
|
sudo mkdir -p /opt/logar
|
||||||
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
sudo cp Linux_Client.bin client_config.json /opt/logar/
|
||||||
@@ -55,14 +71,14 @@ sudo chmod +x /opt/logar/Linux_Client.bin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Running Manually
|
## 3. Running Manually
|
||||||
|
|
||||||
Test the forwarder interactively:
|
Test the forwarder interactively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/logar
|
cd /opt/logar
|
||||||
./Linux_Client.bin --hours 24
|
./Linux_Client.bin --hours 24
|
||||||
```
|
```
|
||||||
|
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||||
|
|
||||||
### Command-Line Arguments
|
### Command-Line Arguments
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
@@ -74,13 +90,10 @@ cd /opt/logar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Installing as a Systemd Service & Timer (Recommended)
|
## 4. Manual Systemd Service & Timer Setup
|
||||||
|
|
||||||
Running `Linux_Client.bin` via a systemd timer ensures reliable periodic execution, automatic restart, and native log integration with `journalctl`.
|
|
||||||
|
|
||||||
### Step 1: Create the Systemd Service Unit
|
### Step 1: Create the Systemd Service Unit
|
||||||
Create `/etc/systemd/system/logar-forwarder.service`:
|
Create `/etc/systemd/system/logar-client.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=LOGAR Edge Log Forwarder
|
Description=LOGAR Edge Log Forwarder
|
||||||
@@ -88,9 +101,11 @@ After=network-online.target
|
|||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=simple
|
||||||
WorkingDirectory=/opt/logar
|
WorkingDirectory=/opt/logar
|
||||||
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
ExecStart=/opt/logar/Linux_Client.bin --hours 24
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5s
|
||||||
User=root
|
User=root
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
@@ -99,51 +114,24 @@ StandardError=journal
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Create the Systemd Timer Unit
|
### Step 2: Enable and Start the Service
|
||||||
Create `/etc/systemd/system/logar-forwarder.timer` to execute the forwarder every 3 hours (with a 5-minute initial delay upon boot):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Run LOGAR Edge Forwarder periodically
|
|
||||||
Requires=logar-forwarder.service
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnBootSec=5min
|
|
||||||
OnUnitActiveSec=3h
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Enable and Start the Timer
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now logar-forwarder.timer
|
sudo systemctl enable --now logar-client.service
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Verify Timer & Service Status
|
### Step 3: Check Logs
|
||||||
```bash
|
```bash
|
||||||
# Check timer schedule
|
sudo journalctl -u logar-client.service -n 50 -f
|
||||||
sudo systemctl list-timers --all | grep logar
|
|
||||||
|
|
||||||
# Trigger an immediate manual execution
|
|
||||||
sudo systemctl start logar-forwarder.service
|
|
||||||
|
|
||||||
# View execution logs
|
|
||||||
sudo journalctl -u logar-forwarder.service -n 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Alternative: Cron Job Deployment
|
## 5. Uninstallation & Removal
|
||||||
|
|
||||||
If systemd timers are not preferred, configure a periodic cron job running every 3 hours:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Open root crontab
|
sudo systemctl disable --now logar-client.service
|
||||||
sudo crontab -e
|
sudo rm -f /etc/systemd/system/logar-client.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
# Add the following entry:
|
sudo rm -rf /opt/logar-client /opt/logar /etc/logar
|
||||||
0 */3 * * * cd /opt/logar && ./Linux_Client.bin --hours 24 >> /var/log/logar_forwarder.log 2>&1
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# LOGAR Linux Server Hub
|
||||||
|
|
||||||
|
Standalone compiled binary and automated systemd service 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, log analysis, dynamic PKI, and reporting 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.
|
||||||
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite.
|
||||||
|
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
||||||
|
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the systemd service.
|
||||||
|
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct client transmission cycles within a rolling 12-hour evaluation window.
|
||||||
|
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Automated Installation via Script (Recommended)
|
||||||
|
|
||||||
|
Deploy using the automated installer:
|
||||||
|
```bash
|
||||||
|
sudo ./compilation/install_linux_server.sh
|
||||||
|
```
|
||||||
|
This script:
|
||||||
|
1. Installs the server binary to `/opt/logar-server/Server`.
|
||||||
|
2. Creates `/etc/logar` and `/var/log/logar`.
|
||||||
|
3. Deploys, enables, and starts the systemd service unit `/etc/systemd/system/logar-server.service` with `LimitNOFILE=65536`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initializing & Generating Server Configuration
|
||||||
|
|
||||||
|
### Step 1: Automatic First-Run Generation
|
||||||
|
When launched without an existing `server_config.json`, `Server.bin` automatically generates:
|
||||||
|
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||||
|
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||||
|
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||||
|
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||||
|
5. Default network socket coordinates (mTLS 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
|
||||||
|
============================================================
|
||||||
|
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||||
|
Transport Security: mTLS (TLS 1.3)
|
||||||
|
License Quota: 10 Active Seats
|
||||||
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
|
Evaluation Window: 12 hours | 4-Run Rule: Warnings | Immediate Pass: Errors
|
||||||
|
============================================================
|
||||||
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
|
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configuration Fields Reference
|
||||||
|
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...",
|
||||||
|
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||||
|
"max_seats": 10,
|
||||||
|
"cert_dir": "certs",
|
||||||
|
"tls_enabled": true,
|
||||||
|
"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..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Exporting Client Configurations
|
||||||
|
|
||||||
|
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||||
|
```bash
|
||||||
|
./Server.bin --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||||
|
```
|
||||||
|
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Manual Systemd Service Management
|
||||||
|
|
||||||
|
Check service status:
|
||||||
|
```bash
|
||||||
|
sudo systemctl status logar-server.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect live service logs:
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u logar-server.service -f -n 50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Uninstallation & Removal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable --now logar-server.service
|
||||||
|
sudo rm -f /etc/systemd/system/logar-server.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo rm -rf /opt/logar-server /etc/logar /var/log/logar
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+49
-44
@@ -1,40 +1,57 @@
|
|||||||
# LOGAR Windows Edge Forwarder
|
# LOGAR Windows Edge Forwarder
|
||||||
|
|
||||||
Standalone compiled executable distribution for Windows Server and workstation environments.
|
Standalone compiled executable and installer distribution for Windows Server and workstation environments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
`Win_Client.exe` is a self-contained, pre-compiled executable that queries the Windows Application Event Log, filters candidate events at the source, encrypts the payload using OpenPGP, and streams records over an authenticated TCP socket to the central LOGAR hub.
|
`Win_Client.exe` is a self-contained executable that queries the Windows Application Event Log, filters candidate events at the source, auto-enrolls with the central LOGAR hub to receive signed mTLS certificates, and streams records over mutual TLS 1.3 (**mTLS**) socket connection.
|
||||||
|
|
||||||
### Key Capabilities
|
### Key Capabilities
|
||||||
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone native Windows executable (`Win_Client.exe`) or full installer (`LOGAR-Client-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on the host.
|
||||||
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit success/failure events and debug noise, skipping events older than 24 hours.
|
- **Mutual TLS 1.3 (mTLS) Ingestion**: Streams directly over hardware-authenticated TLS 1.3 sockets with hardware/machine-bound client certificates.
|
||||||
|
- **Automated Client Enrollment**: On first run with an `enrollment_secret`, the client automatically calls `POST /api/client/enroll` on the hub, saves its certificates into `certs/`, and establishes secure mTLS streaming.
|
||||||
|
- **Proactive Expiry Check & Reactive Self-Healing**: Before each run, the client evaluates `client.crt` validity. If within 30 days of expiry, it automatically contacts the hub to renew certificates. If the server Root CA rotates or a TLS handshake error occurs, the client catch-heals by re-enrolling immediately and re-establishing connection without human intervention.
|
||||||
|
- **Source-Level Filtering**: Retains events spanning `INFO`, `WARNING`, and `ERROR`. Strips audit events and debug noise, skipping events older than 24 hours.
|
||||||
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
- **State Tracking & Deduplication**: Maintains persistent client state in `client_state.json` (tracking event record numbers and timestamp signatures) so every log record is forwarded exactly once without duplicates.
|
||||||
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
- **Fail-Safe State Commit**: State is committed only when the server returns a verified `success` response. In the event of a network outage, state remains unchanged and unsent events are retried automatically on the next run.
|
||||||
- **End-to-End Encryption**: Encrypts payloads using the server's OpenPGP public key before transmission.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Generating & Deploying the Configuration File
|
## 1. Automated Installation via Inno Setup (Recommended)
|
||||||
|
|
||||||
|
Run the self-contained installer built from `compilation/installer_client.iss`:
|
||||||
|
```powershell
|
||||||
|
.\LOGAR-Client-Setup.exe
|
||||||
|
```
|
||||||
|
This installer:
|
||||||
|
1. Installs `Win_Client.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR\`.
|
||||||
|
2. Sets up directory permissions in `C:\ProgramData\LOGAR\`.
|
||||||
|
3. Registers and starts the `LOGAR_Client` Windows service automatically via NSSM.
|
||||||
|
4. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR\client.log` and `client_err.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generating & Deploying the Configuration File
|
||||||
|
|
||||||
### Step 1: Generate `client_config.json` on the Server
|
### Step 1: Generate `client_config.json` on the Server
|
||||||
Run the following command on your central LOGAR server to export a client bundle tailored for your environment:
|
Run the following command on your central LOGAR server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
python src/Server.py --create-client-config --server-host <SERVER_IP_OR_DNS> --server-port 9443 --client-out client_config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
- Replace `<SERVER_IP_OR_DNS>` with the reachable IP address or FQDN of your central LOGAR server hub.
|
||||||
- Default TCP port is `9443`.
|
- Default mTLS socket port is `9443`; Hermes REST API port is `8443`.
|
||||||
|
|
||||||
### Step 2: Configuration Structure
|
### Step 2: Configuration Structure
|
||||||
The generated `client_config.json` contains:
|
The generated `client_config.json` contains:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server_host": "192.168.1.100",
|
"server_host": "192.168.1.100",
|
||||||
"server_port": 9443,
|
"server_port": 9443,
|
||||||
|
"hermes_host": "192.168.1.100",
|
||||||
|
"hermes_port": 8443,
|
||||||
|
"enrollment_secret": "a1b2c3d4e5f6...",
|
||||||
|
"cert_dir": "certs",
|
||||||
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
"server_fingerprint": "375388960531264EA0648EC0D2C4E4ABC6F22AC2",
|
||||||
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
"server_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...",
|
||||||
"auth_token": "a1b2c3d4e5f6..."
|
"auth_token": "a1b2c3d4e5f6..."
|
||||||
@@ -42,11 +59,10 @@ The generated `client_config.json` contains:
|
|||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> A reference example is provided in `client_config.sample.json`. The configuration file contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
> The configuration contains **no host-specific names or site names** to ensure client anonymity and easy redistribution.
|
||||||
|
|
||||||
### Step 3: Copy to Edge Node
|
### Step 3: Copy to Edge Node
|
||||||
Place `Win_Client.exe` and `client_config.json` in the target directory (recommended: `C:\LOGAR\`):
|
Place `client_config.json` next to `Win_Client.exe` (e.g. `C:\Program Files\LOGAR\` or `C:\LOGAR\`):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
New-Item -ItemType Directory -Path "C:\LOGAR" -Force
|
||||||
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
||||||
@@ -54,14 +70,14 @@ Copy-Item "Win_Client.exe", "client_config.json" -Destination "C:\LOGAR\"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Running Manually
|
## 3. Running Manually
|
||||||
|
|
||||||
Test the forwarder interactively from PowerShell or Command Prompt:
|
Test the forwarder interactively from PowerShell or Command Prompt:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd C:\LOGAR
|
cd C:\LOGAR
|
||||||
.\Win_Client.exe --hours 24
|
.\Win_Client.exe --hours 24
|
||||||
```
|
```
|
||||||
|
On first run, the client contacts `http://<hermes_host>:<hermes_port>/api/client/enroll`, downloads `ca.crt`, `client.crt`, and `client.key` into `certs/`, and streams logs over mTLS.
|
||||||
|
|
||||||
### Command-Line Arguments
|
### Command-Line Arguments
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
@@ -73,22 +89,24 @@ cd C:\LOGAR
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Installing as a Background Service / Scheduled Task
|
## 4. Manual Service Installation (NSSM or Scheduled Task)
|
||||||
|
|
||||||
Edge forwarders run as episodic background processes (run, forward unsent candidate records, commit state, and terminate). On Windows, this is natively managed via Windows Task Scheduler running as a background service under `SYSTEM`.
|
|
||||||
|
|
||||||
### Method A: Windows Scheduled Task via PowerShell (Recommended)
|
|
||||||
Open an **Elevated PowerShell (Run as Administrator)** window and execute:
|
|
||||||
|
|
||||||
|
### Method A: Windows Service via Bundled NSSM
|
||||||
|
```powershell
|
||||||
|
# From the compilation directory or with bundled nssm.exe:
|
||||||
|
.\nssm.exe install LOGAR_Client "C:\LOGAR\Win_Client.exe" "--hours 24"
|
||||||
|
.\nssm.exe set LOGAR_Client AppDirectory "C:\LOGAR"
|
||||||
|
.\nssm.exe set LOGAR_Client AppStdout "C:\ProgramData\LOGAR\client.log"
|
||||||
|
.\nssm.exe set LOGAR_Client AppStderr "C:\ProgramData\LOGAR\client_err.log"
|
||||||
|
.\nssm.exe start LOGAR_Client
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method B: Windows Scheduled Task via PowerShell
|
||||||
```powershell
|
```powershell
|
||||||
# Define action and periodic trigger (every 3 hours indefinitely)
|
|
||||||
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
$Action = New-ScheduledTaskAction -Execute "C:\LOGAR\Win_Client.exe" -Argument "--hours 24" -WorkingDirectory "C:\LOGAR"
|
||||||
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 3)
|
||||||
|
|
||||||
# Configure task settings (wake on sleep, start when ready, run hidden)
|
|
||||||
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 15)
|
||||||
|
|
||||||
# Register task running under the local SYSTEM account with highest privileges
|
|
||||||
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
||||||
-Action $Action `
|
-Action $Action `
|
||||||
-Trigger $Trigger `
|
-Trigger $Trigger `
|
||||||
@@ -97,31 +115,18 @@ Register-ScheduledTask -TaskName "LOGAR_Forwarder" `
|
|||||||
-RunLevel Highest `
|
-RunLevel Highest `
|
||||||
-Description "LOGAR Windows Edge Log Forwarder Service"
|
-Description "LOGAR Windows Edge Log Forwarder Service"
|
||||||
|
|
||||||
# Verify task creation and trigger immediate execution
|
|
||||||
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
Start-ScheduledTask -TaskName "LOGAR_Forwarder"
|
||||||
Get-ScheduledTask -TaskName "LOGAR_Forwarder"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method B: Continuous Windows Service via NSSM
|
|
||||||
If your organizational policy requires a formal Windows Service listed under `services.msc`:
|
|
||||||
|
|
||||||
1. Download [NSSM (Non-Sucking Service Manager)](https://nssm.cc/).
|
|
||||||
2. Install the service using NSSM:
|
|
||||||
```cmd
|
|
||||||
nssm.exe install LOGAR_Forwarder "C:\LOGAR\Win_Client.exe" "--hours 24"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppDirectory "C:\LOGAR"
|
|
||||||
nssm.exe set LOGAR_Forwarder AppRestartDelay 10800000
|
|
||||||
nssm.exe start LOGAR_Forwarder
|
|
||||||
```
|
|
||||||
*(Note: `AppRestartDelay 10800000` pauses 3 hours between execution cycles).*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Uninstallation & Removal
|
## 5. Uninstallation
|
||||||
|
|
||||||
To remove the scheduled task:
|
If installed via the Inno Setup installer, use **Windows Add/Remove Programs** or run `unins000.exe` in `C:\Program Files\LOGAR\`.
|
||||||
|
|
||||||
|
If installed manually via NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
Unregister-ScheduledTask -TaskName "LOGAR_Forwarder" -Confirm:$false
|
.\nssm.exe stop LOGAR_Client
|
||||||
|
.\nssm.exe remove LOGAR_Client confirm
|
||||||
Remove-Item -Recurse -Force "C:\LOGAR"
|
Remove-Item -Recurse -Force "C:\LOGAR"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# LOGAR Windows Server Hub
|
||||||
|
|
||||||
|
Standalone compiled executable and installer 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, dynamic PKI certificate authority, and reporting hub of the LOGAR infrastructure.
|
||||||
|
|
||||||
|
### Key Architecture & Capabilities
|
||||||
|
- **Pre-compiled & Dependency-Free**: Ships as a standalone Windows executable (`Server.exe`) or full installer (`LOGAR-Server-Setup.exe`). No Python installation, pip packages, or GnuPG binaries are required on Windows Server.
|
||||||
|
- **Mutual TLS 1.3 (mTLS) Ingestion (Port 9443)**: Enforces mutual TLS 1.3 authentication for all incoming edge connections. Validates client certificates against an internal Root CA and verifies active licensing in SQLite.
|
||||||
|
- **Dynamic PKI & License Accounting**: Built-in Root CA generates server TLS certificates with SANs and dynamically signs client certificates via `POST /api/client/enroll` while enforcing seat limits (`max_seats`).
|
||||||
|
- **In-Flight Certificate Watchdog & Dynamic Reloading**: Continuously monitors Root CA (`ca.crt`) and Server TLS certificate (`server.crt`) validity in the background (every 12 hours). When nearing expiration (< 30 days), certificates are automatically regenerated with timestamped backups, and active `ssl.SSLContext` structures are reloaded dynamically without dropping socket connections or restarting the Windows service.
|
||||||
|
- **Warning Persistence & Immediate Error Routing**: High-severity `ERROR`, `CRITICAL`, and `FATAL` events are promoted to `VERIFIED` immediately on their first occurrence. Operational `WARNING` and `INFO` events require persistence across at least 4 distinct transmission cycles within a rolling 12-hour evaluation window.
|
||||||
|
- **Embedded Hermes Reporting & Management API (Port 8443)**: Integrated REST API exposing `/api/hermes/report`, `/api/clients`, and `/api/client/enroll`.
|
||||||
|
- **State Database**: Stores issue lifecycle records, client telemetry, and licensing quotas in a local SQLite database (`logar_state.db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Automated Installation via Inno Setup (Recommended)
|
||||||
|
|
||||||
|
Run the self-contained installer built from `compilation/installer_server.iss`:
|
||||||
|
```powershell
|
||||||
|
.\LOGAR-Server-Setup.exe
|
||||||
|
```
|
||||||
|
This installer:
|
||||||
|
1. Installs `Server.exe` and bundled `nssm.exe` to `C:\Program Files\LOGAR-Server\`.
|
||||||
|
2. Registers and starts the `LOGAR_Server` Windows service automatically via NSSM.
|
||||||
|
3. Redirects stdout and stderr logs to `C:\ProgramData\LOGAR-Server\server.log` and `server_err.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initializing & Generating Server Configuration
|
||||||
|
|
||||||
|
### Step 1: Automatic First-Run Generation
|
||||||
|
When launched without an existing `server_config.json`, `Server.exe` automatically initializes:
|
||||||
|
1. An internal Root CA (`certs/ca.crt` and `certs/ca.key`).
|
||||||
|
2. A server TLS certificate (`certs/server.crt` and `certs/server.key`) with SANs.
|
||||||
|
3. An OpenPGP RSA-2048 keypair (`private_key` and `public_key`).
|
||||||
|
4. Cryptographically random authentication tokens and enrollment secrets.
|
||||||
|
5. Default network socket coordinates (mTLS 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
|
||||||
|
============================================================
|
||||||
|
LOGAR Server Hub: LOGAR-Cloud-Hub
|
||||||
|
Transport Security: mTLS (TLS 1.3)
|
||||||
|
License Quota: 10 Active Seats
|
||||||
|
Server Encryption Fingerprint: 375388960531264EA0648EC0D2C4E4ABC6F22AC2
|
||||||
|
Evaluation Window: 12 hours | 4-Run Rule: Warnings | Immediate Pass: Errors
|
||||||
|
============================================================
|
||||||
|
[*] LOGAR mTLS TLSv1.3 Socket Server listening on 0.0.0.0:9443
|
||||||
|
[*] Hermes Reporting API available at http://0.0.0.0:8443/api/hermes/report
|
||||||
|
[*] Client Enrollment API available at http://0.0.0.0:8443/api/client/enroll
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configuration Fields Reference
|
||||||
|
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...",
|
||||||
|
"enrollment_secret": "e1f2a3b4c5d6...",
|
||||||
|
"max_seats": 10,
|
||||||
|
"cert_dir": "certs",
|
||||||
|
"tls_enabled": true,
|
||||||
|
"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..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Exporting Client Configurations
|
||||||
|
|
||||||
|
Generate a client configuration bundle to deploy onto Windows or Linux forwarders:
|
||||||
|
```powershell
|
||||||
|
.\Server.exe --create-client-config --server-host 192.168.1.100 --server-port 9443 --client-out client_config.json
|
||||||
|
```
|
||||||
|
The output file contains the server coordinates, enrollment secret, and fingerprint, ready for client deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Manual Windows Service Setup (via NSSM)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\nssm.exe install LOGAR_Server "C:\LOGAR-Server\Server.exe"
|
||||||
|
.\nssm.exe set LOGAR_Server AppDirectory "C:\LOGAR-Server"
|
||||||
|
.\nssm.exe set LOGAR_Server AppStdout "C:\ProgramData\LOGAR-Server\server.log"
|
||||||
|
.\nssm.exe set LOGAR_Server AppStderr "C:\ProgramData\LOGAR-Server\server_err.log"
|
||||||
|
.\nssm.exe start LOGAR_Server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Uninstallation
|
||||||
|
|
||||||
|
If installed via the Inno Setup installer, use **Windows Add/Remove Programs**.
|
||||||
|
|
||||||
|
If installed manually via NSSM:
|
||||||
|
```powershell
|
||||||
|
.\nssm.exe stop LOGAR_Server
|
||||||
|
.\nssm.exe remove LOGAR_Server confirm
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
+165
-18
@@ -2,9 +2,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
@@ -18,6 +20,97 @@ CONFIG_FILE_NAME = "client_config.json"
|
|||||||
STATE_FILE_NAME = "client_state.json"
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||||
|
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||||
|
if not os.path.exists(cert_path):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as f:
|
||||||
|
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||||
|
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||||
|
if expiry is None:
|
||||||
|
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return expiry <= (now + timedelta(days=threshold_days))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_client_if_needed(
|
||||||
|
hub_url: str,
|
||||||
|
enrollment_secret: str,
|
||||||
|
cert_dir: str,
|
||||||
|
client_id: str,
|
||||||
|
hostname: str,
|
||||||
|
os_type: str = "linux",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
):
|
||||||
|
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||||
|
return True
|
||||||
|
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||||
|
|
||||||
|
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||||
|
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||||
|
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||||
|
payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os": os_type,
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
enroll_endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_key"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||||
|
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||||
|
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||||
|
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.check_hostname = False
|
||||||
|
|
||||||
|
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||||
|
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||||
|
|
||||||
|
|
||||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
if custom_state_path:
|
if custom_state_path:
|
||||||
return custom_state_path
|
return custom_state_path
|
||||||
@@ -227,39 +320,96 @@ def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list
|
|||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
Streams logs to the LOGAR hub.
|
||||||
over an authenticated TCP socket.
|
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||||
|
or falls back to OpenPGP encrypted envelope over TCP.
|
||||||
"""
|
"""
|
||||||
server_host = config["server_host"]
|
server_host = config["server_host"]
|
||||||
server_port = int(config["server_port"])
|
server_port = int(config["server_port"])
|
||||||
auth_token = config["auth_token"]
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
pub_key_armored = config["server_public_key"]
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
machine_id = get_machine_identifier()
|
||||||
|
|
||||||
# Load and verify server public key
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux", force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
if expected_fp and actual_fp != expected_fp:
|
if expected_fp and actual_fp != expected_fp:
|
||||||
raise ValueError(
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare batch
|
|
||||||
payload = {
|
payload = {
|
||||||
"server": machine_id,
|
"server": machine_id,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload)
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
encrypted_armored = str(encrypted_msg)
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -267,17 +417,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
|||||||
}
|
}
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
sock.settimeout(15.0)
|
sock.settimeout(15.0)
|
||||||
sock.connect((server_host, server_port))
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
sock.sendall(frame)
|
sock.sendall(frame)
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
resp_len_bytes = sock.recv(4)
|
||||||
if not resp_len_bytes:
|
if not resp_len_bytes:
|
||||||
raise ConnectionError("Server closed connection without response.")
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|||||||
+389
-73
@@ -23,20 +23,34 @@ from pgpy.constants import (
|
|||||||
SymmetricKeyAlgorithm,
|
SymmetricKeyAlgorithm,
|
||||||
CompressionAlgorithm
|
CompressionAlgorithm
|
||||||
)
|
)
|
||||||
|
import ssl
|
||||||
|
from pydantic import BaseModel
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as enrollment
|
||||||
|
except ImportError:
|
||||||
|
import server_enrollment as enrollment
|
||||||
|
|
||||||
CONFIG_FILE_NAME = "server_config.json"
|
CONFIG_FILE_NAME = "server_config.json"
|
||||||
DEFAULT_DB_FILE = "logar_state.db"
|
DEFAULT_DB_FILE = "logar_state.db"
|
||||||
EVALUATION_WINDOW_HOURS = 12
|
EVALUATION_WINDOW_HOURS = 12
|
||||||
RUN_THRESHOLD = 4
|
RUN_THRESHOLD = 4
|
||||||
|
|
||||||
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="2.0.0")
|
app = FastAPI(title="LOGAR Cloud Ingestion & Hermes Hub", version="2.0.1")
|
||||||
|
|
||||||
# Global context holding server state
|
# Global context holding server state
|
||||||
SERVER_STATE: Dict[str, Any] = {}
|
SERVER_STATE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ClientEnrollRequest(BaseModel):
|
||||||
|
client_id: str
|
||||||
|
hostname: str
|
||||||
|
os: str
|
||||||
|
enrollment_secret: str
|
||||||
|
|
||||||
|
|
||||||
def generate_server_keypair(server_name: str):
|
def generate_server_keypair(server_name: str):
|
||||||
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
"""Generates an OpenPGP RSA 2048 key with encryption capability."""
|
||||||
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
key = pgpy.PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 2048)
|
||||||
@@ -60,12 +74,21 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
|||||||
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
|
print(f"[*] Loading server configuration from: {os.path.abspath(config_path)}")
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
|
if "enrollment_secret" not in config:
|
||||||
|
config["enrollment_secret"] = secrets.token_hex(24)
|
||||||
|
if "max_seats" not in config:
|
||||||
|
config["max_seats"] = 10
|
||||||
|
if "cert_dir" not in config:
|
||||||
|
config["cert_dir"] = "certs"
|
||||||
|
if "tls_enabled" not in config:
|
||||||
|
config["tls_enabled"] = True
|
||||||
return config
|
return config
|
||||||
|
|
||||||
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
print(f"[!] Config '{config_path}' not found. Initializing first-run configuration...")
|
||||||
server_name = "LOGAR-Cloud-Hub"
|
server_name = "LOGAR-Cloud-Hub"
|
||||||
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
private_key, public_key, fingerprint = generate_server_keypair(server_name)
|
||||||
auth_token = secrets.token_hex(24)
|
auth_token = secrets.token_hex(24)
|
||||||
|
enrollment_secret = secrets.token_hex(24)
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"server_name": server_name,
|
"server_name": server_name,
|
||||||
@@ -74,6 +97,10 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]:
|
|||||||
"hermes_host": "0.0.0.0",
|
"hermes_host": "0.0.0.0",
|
||||||
"hermes_port": 8443,
|
"hermes_port": 8443,
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
|
"enrollment_secret": enrollment_secret,
|
||||||
|
"max_seats": 10,
|
||||||
|
"cert_dir": "certs",
|
||||||
|
"tls_enabled": True,
|
||||||
"db_path": DEFAULT_DB_FILE,
|
"db_path": DEFAULT_DB_FILE,
|
||||||
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
"evaluation_window_hours": EVALUATION_WINDOW_HOURS,
|
||||||
"min_persistence_runs": RUN_THRESHOLD,
|
"min_persistence_runs": RUN_THRESHOLD,
|
||||||
@@ -95,7 +122,9 @@ def create_client_config(
|
|||||||
server_host: str,
|
server_host: str,
|
||||||
server_port: int,
|
server_port: int,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
config_path: str = CONFIG_FILE_NAME
|
config_path: str = CONFIG_FILE_NAME,
|
||||||
|
hermes_host: Optional[str] = None,
|
||||||
|
hermes_port: Optional[int] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
|
"""Creates a client configuration file containing the server address, auth token, and encryption-only key/fingerprint."""
|
||||||
server_conf = load_or_init_config(config_path)
|
server_conf = load_or_init_config(config_path)
|
||||||
@@ -103,6 +132,10 @@ def create_client_config(
|
|||||||
client_conf = {
|
client_conf = {
|
||||||
"server_host": server_host,
|
"server_host": server_host,
|
||||||
"server_port": server_port,
|
"server_port": server_port,
|
||||||
|
"hermes_host": hermes_host or server_conf.get("hermes_host", "127.0.0.1"),
|
||||||
|
"hermes_port": hermes_port or server_conf.get("hermes_port", 8443),
|
||||||
|
"enrollment_secret": server_conf.get("enrollment_secret"),
|
||||||
|
"cert_dir": "certs",
|
||||||
"server_fingerprint": server_conf["server_fingerprint"],
|
"server_fingerprint": server_conf["server_fingerprint"],
|
||||||
"server_public_key": server_conf["public_key"],
|
"server_public_key": server_conf["public_key"],
|
||||||
"auth_token": server_conf["auth_token"]
|
"auth_token": server_conf["auth_token"]
|
||||||
@@ -121,8 +154,8 @@ def create_client_config(
|
|||||||
return client_conf
|
return client_conf
|
||||||
|
|
||||||
|
|
||||||
def init_db(db_path: str):
|
def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: int = 10):
|
||||||
"""Initializes the SQLite schema for multi-run temporal tracking."""
|
"""Initializes the SQLite schema for multi-run temporal tracking, client tracking, and license quota."""
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS active_issues (
|
CREATE TABLE IF NOT EXISTS active_issues (
|
||||||
@@ -149,6 +182,29 @@ def init_db(db_path: str):
|
|||||||
log_count INTEGER
|
log_count INTEGER
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS license_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
max_seats INTEGER NOT NULL DEFAULT 10,
|
||||||
|
enrollment_secret TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
os_type TEXT NOT NULL,
|
||||||
|
cert_fingerprint TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
if enrollment_secret:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
""", (max_seats, enrollment_secret))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -183,6 +239,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
|||||||
if severity in ["DEBUG", "TRACE"]:
|
if severity in ["DEBUG", "TRACE"]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Errors are always passed immediately; the 4-run rule only concerns warnings
|
||||||
|
is_error = severity in ["ERROR", "CRITICAL", "FATAL"]
|
||||||
|
|
||||||
signature = log.get("signature", "unknown")
|
signature = log.get("signature", "unknown")
|
||||||
server = log.get("server", client_server)
|
server = log.get("server", client_server)
|
||||||
message = log.get("message", "")
|
message = log.get("message", "")
|
||||||
@@ -207,7 +266,7 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
|||||||
# Window elapsed: reset to new cycle
|
# Window elapsed: reset to new cycle
|
||||||
new_runs = 1
|
new_runs = 1
|
||||||
new_first_seen = now_iso
|
new_first_seen = now_iso
|
||||||
new_status = "TRANSIENT"
|
new_status = "VERIFIED" if is_error else "TRANSIENT"
|
||||||
else:
|
else:
|
||||||
# Same run guard: only increment count once per distinct run batch
|
# Same run guard: only increment count once per distinct run batch
|
||||||
if last_run_id != run_id:
|
if last_run_id != run_id:
|
||||||
@@ -215,8 +274,8 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
|||||||
else:
|
else:
|
||||||
new_runs = run_count
|
new_runs = run_count
|
||||||
new_first_seen = first_seen_str
|
new_first_seen = first_seen_str
|
||||||
# 4-run rule enforcement
|
# 4-run rule applies to warnings; errors are always passed immediately as VERIFIED
|
||||||
new_status = "VERIFIED" if new_runs >= min_runs else "TRANSIENT"
|
new_status = "VERIFIED" if (is_error or new_runs >= min_runs) else "TRANSIENT"
|
||||||
|
|
||||||
if new_status == "VERIFIED" and current_status != "VERIFIED":
|
if new_status == "VERIFIED" and current_status != "VERIFIED":
|
||||||
promoted_to_verified += 1
|
promoted_to_verified += 1
|
||||||
@@ -227,7 +286,9 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
|
|||||||
WHERE fingerprint = ?
|
WHERE fingerprint = ?
|
||||||
""", (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp))
|
""", (new_runs, now_iso, new_first_seen, new_status, run_id, message, severity, fp))
|
||||||
else:
|
else:
|
||||||
initial_status = "VERIFIED" if 1 >= min_runs else "TRANSIENT"
|
initial_status = "VERIFIED" if (is_error or 1 >= min_runs) else "TRANSIENT"
|
||||||
|
if initial_status == "VERIFIED":
|
||||||
|
promoted_to_verified += 1
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO active_issues
|
INSERT INTO active_issues
|
||||||
(fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id)
|
(fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id)
|
||||||
@@ -247,15 +308,126 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"):
|
||||||
|
"""
|
||||||
|
Dynamically reloads server certificate chain and Root CA in an active SSLContext.
|
||||||
|
Allows in-flight TLS certificate rotation without dropping the listening socket.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
|
||||||
|
ssl_ctx.load_verify_locations(cafile=ca_file)
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_rotate_server_certs(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
hostnames: Optional[List[str]] = None,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if Root CA or server TLS certificate are expiring within threshold_days.
|
||||||
|
If so, regenerates them, dynamically reloads the active SSLContext in-place,
|
||||||
|
and updates the server's in-memory CA reference so future enrollments use the new CA.
|
||||||
|
Returns True if renewed/reloaded, False otherwise.
|
||||||
|
"""
|
||||||
|
ca_renewed, srv_renewed = enrollment.check_and_renew_hub_pki(
|
||||||
|
cert_dir=cert_dir,
|
||||||
|
hostnames=hostnames,
|
||||||
|
threshold_days=threshold_days
|
||||||
|
)
|
||||||
|
if ca_renewed or srv_renewed:
|
||||||
|
print(f"[!] Server Hub PKI certificates renewed (CA renewed: {ca_renewed}, Server cert renewed: {srv_renewed}).")
|
||||||
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
||||||
|
SERVER_STATE["ca_cert"] = ca_cert
|
||||||
|
SERVER_STATE["ca_key"] = ca_key
|
||||||
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||||
|
|
||||||
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
||||||
|
if ssl_ctx is not None:
|
||||||
|
try:
|
||||||
|
reload_mtls_context(ssl_ctx, cert_dir=cert_dir)
|
||||||
|
print("[+] In-flight mTLS SSLContext successfully reloaded with updated certificates.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Failed to reload in-flight SSLContext: {e}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def cert_validity_watchdog(interval_seconds: int = 43200, threshold_days: int = 30):
|
||||||
|
"""
|
||||||
|
Periodically checks the validity of Hub Root CA and Server TLS certificates (default every 12 hours).
|
||||||
|
Triggers in-flight renewal and dynamic context reloading if expiration is within threshold_days.
|
||||||
|
"""
|
||||||
|
config = SERVER_STATE.get("config", {})
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
hostnames = [config.get("tcp_host", "0.0.0.0"), "127.0.0.1", "localhost"]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval_seconds)
|
||||||
|
check_and_rotate_server_certs(cert_dir=cert_dir, hostnames=hostnames, threshold_days=threshold_days)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Exception in cert_validity_watchdog: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
"""
|
"""
|
||||||
Authenticated TCP socket handler.
|
mTLS TCP socket handler.
|
||||||
Protocol:
|
Extracts client CN (client_id) from the TLS handshake,
|
||||||
- 4-byte big-endian prefix: payload length
|
validates active license status in SQLite, updates last_seen,
|
||||||
- Payload: JSON with auth_token and encrypted_payload (OpenPGP ASCII armored)
|
reads 4-byte big-endian length-prefixed JSON payload,
|
||||||
- Response: 4-byte length + JSON confirmation
|
and ingests candidate logs into the temporal evaluation engine.
|
||||||
"""
|
"""
|
||||||
addr = writer.get_extra_info("peername")
|
client_id = None
|
||||||
|
ssl_obj = writer.get_extra_info("ssl_object")
|
||||||
|
if ssl_obj:
|
||||||
|
peercert = ssl_obj.getpeercert()
|
||||||
|
if peercert and "subject" in peercert:
|
||||||
|
for rdn in peercert["subject"]:
|
||||||
|
for key, val in rdn:
|
||||||
|
if key == "commonName":
|
||||||
|
client_id = val
|
||||||
|
break
|
||||||
|
|
||||||
|
# If mTLS is enforced, verify client in accounting database
|
||||||
|
if SERVER_STATE.get("tls_enabled", False):
|
||||||
|
if not client_id:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return
|
||||||
|
|
||||||
|
db_path = SERVER_STATE["config"]["db_path"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT status FROM clients WHERE client_id = ?", (client_id,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row or row[0] != "active":
|
||||||
|
conn.close()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return
|
||||||
|
c.execute("UPDATE clients SET last_seen = CURRENT_TIMESTAMP WHERE client_id = ?", (client_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Read 4-byte length prefix
|
# Read 4-byte length prefix
|
||||||
length_bytes = await reader.readexactly(4)
|
length_bytes = await reader.readexactly(4)
|
||||||
@@ -264,26 +436,20 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
raise ValueError(f"Invalid frame size: {length}")
|
raise ValueError(f"Invalid frame size: {length}")
|
||||||
|
|
||||||
payload_bytes = await reader.readexactly(length)
|
payload_bytes = await reader.readexactly(length)
|
||||||
envelope = json.loads(payload_bytes.decode("utf-8"))
|
raw_payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
# Authenticate socket client
|
# Support both direct JSON payload over mTLS and legacy OpenPGP envelope
|
||||||
expected_token = SERVER_STATE["config"]["auth_token"]
|
if "encrypted_payload" in raw_payload and SERVER_STATE.get("private_key_obj"):
|
||||||
provided_token = envelope.get("auth_token")
|
pgp_msg = pgpy.PGPMessage.from_blob(raw_payload["encrypted_payload"])
|
||||||
if not secrets.compare_digest(str(provided_token), str(expected_token)):
|
priv_key = SERVER_STATE["private_key_obj"]
|
||||||
err_msg = json.dumps({"status": "error", "message": "Authentication failed"}).encode("utf-8")
|
decrypted_obj = priv_key.decrypt(pgp_msg)
|
||||||
writer.write(struct.pack(">I", len(err_msg)) + err_msg)
|
log_payload = json.loads(decrypted_obj.message)
|
||||||
await writer.drain()
|
else:
|
||||||
writer.close()
|
log_payload = raw_payload
|
||||||
await writer.wait_closed()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Decrypt payload using server's OpenPGP private key
|
# Attach authenticated client_id if not present
|
||||||
encrypted_armored = envelope.get("encrypted_payload", "")
|
if client_id and "server" not in log_payload:
|
||||||
pgp_msg = pgpy.PGPMessage.from_blob(encrypted_armored)
|
log_payload["server"] = client_id
|
||||||
priv_key = SERVER_STATE["private_key_obj"]
|
|
||||||
decrypted_obj = priv_key.decrypt(pgp_msg)
|
|
||||||
decrypted_json_str = decrypted_obj.message
|
|
||||||
log_payload = json.loads(decrypted_json_str)
|
|
||||||
|
|
||||||
# Ingest and apply 12h window / 4-run rule
|
# Ingest and apply 12h window / 4-run rule
|
||||||
res = process_ingested_logs(
|
res = process_ingested_logs(
|
||||||
@@ -312,47 +478,158 @@ async def handle_socket_client(reader: asyncio.StreamReader, writer: asyncio.Str
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/hermes/report")
|
@app.post("/api/client/enroll")
|
||||||
def get_hermes_report():
|
def enroll_client(req: ClientEnrollRequest):
|
||||||
"""
|
"""
|
||||||
Agentic Integration endpoint: Consumed by Hermes to fetch anomalies that have persisted
|
Enrolls an edge client by validating the enrollment secret,
|
||||||
across the 12-hour evaluation window and satisfied the 4-run rule.
|
checking license seat limits, issuing a signed client certificate + key,
|
||||||
|
and recording the client in the SQLite accounting database.
|
||||||
"""
|
"""
|
||||||
db_path = SERVER_STATE["config"]["db_path"]
|
db_path = SERVER_STATE["config"]["db_path"]
|
||||||
window_hours = SERVER_STATE["config"]["evaluation_window_hours"]
|
conn = sqlite3.connect(db_path)
|
||||||
min_runs = SERVER_STATE["config"]["min_persistence_runs"]
|
c = conn.cursor()
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
# 1. Validate enrollment secret against license_config
|
||||||
|
c.execute("SELECT enrollment_secret, max_seats FROM license_config WHERE id = 1")
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=500, detail="License configuration not initialized")
|
||||||
|
|
||||||
|
expected_secret, max_seats = row
|
||||||
|
if not secrets.compare_digest(str(req.enrollment_secret), str(expected_secret)):
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid enrollment secret")
|
||||||
|
|
||||||
|
ca_cert = SERVER_STATE.get("ca_cert")
|
||||||
|
ca_key = SERVER_STATE.get("ca_key")
|
||||||
|
ca_cert_pem = SERVER_STATE.get("ca_cert_pem")
|
||||||
|
|
||||||
|
if not ca_cert or not ca_key:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=500, detail="Root CA not loaded on server")
|
||||||
|
|
||||||
|
# 2. Check if client_id already registered
|
||||||
|
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
|
||||||
|
client_row = c.fetchone()
|
||||||
|
if client_row:
|
||||||
|
if client_row[0] == "revoked":
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=403, detail="Client certificate has been revoked")
|
||||||
|
|
||||||
|
# Re-issue for existing active client
|
||||||
|
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||||
|
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||||
|
c.execute("""
|
||||||
|
UPDATE clients
|
||||||
|
SET hostname = ?, os_type = ?, cert_fingerprint = ?, last_seen = CURRENT_TIMESTAMP
|
||||||
|
WHERE client_id = ?
|
||||||
|
""", (req.hostname, req.os, fp, req.client_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[+] Re-enrolled active client: {req.client_id} ({req.hostname})")
|
||||||
|
return {
|
||||||
|
"ca_cert": ca_cert_pem,
|
||||||
|
"client_cert": client_cert_pem,
|
||||||
|
"client_key": client_key_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. New client: check seat limits
|
||||||
|
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'")
|
||||||
|
active_count = c.fetchone()[0]
|
||||||
|
if active_count >= max_seats:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=403, detail="License seat limit reached")
|
||||||
|
|
||||||
|
# 4. Issue signed cert + key
|
||||||
|
client_cert_pem, client_key_pem = enrollment.issue_client_cert(req.client_id, ca_cert, ca_key)
|
||||||
|
fp = enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||||
|
c.execute("""
|
||||||
|
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status)
|
||||||
|
VALUES (?, ?, ?, ?, 'active')
|
||||||
|
""", (req.client_id, req.hostname, req.os, fp))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[+] Successfully enrolled new client: {req.client_id} ({req.hostname}) [Seats: {active_count + 1}/{max_seats}]")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ca_cert": ca_cert_pem,
|
||||||
|
"client_cert": client_cert_pem,
|
||||||
|
"client_key": client_key_pem
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/clients")
|
||||||
|
def list_clients():
|
||||||
|
"""Returns all registered clients and license seat usage."""
|
||||||
|
db_path = SERVER_STATE["config"]["db_path"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT max_seats FROM license_config WHERE id = 1")
|
||||||
|
lic_row = c.fetchone()
|
||||||
|
max_seats = lic_row[0] if lic_row else 10
|
||||||
|
|
||||||
|
c.execute("SELECT client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen FROM clients")
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
clients = [
|
||||||
|
{
|
||||||
|
"client_id": r[0],
|
||||||
|
"hostname": r[1],
|
||||||
|
"os_type": r[2],
|
||||||
|
"cert_fingerprint": r[3],
|
||||||
|
"status": r[4],
|
||||||
|
"first_seen": r[5],
|
||||||
|
"last_seen": r[6]
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
active_count = sum(1 for cl in clients if cl["status"] == "active")
|
||||||
|
return {
|
||||||
|
"active_seats": active_count,
|
||||||
|
"max_seats": max_seats,
|
||||||
|
"clients": clients
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/hermes/report")
|
||||||
|
def get_verified_anomalies_for_hermes():
|
||||||
|
"""
|
||||||
|
Ingestion endpoint for Hermes agentic workflows.
|
||||||
|
Returns only verified anomalies that have satisfied the 4-run persistence rule
|
||||||
|
within the active 12-hour evaluation window. Transient blips (< 4 runs) are excluded.
|
||||||
|
"""
|
||||||
|
db_path = SERVER_STATE["config"]["db_path"]
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status
|
||||||
FROM active_issues
|
FROM active_issues
|
||||||
WHERE status = 'VERIFIED' AND run_count >= ?
|
WHERE status = 'VERIFIED'
|
||||||
""", (min_runs,))
|
ORDER BY last_seen DESC
|
||||||
|
""")
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
report = []
|
report = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
last_seen_dt = datetime.fromisoformat(r[8])
|
report.append({
|
||||||
# Only return anomalies active within the evaluation window
|
"fingerprint": r[0],
|
||||||
if (now - last_seen_dt) <= timedelta(hours=window_hours):
|
"site": r[1],
|
||||||
report.append({
|
"server": r[2],
|
||||||
"fingerprint": r[0],
|
"signature": r[3],
|
||||||
"site": r[1],
|
"severity": r[4],
|
||||||
"server": r[2],
|
"message": r[5],
|
||||||
"signature": r[3],
|
"os_type": r[6],
|
||||||
"severity": r[4],
|
"first_seen": r[7],
|
||||||
"message": r[5],
|
"last_seen": r[8],
|
||||||
"os_type": r[6],
|
"consecutive_runs": r[9],
|
||||||
"first_seen": r[7],
|
"evaluation_window": f"{SERVER_STATE['config']['evaluation_window_hours']}h",
|
||||||
"last_seen": r[8],
|
"verified": True,
|
||||||
"consecutive_runs": r[9],
|
"status": r[10]
|
||||||
"evaluation_window": f"{window_hours}h",
|
})
|
||||||
"verified": True,
|
|
||||||
"status": r[10]
|
|
||||||
})
|
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
@@ -395,31 +672,45 @@ def health_check():
|
|||||||
"server_name": SERVER_STATE["config"]["server_name"],
|
"server_name": SERVER_STATE["config"]["server_name"],
|
||||||
"fingerprint": SERVER_STATE["config"]["server_fingerprint"],
|
"fingerprint": SERVER_STATE["config"]["server_fingerprint"],
|
||||||
"tcp_port": SERVER_STATE["config"]["tcp_port"],
|
"tcp_port": SERVER_STATE["config"]["tcp_port"],
|
||||||
"hermes_port": SERVER_STATE["config"]["hermes_port"]
|
"hermes_port": SERVER_STATE["config"]["hermes_port"],
|
||||||
|
"tls_enabled": SERVER_STATE.get("tls_enabled", False)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def run_server():
|
async def run_server():
|
||||||
"""Runs the TCP socket listener and the Hermes REST API concurrently."""
|
"""Runs the mTLS TCP socket listener and the Hermes REST API concurrently."""
|
||||||
config = SERVER_STATE["config"]
|
config = SERVER_STATE["config"]
|
||||||
tcp_host = config["tcp_host"]
|
tcp_host = config["tcp_host"]
|
||||||
tcp_port = int(config["tcp_port"])
|
tcp_port = int(config["tcp_port"])
|
||||||
hermes_host = config["hermes_host"]
|
hermes_host = config["hermes_host"]
|
||||||
hermes_port = int(config["hermes_port"])
|
hermes_port = int(config["hermes_port"])
|
||||||
|
ssl_ctx = SERVER_STATE.get("ssl_ctx")
|
||||||
|
|
||||||
# Start TCP Socket Server
|
# Start mTLS / TCP Socket Server
|
||||||
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port)
|
tcp_server = await asyncio.start_server(handle_socket_client, tcp_host, tcp_port, ssl=ssl_ctx)
|
||||||
print(f"[*] LOGAR TCP Socket Server listening on {tcp_host}:{tcp_port}")
|
mode_str = "mTLS TLSv1.3" if ssl_ctx else "Plain TCP"
|
||||||
|
print(f"[*] LOGAR {mode_str} Socket Server listening on {tcp_host}:{tcp_port}")
|
||||||
|
|
||||||
# Start FastAPI / Uvicorn server for Hermes
|
# Start FastAPI / Uvicorn server for Hermes & Enrollment
|
||||||
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
|
uv_config = uvicorn.Config(app, host=hermes_host, port=hermes_port, log_level="warning")
|
||||||
uv_server = uvicorn.Server(uv_config)
|
uv_server = uvicorn.Server(uv_config)
|
||||||
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
|
print(f"[*] Hermes Reporting API available at http://{hermes_host}:{hermes_port}/api/hermes/report")
|
||||||
|
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
|
||||||
|
|
||||||
await asyncio.gather(
|
watchdog_task = asyncio.create_task(cert_validity_watchdog())
|
||||||
tcp_server.serve_forever(),
|
|
||||||
uv_server.serve()
|
try:
|
||||||
)
|
await asyncio.gather(
|
||||||
|
tcp_server.serve_forever(),
|
||||||
|
uv_server.serve(),
|
||||||
|
watchdog_task
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
watchdog_task.cancel()
|
||||||
|
try:
|
||||||
|
await watchdog_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -432,12 +723,35 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config = load_or_init_config(args.config)
|
config = load_or_init_config(args.config)
|
||||||
init_db(config["db_path"])
|
init_db(
|
||||||
|
config["db_path"],
|
||||||
|
enrollment_secret=config.get("enrollment_secret"),
|
||||||
|
max_seats=config.get("max_seats", 10)
|
||||||
|
)
|
||||||
|
|
||||||
# Load OpenPGP private key into memory
|
# Initialize dynamic PKI (Root CA and Server TLS Certificate)
|
||||||
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
|
ca_cert, ca_key, ca_pem, ca_key_pem = enrollment.generate_ca_if_needed(cert_dir=cert_dir)
|
||||||
|
srv_cert, srv_key, srv_pem, srv_key_pem = enrollment.generate_server_cert_if_needed(
|
||||||
|
ca_cert, ca_key,
|
||||||
|
hostnames=[config.get("tcp_host"), "127.0.0.1", "localhost"],
|
||||||
|
cert_dir=cert_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize mTLS SSLContext if enabled
|
||||||
|
ssl_ctx = None
|
||||||
|
if config.get("tls_enabled", True):
|
||||||
|
ssl_ctx = init_mtls_server_context(cert_dir=cert_dir)
|
||||||
|
|
||||||
|
# Load OpenPGP private key into memory (legacy fallback)
|
||||||
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
priv_key_obj, _ = pgpy.PGPKey.from_blob(config["private_key"])
|
||||||
SERVER_STATE["config"] = config
|
SERVER_STATE["config"] = config
|
||||||
SERVER_STATE["private_key_obj"] = priv_key_obj
|
SERVER_STATE["private_key_obj"] = priv_key_obj
|
||||||
|
SERVER_STATE["ca_cert"] = ca_cert
|
||||||
|
SERVER_STATE["ca_key"] = ca_key
|
||||||
|
SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||||
|
SERVER_STATE["ssl_ctx"] = ssl_ctx
|
||||||
|
SERVER_STATE["tls_enabled"] = config.get("tls_enabled", True)
|
||||||
|
|
||||||
if args.create_client_config:
|
if args.create_client_config:
|
||||||
port = args.server_port or config["tcp_port"]
|
port = args.server_port or config["tcp_port"]
|
||||||
@@ -451,8 +765,10 @@ def main():
|
|||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(f" LOGAR Server Hub: {config['server_name']}")
|
print(f" LOGAR Server Hub: {config['server_name']}")
|
||||||
print(f" Encryption Fingerprint: {config['server_fingerprint']}")
|
print(f" Transport Security: {'mTLS (TLS 1.3)' if ssl_ctx else 'Plain TCP'}")
|
||||||
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | Rule: {config['min_persistence_runs']}+ consecutive runs")
|
print(f" License Quota: {config.get('max_seats', 10)} Active Seats")
|
||||||
|
print(f" Server Encryption Fingerprint: {config['server_fingerprint']}")
|
||||||
|
print(f" Evaluation Window: {config['evaluation_window_hours']} hours | 4-Run Rule: Warnings | Immediate Pass: Errors")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+165
-18
@@ -2,8 +2,10 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import argparse
|
import argparse
|
||||||
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
@@ -22,6 +24,97 @@ CONFIG_FILE_NAME = "client_config.json"
|
|||||||
STATE_FILE_NAME = "client_state.json"
|
STATE_FILE_NAME = "client_state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
|
||||||
|
"""Checks if client certificate at cert_path is expiring within threshold_days."""
|
||||||
|
if not os.path.exists(cert_path):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from cryptography import x509
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as f:
|
||||||
|
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
|
||||||
|
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||||
|
if expiry is None:
|
||||||
|
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return expiry <= (now + timedelta(days=threshold_days))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_client_if_needed(
|
||||||
|
hub_url: str,
|
||||||
|
enrollment_secret: str,
|
||||||
|
cert_dir: str,
|
||||||
|
client_id: str,
|
||||||
|
hostname: str,
|
||||||
|
os_type: str = "windows",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
):
|
||||||
|
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
|
||||||
|
return True
|
||||||
|
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
|
||||||
|
|
||||||
|
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
|
||||||
|
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
|
||||||
|
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
|
||||||
|
payload = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"hostname": hostname,
|
||||||
|
"os": os_type,
|
||||||
|
"enrollment_secret": enrollment_secret
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
enroll_endpoint,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
with open(ca_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["ca_cert"])
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_cert"])
|
||||||
|
with open(key_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["client_key"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
|
||||||
|
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
|
||||||
|
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
|
||||||
|
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
|
||||||
|
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.check_hostname = False
|
||||||
|
|
||||||
|
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
|
||||||
|
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
|
||||||
|
|
||||||
|
|
||||||
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
if custom_state_path:
|
if custom_state_path:
|
||||||
return custom_state_path
|
return custom_state_path
|
||||||
@@ -194,39 +287,96 @@ def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> li
|
|||||||
|
|
||||||
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
Streams logs to the LOGAR hub.
|
||||||
over an authenticated TCP socket. Zero local state is maintained on the client.
|
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
|
||||||
|
or falls back to OpenPGP encrypted envelope over TCP.
|
||||||
"""
|
"""
|
||||||
server_host = config["server_host"]
|
server_host = config["server_host"]
|
||||||
server_port = int(config["server_port"])
|
server_port = int(config["server_port"])
|
||||||
auth_token = config["auth_token"]
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
pub_key_armored = config["server_public_key"]
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
machine_id = get_machine_identifier()
|
||||||
|
|
||||||
# Load and verify server public key
|
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
|
||||||
|
hub_url = None
|
||||||
|
if enrollment_secret:
|
||||||
|
hermes_host = config.get("hermes_host", server_host)
|
||||||
|
hermes_port = config.get("hermes_port", 8443)
|
||||||
|
hub_url = f"http://{hermes_host}:{hermes_port}"
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
|
||||||
|
|
||||||
|
ca_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
cert_path = os.path.join(cert_dir, "client.crt")
|
||||||
|
key_path = os.path.join(cert_dir, "client.key")
|
||||||
|
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
|
||||||
|
|
||||||
|
if has_mtls_certs:
|
||||||
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
|
||||||
|
if enrollment_secret and hub_url:
|
||||||
|
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
|
||||||
|
try:
|
||||||
|
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows", force_renew=True)
|
||||||
|
sock = get_tls_socket(server_host, server_port, cert_dir)
|
||||||
|
except Exception as retry_err:
|
||||||
|
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
payload = {
|
||||||
|
"server": machine_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"logs": logs
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||||
|
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
resp_len_bytes = sock.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed mTLS connection without response.")
|
||||||
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
|
|
||||||
|
response = json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
print(f"[+] Server response: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Fallback to OpenPGP envelope over plain TCP socket
|
||||||
|
auth_token = config.get("auth_token", "")
|
||||||
|
pub_key_armored = config.get("server_public_key")
|
||||||
|
if not pub_key_armored:
|
||||||
|
raise ValueError("No server public key or mTLS certificates available for connection.")
|
||||||
|
|
||||||
|
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
if expected_fp and actual_fp != expected_fp:
|
if expected_fp and actual_fp != expected_fp:
|
||||||
raise ValueError(
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare zero-state candidate batch
|
|
||||||
payload = {
|
payload = {
|
||||||
"server": machine_id,
|
"server": machine_id,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload)
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
encrypted_armored = str(encrypted_msg)
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -234,17 +384,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list):
|
|||||||
}
|
}
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
||||||
|
|
||||||
# Connect over TCP socket and transmit with 4-byte length prefix framing
|
|
||||||
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
sock.settimeout(15.0)
|
sock.settimeout(15.0)
|
||||||
sock.connect((server_host, server_port))
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
sock.sendall(frame)
|
sock.sendall(frame)
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
resp_len_bytes = sock.recv(4)
|
||||||
if not resp_len_bytes:
|
if not resp_len_bytes:
|
||||||
raise ConnectionError("Server closed connection without response.")
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
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 is_cert_expiring_soon(cert_pem: str, threshold_days: int = 30) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if a PEM-encoded X.509 certificate expires within `threshold_days` (or is already expired).
|
||||||
|
Returns True if expiring soon or expired, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
||||||
|
expiry = getattr(cert, "not_valid_after_utc", None)
|
||||||
|
if expiry is None:
|
||||||
|
expiry = cert.not_valid_after.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
return expiry <= (now + datetime.timedelta(days=threshold_days))
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ca_if_needed(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
common_name: str = "LOGAR-Root-CA",
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]:
|
||||||
|
"""
|
||||||
|
Loads an existing Root CA or generates a self-signed Root CA certificate and private key.
|
||||||
|
If existing CA cert is expiring within threshold_days (or force_renew is True), regenerates it.
|
||||||
|
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 not force_renew and 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()
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
if not is_cert_expiring_soon(ca_cert_pem, threshold_days=threshold_days):
|
||||||
|
return ca_cert, ca_key, ca_cert_pem, ca_key_pem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create timestamped backup of previous CA if present
|
||||||
|
if os.path.exists(ca_cert_path):
|
||||||
|
try:
|
||||||
|
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
os.replace(ca_cert_path, f"{ca_cert_path}.{timestamp}.bak")
|
||||||
|
if os.path.exists(ca_key_path):
|
||||||
|
os.replace(ca_key_path, f"{ca_key_path}.{timestamp}.bak")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
force_renew: bool = False,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> Tuple[x509.Certificate, rsa.RSAPrivateKey, str, str]:
|
||||||
|
"""
|
||||||
|
Loads an existing server certificate or generates a new server TLS certificate signed by the Root CA.
|
||||||
|
If existing server cert is expiring within threshold_days (or force_renew is True), regenerates it.
|
||||||
|
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 not force_renew and 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()
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
if not is_cert_expiring_soon(server_cert_pem, threshold_days=threshold_days):
|
||||||
|
return srv_cert, srv_key, server_cert_pem, server_key_pem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create timestamped backup of previous server cert if present
|
||||||
|
if os.path.exists(server_cert_path):
|
||||||
|
try:
|
||||||
|
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
os.replace(server_cert_path, f"{server_cert_path}.{timestamp}.bak")
|
||||||
|
if os.path.exists(server_key_path):
|
||||||
|
os.replace(server_key_path, f"{server_key_path}.{timestamp}.bak")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def check_and_renew_hub_pki(
|
||||||
|
cert_dir: str = "certs",
|
||||||
|
hostnames: Optional[List[str]] = None,
|
||||||
|
threshold_days: int = 30
|
||||||
|
) -> Tuple[bool, bool]:
|
||||||
|
"""
|
||||||
|
Evaluates expiration status of Root CA and Server TLS certificates.
|
||||||
|
If CA certificate is expiring within threshold_days (or missing):
|
||||||
|
- Regenerates Root CA.
|
||||||
|
- Automatically regenerates Server TLS certificate (since CA issuer changed).
|
||||||
|
- Returns (ca_renewed=True, server_renewed=True)
|
||||||
|
Else if Server TLS certificate is expiring within threshold_days (or missing):
|
||||||
|
- Regenerates Server TLS certificate signed by existing Root CA.
|
||||||
|
- Returns (ca_renewed=False, server_renewed=True)
|
||||||
|
Otherwise:
|
||||||
|
- Returns (False, False)
|
||||||
|
"""
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
ca_cert_path = os.path.join(cert_dir, "ca.crt")
|
||||||
|
server_cert_path = os.path.join(cert_dir, "server.crt")
|
||||||
|
|
||||||
|
renew_ca = False
|
||||||
|
renew_server = False
|
||||||
|
|
||||||
|
if not os.path.exists(ca_cert_path):
|
||||||
|
renew_ca = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(ca_cert_path, "r", encoding="utf-8") as f:
|
||||||
|
ca_pem = f.read()
|
||||||
|
if is_cert_expiring_soon(ca_pem, threshold_days=threshold_days):
|
||||||
|
renew_ca = True
|
||||||
|
except Exception:
|
||||||
|
renew_ca = True
|
||||||
|
|
||||||
|
if renew_ca:
|
||||||
|
ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=True)
|
||||||
|
generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True)
|
||||||
|
return True, True
|
||||||
|
|
||||||
|
if not os.path.exists(server_cert_path):
|
||||||
|
renew_server = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(server_cert_path, "r", encoding="utf-8") as f:
|
||||||
|
srv_pem = f.read()
|
||||||
|
if is_cert_expiring_soon(srv_pem, threshold_days=threshold_days):
|
||||||
|
renew_server = True
|
||||||
|
except Exception:
|
||||||
|
renew_server = True
|
||||||
|
|
||||||
|
if renew_server:
|
||||||
|
ca_cert, ca_key, _, _ = generate_ca_if_needed(cert_dir=cert_dir, force_renew=False)
|
||||||
|
generate_server_cert_if_needed(ca_cert, ca_key, hostnames=hostnames, cert_dir=cert_dir, force_renew=True)
|
||||||
|
return False, True
|
||||||
|
|
||||||
|
return False, False
|
||||||
|
|
||||||
@@ -198,6 +198,52 @@ class TestLinuxClientComponent(unittest.TestCase):
|
|||||||
self.assertEqual(logs[0]["__CURSOR"], "c3")
|
self.assertEqual(logs[0]["__CURSOR"], "c3")
|
||||||
self.assertEqual(newest_cursor, "c4")
|
self.assertEqual(newest_cursor, "c4")
|
||||||
|
|
||||||
|
def test_mtls_client_certificate_handling(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_linux_mtls_certs"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, ca_pem, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("linux-client-test", ca_cert, ca_key)
|
||||||
|
|
||||||
|
with open(os.path.join(test_dir, "ca.crt"), "w") as f:
|
||||||
|
f.write(ca_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.crt"), "w") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.key"), "w") as f:
|
||||||
|
f.write(client_key_pem)
|
||||||
|
|
||||||
|
# Test missing certs exception
|
||||||
|
empty_dir = "test_empty_linux_certs"
|
||||||
|
os.makedirs(empty_dir, exist_ok=True)
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
Linux_Client.get_tls_socket("127.0.0.1", 9443, empty_dir)
|
||||||
|
shutil.rmtree(empty_dir, ignore_errors=True)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_client_certificate_validity_and_proactive_check(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_linux_client_validity"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, _, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("linux-validity-test", ca_cert, ca_key, days_valid=365)
|
||||||
|
cert_path = os.path.join(test_dir, "client.crt")
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
|
||||||
|
# Newly issued cert (365 days) is not expiring soon at 30 days
|
||||||
|
self.assertFalse(Linux_Client.is_cert_expiring_soon(cert_path, threshold_days=30))
|
||||||
|
# Large threshold (500 days) reports expiring soon
|
||||||
|
self.assertTrue(Linux_Client.is_cert_expiring_soon(cert_path, threshold_days=500))
|
||||||
|
# Non-existent file reports expiring / missing
|
||||||
|
self.assertTrue(Linux_Client.is_cert_expiring_soon(os.path.join(test_dir, "missing.crt")))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+152
-44
@@ -3,9 +3,11 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ sys.path.insert(0, ROOT_DIR)
|
|||||||
sys.path.insert(0, SRC_DIR)
|
sys.path.insert(0, SRC_DIR)
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
import pgpy
|
|
||||||
|
import Win_Client
|
||||||
|
import Linux_Client
|
||||||
|
|
||||||
# Test server endpoints
|
# Test server endpoints
|
||||||
TCP_HOST = "127.0.0.1"
|
TCP_HOST = "127.0.0.1"
|
||||||
@@ -24,6 +28,7 @@ TCP_PORT = 9443
|
|||||||
HERMES_HOST = "127.0.0.1"
|
HERMES_HOST = "127.0.0.1"
|
||||||
HERMES_PORT = 8443
|
HERMES_PORT = 8443
|
||||||
|
|
||||||
|
|
||||||
def run_tests():
|
def run_tests():
|
||||||
print("=== [1] Verifying server_config.json & client_config.json ===")
|
print("=== [1] Verifying server_config.json & client_config.json ===")
|
||||||
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json")
|
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json")
|
||||||
@@ -45,55 +50,121 @@ def run_tests():
|
|||||||
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
|
print(f"[OK] Verified client_config.json contains no machine/server/site name.")
|
||||||
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
|
||||||
|
|
||||||
# Load public key
|
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
|
||||||
pub_key, _ = pgpy.PGPKey.from_blob(client_conf["server_public_key"])
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
client_id = "test-edge-node.corp.internal"
|
||||||
|
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
|
||||||
|
|
||||||
def send_socket_batch(logs, auth_token=client_conf["auth_token"]):
|
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
|
||||||
payload = {
|
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
|
||||||
"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 = {
|
# 2a. Test rejection on invalid enrollment secret
|
||||||
"auth_token": auth_token,
|
bad_enroll_payload = {
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"client_id": client_id,
|
||||||
"encrypted_payload": str(enc)
|
"hostname": client_id,
|
||||||
}
|
"os": "linux",
|
||||||
envelope_bytes = json.dumps(envelope).encode("utf-8")
|
"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.")
|
||||||
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
# 2b. Test valid client enrollment
|
||||||
s.settimeout(5.0)
|
valid_enroll_payload = {
|
||||||
s.connect((TCP_HOST, TCP_PORT))
|
"client_id": client_id,
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
"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)
|
s.sendall(frame)
|
||||||
|
|
||||||
resp_len_bytes = s.recv(4)
|
resp_len_bytes = s.recv(4)
|
||||||
|
if not resp_len_bytes:
|
||||||
|
raise ConnectionError("Server closed connection without response.")
|
||||||
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
resp_len = struct.unpack(">I", resp_len_bytes)[0]
|
||||||
resp_bytes = s.recv(resp_len)
|
resp_bytes = bytearray()
|
||||||
|
while len(resp_bytes) < resp_len:
|
||||||
|
chunk = s.recv(min(4096, resp_len - len(resp_bytes)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
resp_bytes.extend(chunk)
|
||||||
return json.loads(resp_bytes.decode("utf-8"))
|
return json.loads(resp_bytes.decode("utf-8"))
|
||||||
|
|
||||||
print("\n=== [2] Testing Socket Authentication Failure ===")
|
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule for Warnings over mTLS ===")
|
||||||
bad_resp = send_socket_batch([], auth_token="invalid-token-12345")
|
test_signature = "TestServiceDegraded"
|
||||||
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 = [{
|
candidate_log = [{
|
||||||
"server": "test-edge-node",
|
"server": client_id,
|
||||||
"os_type": "linux",
|
"os_type": "linux",
|
||||||
"signature": test_signature,
|
"signature": test_signature,
|
||||||
"severity": "ERROR",
|
"severity": "WARNING",
|
||||||
"message": "Out of memory killer triggered"
|
"message": "Resource usage high warning"
|
||||||
}]
|
}]
|
||||||
|
|
||||||
print("\n=== [3] Testing Temporal Persistence & 4-Run Rule ===")
|
|
||||||
for run_num in range(1, 5):
|
for run_num in range(1, 5):
|
||||||
resp = send_socket_batch(candidate_log)
|
resp = send_mtls_batch(candidate_log)
|
||||||
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
|
assert resp.get("status") == "success", f"Run {run_num} failed: {resp}"
|
||||||
print(f"[Run {run_num}/4] Ingested successfully. Promoted to verified: {resp.get('promoted_verified')}")
|
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
|
# Inspect SQLite database directly
|
||||||
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
conn = sqlite3.connect(server_conf.get("db_path", "logar_state.db"))
|
||||||
@@ -107,39 +178,76 @@ def run_tests():
|
|||||||
print(f"[DB Verification] Issue '{test_signature}' -> run_count: {run_count}, status: {status}")
|
print(f"[DB Verification] Issue '{test_signature}' -> run_count: {run_count}, status: {status}")
|
||||||
assert run_count >= 4, f"Expected run_count >= 4, got {run_count}"
|
assert run_count >= 4, f"Expected run_count >= 4, got {run_count}"
|
||||||
assert status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
|
assert status == "VERIFIED", f"Expected status 'VERIFIED', got {status}"
|
||||||
print("[OK] 4-Run Rule verified: Transient issue promoted to VERIFIED anomaly!")
|
print("[OK] 4-Run Rule verified: Warning promoted to VERIFIED anomaly on 4th run over mTLS!")
|
||||||
|
|
||||||
print("\n=== [4] Testing Hermes Reporting Endpoint (/api/hermes/report) ===")
|
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")
|
req = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/hermes/report")
|
||||||
with urllib.request.urlopen(req, timeout=5) as response:
|
with urllib.request.urlopen(req, timeout=5) as response:
|
||||||
assert response.status == 200, f"Expected 200, got {response.status}"
|
assert response.status == 200, f"Expected 200, got {response.status}"
|
||||||
hermes_data = json.loads(response.read().decode("utf-8"))
|
hermes_data = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
|
print(f"[Hermes API] Returned {len(hermes_data)} verified anomalies:")
|
||||||
found_issue = False
|
found_warning = False
|
||||||
|
found_error = False
|
||||||
for issue in hermes_data:
|
for issue in hermes_data:
|
||||||
print(f" - Fingerprint: {issue['fingerprint']} | Consecutive Runs: {issue['consecutive_runs']} | Status: {issue['status']}")
|
print(f" - Fingerprint: {issue['fingerprint']} | Consecutive Runs: {issue['consecutive_runs']} | Status: {issue['status']}")
|
||||||
if issue["signature"] == test_signature:
|
if issue["signature"] == test_signature:
|
||||||
found_issue = True
|
found_warning = True
|
||||||
assert issue["verified"] is True
|
assert issue["verified"] is True
|
||||||
assert issue["consecutive_runs"] >= 4
|
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_issue, f"Test issue {test_signature} should be in Hermes report"
|
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("[OK] Hermes reporting validated!")
|
||||||
|
|
||||||
print("\n=== [5] Testing Windows Client Script Integration ===")
|
print("\n=== [6] Testing Windows Client Script Integration ===")
|
||||||
from Win_Client import get_recent_windows_logs
|
from Win_Client import get_recent_windows_logs
|
||||||
win_logs = get_recent_windows_logs(hours=24)
|
win_logs = get_recent_windows_logs(hours=24)
|
||||||
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
|
print(f"[Win_Client] Successfully queried Windows logs: {len(win_logs)} candidate entries.")
|
||||||
|
|
||||||
print("\n=== [6] Testing Linux Client Script Integration ===")
|
print("\n=== [7] Testing Linux Client Script Integration ===")
|
||||||
from Linux_Client import get_recent_linux_logs
|
from Linux_Client import get_recent_linux_logs
|
||||||
linux_logs = get_recent_linux_logs(hours=24)
|
linux_logs = get_recent_linux_logs(hours=24)
|
||||||
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
|
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
|
||||||
|
|
||||||
print("\n==========================================")
|
import shutil
|
||||||
print(" ALL VERIFICATION TESTS PASSED SUCCESSFULLY! ")
|
if os.path.exists(cert_dir):
|
||||||
print("==========================================")
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
print("\n=======================================================")
|
||||||
|
print(" ALL VERIFICATION TESTS (mTLS + PKI + PIPELINE) PASSED! ")
|
||||||
|
print("=======================================================")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_tests()
|
run_tests()
|
||||||
|
|||||||
+200
-14
@@ -79,9 +79,9 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
Server.init_db(self.test_db)
|
Server.init_db(self.test_db)
|
||||||
log_entry = {
|
log_entry = {
|
||||||
"server": "app-worker-01.corp.local",
|
"server": "app-worker-01.corp.local",
|
||||||
"signature": "PostgresConnTimeout",
|
"signature": "PostgresConnWarning",
|
||||||
"severity": "ERROR",
|
"severity": "WARNING",
|
||||||
"message": "Connection to database pool timed out after 30s",
|
"message": "Connection to database pool near capacity: 85%",
|
||||||
"os_type": "linux"
|
"os_type": "linux"
|
||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
@@ -89,7 +89,7 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
"logs": [log_entry]
|
"logs": [log_entry]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Runs 1 to 3: should remain TRANSIENT
|
# Runs 1 to 3: WARNING should remain TRANSIENT
|
||||||
for run_idx in range(1, 4):
|
for run_idx in range(1, 4):
|
||||||
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
||||||
self.assertEqual(res["status"], "success")
|
self.assertEqual(res["status"], "success")
|
||||||
@@ -97,24 +97,53 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnTimeout",))
|
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnWarning",))
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertEqual(row[0], 3)
|
self.assertEqual(row[0], 3)
|
||||||
self.assertEqual(row[1], "TRANSIENT")
|
self.assertEqual(row[1], "TRANSIENT")
|
||||||
|
|
||||||
# Run 4: promotes to VERIFIED!
|
# Run 4: promotes WARNING to VERIFIED!
|
||||||
res4 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
res4 = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
||||||
self.assertEqual(res4["promoted_verified"], 1)
|
self.assertEqual(res4["promoted_verified"], 1)
|
||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnTimeout",))
|
c.execute("SELECT run_count, status FROM active_issues WHERE signature = ?", ("PostgresConnWarning",))
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
self.assertEqual(row[0], 4)
|
self.assertEqual(row[0], 4)
|
||||||
self.assertEqual(row[1], "VERIFIED")
|
self.assertEqual(row[1], "VERIFIED")
|
||||||
|
|
||||||
|
def test_error_immediate_pass(self):
|
||||||
|
Server.init_db(self.test_db)
|
||||||
|
log_entry = {
|
||||||
|
"server": "app-worker-01.corp.local",
|
||||||
|
"signature": "KernelPanicCritical",
|
||||||
|
"severity": "ERROR",
|
||||||
|
"message": "Kernel panic - not syncing: Fatal hardware error",
|
||||||
|
"os_type": "linux"
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"server": "app-worker-01.corp.local",
|
||||||
|
"logs": [log_entry]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run 1: ERROR must immediately promote to VERIFIED
|
||||||
|
res = Server.process_ingested_logs(payload, self.test_db, window_hours=12, min_runs=4)
|
||||||
|
self.assertEqual(res["status"], "success")
|
||||||
|
self.assertEqual(res["promoted_verified"], 1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT run_count, status, severity FROM active_issues WHERE signature = ?", ("KernelPanicCritical",))
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row[0], 1)
|
||||||
|
self.assertEqual(row[1], "VERIFIED")
|
||||||
|
self.assertEqual(row[2], "ERROR")
|
||||||
|
|
||||||
def test_server_severity_filtering(self):
|
def test_server_severity_filtering(self):
|
||||||
Server.init_db(self.test_db)
|
Server.init_db(self.test_db)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -132,15 +161,172 @@ class TestServerComponent(unittest.TestCase):
|
|||||||
|
|
||||||
conn = sqlite3.connect(self.test_db)
|
conn = sqlite3.connect(self.test_db)
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("SELECT signature FROM active_issues ORDER BY signature")
|
c.execute("SELECT signature, status FROM active_issues ORDER BY signature")
|
||||||
sigs = [r[0] for r in c.fetchall()]
|
rows = dict(c.fetchall())
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
self.assertIn("SigInfo", sigs)
|
self.assertIn("SigInfo", rows)
|
||||||
self.assertIn("SigWarn", sigs)
|
self.assertIn("SigWarn", rows)
|
||||||
self.assertIn("SigErr", sigs)
|
self.assertIn("SigErr", rows)
|
||||||
self.assertNotIn("SigDebug", sigs)
|
self.assertNotIn("SigDebug", rows)
|
||||||
self.assertNotIn("SigTrace", sigs)
|
self.assertNotIn("SigTrace", rows)
|
||||||
|
|
||||||
|
# SigErr is immediately VERIFIED; SigWarn and SigInfo are TRANSIENT on run 1
|
||||||
|
self.assertEqual(rows["SigErr"], "VERIFIED")
|
||||||
|
self.assertEqual(rows["SigWarn"], "TRANSIENT")
|
||||||
|
self.assertEqual(rows["SigInfo"], "TRANSIENT")
|
||||||
|
|
||||||
|
def test_license_schema_and_pki_generation(self):
|
||||||
|
secret = "test-secret-12345"
|
||||||
|
Server.init_db(self.test_db, enrollment_secret=secret, max_seats=5)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT max_seats, enrollment_secret FROM license_config WHERE id = 1")
|
||||||
|
row = c.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row[0], 5)
|
||||||
|
self.assertEqual(row[1], secret)
|
||||||
|
|
||||||
|
# Test Dynamic PKI
|
||||||
|
test_cert_dir = "test_certs_pki"
|
||||||
|
try:
|
||||||
|
ca_cert, ca_key, ca_pem, ca_key_pem = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", ca_pem)
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", ca_key_pem)
|
||||||
|
|
||||||
|
srv_cert, srv_key, srv_pem, srv_key_pem = Server.enrollment.generate_server_cert_if_needed(
|
||||||
|
ca_cert, ca_key, hostnames=["127.0.0.1", "localhost"], cert_dir=test_cert_dir
|
||||||
|
)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", srv_pem)
|
||||||
|
|
||||||
|
client_cert_pem, client_key_pem = Server.enrollment.issue_client_cert("node-test-1", ca_cert, ca_key)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", client_cert_pem)
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", client_key_pem)
|
||||||
|
|
||||||
|
fp = Server.enrollment.calculate_cert_fingerprint(client_cert_pem)
|
||||||
|
self.assertEqual(len(fp), 64)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_enrollment_endpoint_and_seat_quota(self):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
secret = "super-secret-enrollment"
|
||||||
|
max_seats = 2
|
||||||
|
Server.init_db(self.test_db, enrollment_secret=secret, max_seats=max_seats)
|
||||||
|
|
||||||
|
test_cert_dir = "test_certs_enroll"
|
||||||
|
try:
|
||||||
|
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db}
|
||||||
|
Server.SERVER_STATE["ca_cert"] = ca_cert
|
||||||
|
Server.SERVER_STATE["ca_key"] = ca_key
|
||||||
|
Server.SERVER_STATE["ca_cert_pem"] = ca_pem
|
||||||
|
|
||||||
|
# 1. Invalid secret should raise 403
|
||||||
|
bad_req = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-1",
|
||||||
|
hostname="host-1",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret="wrong-secret"
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(bad_req)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
|
||||||
|
# 2. Valid enrollment for client 1
|
||||||
|
req1 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-1",
|
||||||
|
hostname="host-1",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
resp1 = Server.enroll_client(req1)
|
||||||
|
self.assertIn("client_cert", resp1)
|
||||||
|
self.assertIn("client_key", resp1)
|
||||||
|
self.assertEqual(resp1["ca_cert"], ca_pem)
|
||||||
|
|
||||||
|
# 3. Valid enrollment for client 2
|
||||||
|
req2 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-2",
|
||||||
|
hostname="host-2",
|
||||||
|
os="windows",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
resp2 = Server.enroll_client(req2)
|
||||||
|
self.assertIn("client_cert", resp2)
|
||||||
|
|
||||||
|
# 4. Seat quota exhausted: client 3 should raise 403
|
||||||
|
req3 = Server.ClientEnrollRequest(
|
||||||
|
client_id="client-3",
|
||||||
|
hostname="host-3",
|
||||||
|
os="linux",
|
||||||
|
enrollment_secret=secret
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(req3)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
self.assertIn("License seat limit reached", cm.exception.detail)
|
||||||
|
|
||||||
|
# 5. Re-enrollment for existing client 1 should succeed
|
||||||
|
resp1_re = Server.enroll_client(req1)
|
||||||
|
self.assertIn("client_cert", resp1_re)
|
||||||
|
|
||||||
|
# 6. Revoked client should be rejected
|
||||||
|
conn = sqlite3.connect(self.test_db)
|
||||||
|
conn.execute("UPDATE clients SET status = 'revoked' WHERE client_id = 'client-1'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as cm:
|
||||||
|
Server.enroll_client(req1)
|
||||||
|
self.assertEqual(cm.exception.status_code, 403)
|
||||||
|
self.assertIn("revoked", cm.exception.detail)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_cert_validity_and_hub_pki_renewal(self):
|
||||||
|
test_cert_dir = "test_certs_renew"
|
||||||
|
try:
|
||||||
|
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
|
||||||
|
srv_cert, srv_key, srv_pem, _ = Server.enrollment.generate_server_cert_if_needed(
|
||||||
|
ca_cert, ca_key, hostnames=["127.0.0.1"], cert_dir=test_cert_dir
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Freshly generated certificates should NOT be expiring soon with standard 30-day threshold
|
||||||
|
self.assertFalse(Server.enrollment.is_cert_expiring_soon(ca_pem, threshold_days=30))
|
||||||
|
self.assertFalse(Server.enrollment.is_cert_expiring_soon(srv_pem, threshold_days=30))
|
||||||
|
|
||||||
|
# 2. Huge threshold (e.g. 5000 days) should flag expiration
|
||||||
|
self.assertTrue(Server.enrollment.is_cert_expiring_soon(srv_pem, threshold_days=5000))
|
||||||
|
|
||||||
|
# 3. check_and_renew_hub_pki with standard threshold should report no renewal needed
|
||||||
|
ca_renewed, srv_renewed = Server.enrollment.check_and_renew_hub_pki(cert_dir=test_cert_dir, threshold_days=30)
|
||||||
|
self.assertFalse(ca_renewed)
|
||||||
|
self.assertFalse(srv_renewed)
|
||||||
|
|
||||||
|
# 4. In-flight reload of SSLContext
|
||||||
|
ssl_ctx = Server.init_mtls_server_context(cert_dir=test_cert_dir)
|
||||||
|
Server.SERVER_STATE["ssl_ctx"] = ssl_ctx
|
||||||
|
Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir, "tcp_host": "127.0.0.1"}
|
||||||
|
|
||||||
|
# Trigger rotation using high threshold
|
||||||
|
rotated = Server.check_and_rotate_server_certs(cert_dir=test_cert_dir, hostnames=["127.0.0.1"], threshold_days=5000)
|
||||||
|
self.assertTrue(rotated)
|
||||||
|
|
||||||
|
# Check that backup files were generated
|
||||||
|
bak_files = [f for f in os.listdir(test_cert_dir) if f.endswith(".bak")]
|
||||||
|
self.assertGreater(len(bak_files), 0)
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(test_cert_dir):
|
||||||
|
shutil.rmtree(test_cert_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -184,6 +184,52 @@ class TestWinClientComponent(unittest.TestCase):
|
|||||||
# Only rec 103 and 102 should be processed (101 is already sent, <= 100 breaks early)
|
# Only rec 103 and 102 should be processed (101 is already sent, <= 100 breaks early)
|
||||||
self.assertEqual(logs, [103, 102])
|
self.assertEqual(logs, [103, 102])
|
||||||
|
|
||||||
|
def test_mtls_client_certificate_handling(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_win_mtls_certs"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, ca_pem, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("win-client-test", ca_cert, ca_key)
|
||||||
|
|
||||||
|
with open(os.path.join(test_dir, "ca.crt"), "w") as f:
|
||||||
|
f.write(ca_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.crt"), "w") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
with open(os.path.join(test_dir, "client.key"), "w") as f:
|
||||||
|
f.write(client_key_pem)
|
||||||
|
|
||||||
|
# Test missing certs exception
|
||||||
|
empty_dir = "test_empty_certs"
|
||||||
|
os.makedirs(empty_dir, exist_ok=True)
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
Win_Client.get_tls_socket("127.0.0.1", 9443, empty_dir)
|
||||||
|
shutil.rmtree(empty_dir, ignore_errors=True)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_client_certificate_validity_and_proactive_check(self):
|
||||||
|
import shutil
|
||||||
|
test_dir = "test_win_client_validity"
|
||||||
|
os.makedirs(test_dir, exist_ok=True)
|
||||||
|
try:
|
||||||
|
from src import server_enrollment as se
|
||||||
|
ca_cert, ca_key, _, _ = se.generate_ca_if_needed(test_dir)
|
||||||
|
client_cert_pem, client_key_pem = se.issue_client_cert("win-validity-test", ca_cert, ca_key, days_valid=365)
|
||||||
|
cert_path = os.path.join(test_dir, "client.crt")
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(client_cert_pem)
|
||||||
|
|
||||||
|
# Newly issued cert (365 days) is not expiring soon at 30 days
|
||||||
|
self.assertFalse(Win_Client.is_cert_expiring_soon(cert_path, threshold_days=30))
|
||||||
|
# Large threshold (500 days) reports expiring soon
|
||||||
|
self.assertTrue(Win_Client.is_cert_expiring_soon(cert_path, threshold_days=500))
|
||||||
|
# Non-existent file reports expiring / missing
|
||||||
|
self.assertTrue(Win_Client.is_cert_expiring_soon(os.path.join(test_dir, "missing.crt")))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(test_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user