14 Commits
Author SHA1 Message Date
me0nline 900d03b7f7 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m37s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 5m4s
2026-09-27 17:10:29 +02:00
me0nline a8d59b2f14 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m14s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m15s
2026-09-27 17:00:17 +02:00
me0nline b79022bc41 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m17s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m15s
2026-09-27 16:07:18 +02:00
me0nline fcba813edd windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m26s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m17s
2026-09-27 15:51:03 +02:00
me0nline 72e3527c79 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m29s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m18s
2026-09-27 15:06:32 +02:00
me0nline 4a40b4d395 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m41s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m47s
2026-09-27 15:00:26 +02:00
me0nline 399cf469c5 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m19s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 9m48s
2026-09-26 22:30:43 +02:00
me0nline ce7d2b97d2 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m21s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 7m43s
2026-09-26 22:09:40 +02:00
me0nline 2e256baf4a windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m13s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 6m51s
2026-09-26 22:00:50 +02:00
me0nline 1a984caaf7 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m24s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m42s
2026-09-26 21:42:51 +02:00
me0nline 96756ca5db windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m14s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m29s
2026-09-26 21:34:21 +02:00
me0nline 157229a84e windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m9s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 10m27s
2026-09-26 21:21:36 +02:00
me0nline 8287af3129 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m36s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 10m7s
2026-09-26 21:07:34 +02:00
me0nline a133050cb5 windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m13s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 7m32s
2026-09-26 20:59:13 +02:00
25 changed files with 2301 additions and 1103 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# LOGAR: Fleet Log Aggregation & Centralized Provisioning System # LOGAR: Fleet Log Aggregation & Centralized Provisioning System
### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis* ### *Secure Log Aggregation, PKI Fleet Licensing & Centralized Delivery for Autonomous AI Analysis*
[![Version](https://img.shields.io/badge/version-0.3.1-blue.svg)](RELEASE_NOTES.md) [![Version](https://img.shields.io/badge/version-0.3.2-blue.svg)](RELEASE_NOTES.md)
[![Tests](https://img.shields.io/badge/tests-35%20passed-success.svg)](tests/) [![Tests](https://img.shields.io/badge/tests-35%20passed-success.svg)](tests/)
[![Security](https://img.shields.io/badge/mTLS-TLS%201.3-green.svg)](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing) [![Security](https://img.shields.io/badge/mTLS-TLS%201.3-green.svg)](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
+5 -12
View File
@@ -1,13 +1,6 @@
# LOGAR Release v0.3.1 # LOGAR Release v0.3.2
### 🛡️ Security Fixes & Defensive Hardening ### 🪟 Windows Installer Fixes
- **Mandatory Admin Authentication**: Enforced strict administrator authentication across all fleet management endpoints (`/api/clients`, `/api/clients/approve`, `/api/clients/revoke`, `/api/clients/delete`, `/api/client/export-profile`) and license management APIs. - **Standalone Windows Binaries & Python Runtime**: Resolved issues with standalone Windows executable execution and bundled runtime paths.
- **Brute-Force Mitigation & Rate Limiting**: Implemented thread-safe sliding-window IP rate limiting on `/login` (10 attempts/min) and `/api/client/enroll` (15 attempts/min) with progressive delays and `HTTP 429` throttling. - **Data Directory Selection & Permissions**: Added custom data directory selection during setup and fixed permissions for background Windows services.
- **Cookie Security Flags**: Session cookies now enforce `HttpOnly=True`, `SameSite="Lax"`, and dynamic `Secure=True` for HTTPS connections and reverse-proxy deployments (`X-Forwarded-Proto`). - **Service Lifecycle & Clean Uninstallation**: Resolved service startup issues and ensured complete cleanup of state files upon uninstallation.
- **Cryptographic Random Secrets**: Server Hub and License Authority session secrets are now generated with 256-bit CSPRNG (`secrets.token_hex(32)`) on initialization.
- **License Renewal Validation**: Hardened `/api/license/renew` with explicit PEM structure and database validation, preventing unhandled exceptions.
### ⚡ Operational Improvements & Modernization
- **FastAPI Lifespan Handlers**: Migrated legacy startup events to modern ASGI lifespan context managers.
- **OpenPGP Compression Preferences**: Aligned Server public key compression algorithms (`ZIP`, `ZLIB`, `BZ2`, `Uncompressed`) with client preferences.
- **Cross-Platform Compilation**: Unified automated packaging for standalone Windows and Linux binaries.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0E1A2E"/>
<stop offset="50%" stop-color="#08101E"/>
<stop offset="100%" stop-color="#040810"/>
</linearGradient>
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00E5FF"/>
<stop offset="100%" stop-color="#0072FF"/>
</linearGradient>
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00FFA3"/>
<stop offset="100%" stop-color="#00E5FF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Base Hexagon Shield -->
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
<!-- Inner Telemetry Grid / Ring -->
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
<!-- Stylized L and Log Streams -->
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
<!-- Connection Nodes / Spark Points -->
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+365
View File
@@ -0,0 +1,365 @@
"""
LOGAR Branding & Installer Graphics Generator
Generates clean, professional SVG, PNG, ICO, and BMP visual assets for
LOGAR installers (Inno Setup & NSIS), application icons, and WebUI branding.
Zero external dependencies required (uses standard library zlib and struct).
"""
import os
import math
import zlib
import struct
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
os.makedirs(ASSETS_DIR, exist_ok=True)
def create_bmp_24bit(width: int, height: int, pixel_func) -> bytes:
"""Generates an uncompressed 24-bit RGB BMP image."""
row_bytes = width * 3
padding = (4 - (row_bytes % 4)) % 4
image_size = (row_bytes + padding) * height
file_size = 14 + 40 + image_size
# BITMAPFILEHEADER (14 bytes)
header = struct.pack(
"<2sIHHI",
b"BM",
file_size,
0, 0,
54 # Offset to pixel data
)
# BITMAPINFOHEADER (40 bytes)
info = struct.pack(
"<IIIHHIIIIII",
40, # biSize
width, # biWidth
height, # biHeight (positive = bottom-to-top)
1, # biPlanes
24, # biBitCount (24-bit BGR)
0, # biCompression (BI_RGB)
image_size, # biSizeImage
2835, # biXPelsPerMeter (72 DPI)
2835, # biYPelsPerMeter
0, # biClrUsed
0 # biClrImportant
)
data = bytearray()
pad = b"\x00" * padding
# BMP is stored bottom-to-top
for y in range(height - 1, -1, -1):
for x in range(width):
r, g, b = pixel_func(x, y, width, height)
data.extend(bytes([int(b) & 0xFF, int(g) & 0xFF, int(r) & 0xFF]))
data.extend(pad)
return header + info + bytes(data)
def create_png_rgba(width: int, height: int, pixel_func) -> bytes:
"""Generates a standard 32-bit RGBA PNG image."""
raw_scanlines = bytearray()
for y in range(height):
raw_scanlines.append(0) # Filter type 0 (None)
for x in range(width):
r, g, b, a = pixel_func(x, y, width, height)
raw_scanlines.extend(bytes([int(r) & 0xFF, int(g) & 0xFF, int(b) & 0xFF, int(a) & 0xFF]))
compressed = zlib.compress(bytes(raw_scanlines), level=9)
def chunk(tag: bytes, data: bytes) -> bytes:
chk = tag + data
crc = zlib.crc32(chk) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + chk + struct.pack(">I", crc)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
png_bytes = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")
return png_bytes
def draw_logo_rgba(x: int, y: int, w: int, h: int) -> tuple:
"""Renders a sleek modern cyberpunk hexagon shield and pulse log streams."""
nx = (x - w / 2) / (w / 2)
ny = (y - h / 2) / (h / 2)
dist = math.sqrt(nx * nx + ny * ny)
if dist > 0.98:
return 0, 0, 0, 0
# Hexagon radius formula
angle = math.atan2(ny, nx)
hex_radius = 0.88 / (math.cos((angle % (math.pi / 3)) - (math.pi / 6)) + 0.001)
if dist > hex_radius:
if dist < hex_radius + 0.04:
alpha = max(0.0, min(1.0, (hex_radius + 0.04 - dist) / 0.04))
return 11, 25, 44, int(alpha * 255)
return 0, 0, 0, 0
# Gradient background: Dark Slate Blue to Navy
grad_t = (ny + 1) / 2
bg_r = int(12 * (1 - grad_t) + 6 * grad_t)
bg_g = int(24 * (1 - grad_t) + 14 * grad_t)
bg_b = int(48 * (1 - grad_t) + 32 * grad_t)
# Hexagon outer border (Cyber Cyan / Emerald highlight)
if dist > hex_radius - 0.07:
glow_t = (nx + 1) / 2
br = int(0 * (1 - glow_t) + 0 * glow_t)
bg = int(229 * (1 - glow_t) + 160 * glow_t)
bb = int(255 * (1 - glow_t) + 255 * glow_t)
return br, bg, bb, 255
# Center stylized 'L' log symbol and dynamic telemetry bars
# Bar 1 (Left Log Stream): x in [-0.42, -0.22], y in [-0.45, 0.45]
if -0.42 <= nx <= -0.22 and -0.45 <= ny <= 0.45:
v = (ny + 0.45) / 0.9
return int(0), int(220 * (1 - v) + 140 * v), int(255), 255
# Bar 2 (Bottom Base of 'L'): x in [-0.42, 0.35], y in [0.25, 0.45]
if -0.42 <= nx <= 0.35 and 0.25 <= ny <= 0.45:
v = (nx + 0.42) / 0.77
return int(0), int(220 * (1 - v) + 180 * v), int(255), 255
# Bar 3 (Central Stream Wave 1): x in [-0.10, 0.08], y in [-0.30, 0.12]
if -0.10 <= nx <= 0.08 and -0.30 <= ny <= 0.12:
return 0, 240, 255, 255
# Bar 4 (Central Stream Wave 2): x in [0.18, 0.36], y in [-0.50, 0.12]
if 0.18 <= nx <= 0.36 and -0.50 <= ny <= 0.12:
return 0, 180, 255, 255
# Subtle inner grid dots
grid_x = (nx * 10) % 1
grid_y = (ny * 10) % 1
if 0.45 <= grid_x <= 0.55 and 0.45 <= grid_y <= 0.55:
return bg_r + 20, bg_g + 30, bg_b + 50, 255
return bg_r, bg_g, bg_b, 255
def draw_wizard_header_white_bgr(x: int, y: int, w: int, h: int) -> tuple:
"""
Renders header BMP graphic with a pure white background (255, 255, 255)
matching the installer wizard dialog background seamlessly.
The logo is positioned on the left with clean alpha-blended edges.
"""
bg_r, bg_g, bg_b = 255, 255, 255
# Position emblem cleanly on the left (center x = 30, center y = h / 2)
icon_center_x = 28
icon_center_y = h / 2
icon_radius = 22
dx = (x - icon_center_x) / icon_radius
dy = (y - icon_center_y) / icon_radius
d = math.sqrt(dx * dx + dy * dy)
if d < 1.0:
er, eg, eb, ea = draw_logo_rgba(int((dx + 1) * 32), int((dy + 1) * 32), 64, 64)
alpha = ea / 255.0
r = int(er * alpha + bg_r * (1.0 - alpha))
g = int(eg * alpha + bg_g * (1.0 - alpha))
b = int(eb * alpha + bg_b * (1.0 - alpha))
return r, g, b
return bg_r, bg_g, bg_b
def draw_wizard_small_bgr(x: int, y: int, w: int, h: int) -> tuple:
"""Renders 55x55 square header BMP with centered logo on pure white background."""
bg_r, bg_g, bg_b = 255, 255, 255
cx = w / 2
cy = h / 2
rad = (min(w, h) / 2) - 4
dx = (x - cx) / rad
dy = (y - cy) / rad
d = math.sqrt(dx * dx + dy * dy)
if d < 1.0:
er, eg, eb, ea = draw_logo_rgba(int((dx + 1) * 32), int((dy + 1) * 32), 64, 64)
alpha = ea / 255.0
r = int(er * alpha + bg_r * (1.0 - alpha))
g = int(eg * alpha + bg_g * (1.0 - alpha))
b = int(eb * alpha + bg_b * (1.0 - alpha))
return r, g, b
return bg_r, bg_g, bg_b
def draw_wizard_sidebar_bgr(x: int, y: int, w: int, h: int) -> tuple:
"""Renders 164x314 BMP sidebar graphic for NSIS / Inno Setup."""
t_y = y / h
# Deep cyber navy to midnight blue vertical gradient
r = int(10 * (1 - t_y) + 4 * t_y)
g = int(22 * (1 - t_y) + 12 * t_y)
b = int(48 * (1 - t_y) + 26 * t_y)
# Subtle telemetry grid / stream lines
if int(x) % 18 == 0 or int(y) % 24 == 0:
r = min(255, r + 8)
g = min(255, g + 14)
b = min(255, b + 25)
# Decorative node glow in top region
emblem_cx = w / 2
emblem_cy = 90
edx = (x - emblem_cx) / 44
edy = (y - emblem_cy) / 44
ed = math.sqrt(edx * edx + edy * edy)
if ed < 0.98:
er, eg, eb, ea = draw_logo_rgba(int((edx + 1) * 64), int((edy + 1) * 64), 128, 128)
if ea > 128:
return er, eg, eb
# Glowing pulse lines streaming downwards from the emblem
if 55 <= x <= 110 and y > 150:
line_idx = int((x - 55) / 14)
line_offset = (line_idx * 37 + int(y * 0.4)) % 100
if line_offset < 30:
glow = math.sin((line_offset / 30) * math.pi)
g = min(255, int(g + 160 * glow))
b = min(255, int(b + 220 * glow))
# Bottom accent band
if y >= h - 4:
accent_t = x / w
return 0, int(220 * accent_t), 255
return r, g, b
def generate_ico_file(png_images: list) -> bytes:
"""Packs multiple PNG images into a valid Windows .ico container."""
count = len(png_images)
header = struct.pack("<HHH", 0, 1, count) # Reserved=0, Type=1 (ICO), Count
entries = bytearray()
offset = 6 + (16 * count)
image_data_blocks = []
for w, h, png_bytes in png_images:
size = len(png_bytes)
b_w = w if w < 256 else 0
b_h = h if h < 256 else 0
entry = struct.pack(
"<BBBBHHII",
b_w,
b_h,
0, # Color count
0, # Reserved
1, # Color planes
32, # Bits per pixel
size,
offset
)
entries.extend(entry)
image_data_blocks.append(png_bytes)
offset += size
return header + bytes(entries) + b"".join(image_data_blocks)
def generate_svg_logo() -> str:
"""Generates a high-quality SVG vector graphic for LOGAR."""
return """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0E1A2E"/>
<stop offset="50%" stop-color="#08101E"/>
<stop offset="100%" stop-color="#040810"/>
</linearGradient>
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00E5FF"/>
<stop offset="100%" stop-color="#0072FF"/>
</linearGradient>
<linearGradient id="emeraldGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#00FFA3"/>
<stop offset="100%" stop-color="#00E5FF"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="12" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Base Hexagon Shield -->
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139"
fill="url(#bgGrad)" stroke="url(#cyanGrad)" stroke-width="8" filter="url(#glow)"/>
<!-- Inner Telemetry Grid / Ring -->
<polygon points="256,58 426,156 426,356 256,454 86,356 86,156"
fill="none" stroke="#162A45" stroke-width="3" stroke-dasharray="8 6"/>
<!-- Stylized L and Log Streams -->
<rect x="140" y="140" width="46" height="232" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
<rect x="140" y="326" width="220" height="46" rx="10" fill="url(#cyanGrad)" filter="url(#glow)"/>
<rect x="220" y="210" width="36" height="80" rx="8" fill="url(#emeraldGrad)"/>
<rect x="286" y="150" width="36" height="140" rx="8" fill="url(#cyanGrad)"/>
<!-- Connection Nodes / Spark Points -->
<circle cx="238" cy="190" r="7" fill="#00FFA3" filter="url(#glow)"/>
<circle cx="304" cy="130" r="7" fill="#00E5FF" filter="url(#glow)"/>
<circle cx="380" cy="349" r="8" fill="#00E5FF" filter="url(#glow)"/>
</svg>"""
def main():
print("[*] Generating high-resolution branding assets for LOGAR...")
# 1. Generate SVG Logo
svg_path = os.path.join(ASSETS_DIR, "logo.svg")
with open(svg_path, "w", encoding="utf-8") as f:
f.write(generate_svg_logo())
print(f"[+] Saved SVG logo: {svg_path}")
# 2. Generate PNG images of various sizes
png_sizes = [16, 32, 48, 64, 128, 256, 512]
png_buffers = []
for s in png_sizes:
png_data = create_png_rgba(s, s, draw_logo_rgba)
png_buffers.append((s, s, png_data))
if s in [256, 512]:
out_p = os.path.join(ASSETS_DIR, f"logo_{s}x{s}.png" if s != 512 else "logo.png")
with open(out_p, "wb") as f:
f.write(png_data)
print(f"[+] Saved PNG logo: {out_p}")
# 3. Generate Windows ICO File
ico_path = os.path.join(ASSETS_DIR, "logo.ico")
ico_data = generate_ico_file(png_buffers[:-1]) # Up to 256x256
with open(ico_path, "wb") as f:
f.write(ico_data)
print(f"[+] Saved Windows Icon (.ico): {ico_path}")
# 4. Generate NSIS / Inno Setup Wizard Header Bitmap (150x57) with seamless white background
header_bmp_path = os.path.join(ASSETS_DIR, "wizard_header.bmp")
header_bmp_data = create_bmp_24bit(150, 57, draw_wizard_header_white_bgr)
with open(header_bmp_path, "wb") as f:
f.write(header_bmp_data)
print(f"[+] Saved Wizard Header Bitmap (White Background): {header_bmp_path}")
# 5. Generate Inno Setup Small Wizard Bitmap (55x55) with seamless white background
small_bmp_path = os.path.join(ASSETS_DIR, "wizard_small.bmp")
small_bmp_data = create_bmp_24bit(55, 55, draw_wizard_small_bgr)
with open(small_bmp_path, "wb") as f:
f.write(small_bmp_data)
print(f"[+] Saved Small Wizard Bitmap (55x55): {small_bmp_path}")
# 6. Generate NSIS / Inno Setup Wizard Sidebar Bitmap (164x314)
sidebar_bmp_path = os.path.join(ASSETS_DIR, "wizard_sidebar.bmp")
sidebar_bmp_data = create_bmp_24bit(164, 314, draw_wizard_sidebar_bgr)
with open(sidebar_bmp_path, "wb") as f:
f.write(sidebar_bmp_data)
print(f"[+] Saved Wizard Sidebar Bitmap: {sidebar_bmp_path}")
print("[+] All LOGAR branding and installer graphics generated successfully.")
if __name__ == "__main__":
main()
+90 -9
View File
@@ -1,6 +1,6 @@
[Setup] [Setup]
AppName=LOGAR Client AppName=LOGAR Client
AppVersion=0.3.1 AppVersion=0.3.2
AppPublisher=LOGAR Security Systems AppPublisher=LOGAR Security Systems
AppPublisherURL=https://logar.eibl.tech AppPublisherURL=https://logar.eibl.tech
AppSupportURL=https://logar.eibl.tech AppSupportURL=https://logar.eibl.tech
@@ -12,11 +12,14 @@ PrivilegesRequired=admin
Compression=lzma Compression=lzma
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
SetupIconFile=assets\logo.ico
WizardImageFile=assets\wizard_sidebar.bmp
WizardSmallImageFile=assets\wizard_small.bmp
VersionInfoCompany=LOGAR Security Systems VersionInfoCompany=LOGAR Security Systems
VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup VersionInfoDescription=LOGAR Windows Edge Log Forwarder Setup
VersionInfoVersion=0.3.1.0 VersionInfoVersion=0.3.2.0
VersionInfoProductName=LOGAR Client VersionInfoProductName=LOGAR Client
VersionInfoProductVersion=0.3.1 VersionInfoProductVersion=0.3.2
[Files] [Files]
Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\dist\Win_Client.exe"; DestDir: "{app}"; Flags: ignoreversion
@@ -31,7 +34,7 @@ Filename: "{app}\nssm.exe"; Parameters: "install LOGAR_Client ""{app}\Win_Client
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppDirectory ""{app}"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{code:GetDataDir}\client.log"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStdout ""{code:GetDataDir}\client.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{code:GetDataDir}\client_err.log"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppStderr ""{code:GetDataDir}\client_err.log"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppParameters ""--config """"{code:GetDataDir}\client_config.json"""""""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Client AppParameters ""--config """"{code:GetDataDir}\client_config.json"""" --interval 30"""; Flags: runhidden
Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Client"; Flags: runhidden
[UninstallRun] [UninstallRun]
@@ -50,6 +53,7 @@ Type: filesandordirs; Name: "{commonappdata}\LOGAR"
[Code] [Code]
var var
DataDirPage: TInputDirWizardPage; DataDirPage: TInputDirWizardPage;
ConfigFilePage: TInputFileWizardPage;
procedure InitializeWizard; procedure InitializeWizard;
begin begin
@@ -64,6 +68,16 @@ begin
); );
DataDirPage.Add('Data Directory:'); DataDirPage.Add('Data Directory:');
DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR'); DataDirPage.Values[0] := ExpandConstant('{commonappdata}\LOGAR');
ConfigFilePage := CreateInputFilePage(
DataDirPage.ID,
'Import Client Configuration (Optional)',
'Do you have a pre-exported client_config.json profile to import?',
'Select your client_config.json profile exported from the LOGAR Server Hub.'#13#10#13#10 +
'If selected, this configuration will be copied into the data directory and used for automated server connection and enrollment.'#13#10#13#10 +
'You can also leave this blank to place client_config.json in the data directory manually later.'
);
ConfigFilePage.Add('Client Configuration (client_config.json):', 'JSON Files (*.json)|*.json|All Files (*.*)|*.*', '.json');
end; end;
function GetDataDir(Param: String): String; function GetDataDir(Param: String): String;
@@ -73,25 +87,92 @@ begin
Result := ExpandConstant('{commonappdata}\LOGAR'); Result := ExpandConstant('{commonappdata}\LOGAR');
end; end;
procedure CurStepChanged(CurStep: TSetupStep); function GetConfigFile(Param: String): String;
begin begin
if CurStep = ssPostInstall then Result := ConfigFilePage.Values[0];
end;
procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
CfgPath, DestCfg: String;
begin
if CurStep = ssInstall then
begin
Exec('net.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('sc.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Sleep(500);
end
else if CurStep = ssPostInstall then
begin begin
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir('')); RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', GetDataDir(''));
CfgPath := GetConfigFile('');
if (CfgPath <> '') and FileExists(CfgPath) then
begin
DestCfg := GetDataDir('') + '\client_config.json';
FileCopy(CfgPath, DestCfg, False);
end;
end; end;
end; end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var var
CustomDataDir: String; CustomDataDir, AppDir, PowerShellCmd: String;
I, ResultCode: Integer;
begin begin
if CurUninstallStep = usPostUninstall then if CurUninstallStep = usUninstall then
begin begin
AppDir := ExpandConstant('{app}');
Exec('net.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('sc.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if FileExists(AppDir + '\nssm.exe') then
begin
Exec(AppDir + '\nssm.exe', 'stop LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(AppDir + '\nssm.exe', 'remove LOGAR_Client confirm', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
Exec('sc.exe', 'delete LOGAR_Client', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
PowerShellCmd := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "' +
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like ''' + AppDir + '\*'' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"';
Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Sleep(1200);
end
else if CurUninstallStep = usPostUninstall then
begin
AppDir := ExpandConstant('{app}');
Exec('taskkill.exe', '/F /T /IM Win_Client.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', CustomDataDir) then if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client', 'DataDir', CustomDataDir) then
begin begin
if (CustomDataDir <> '') and DirExists(CustomDataDir) then if (CustomDataDir <> '') and DirExists(CustomDataDir) then
DelTree(CustomDataDir, True, True, True); begin
for I := 1 to 5 do
begin
if DelTree(CustomDataDir, True, True, True) or not DirExists(CustomDataDir) then
Break;
Sleep(300);
end;
end;
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client'); RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Client');
end; end;
if (AppDir <> '') and DirExists(AppDir) then
begin
for I := 1 to 10 do
begin
DeleteFile(AppDir + '\nssm.exe');
DeleteFile(AppDir + '\Win_Client.exe');
DelTree(AppDir + '\runtime', True, True, True);
DelTree(AppDir + '\certs', True, True, True);
if DelTree(AppDir, True, True, True) or not DirExists(AppDir) then
Break;
Sleep(300);
end;
end;
end; end;
end; end;
+89 -8
View File
@@ -1,29 +1,42 @@
!include "MUI2.nsh" !include "MUI2.nsh"
!include "LogicLib.nsh" !include "LogicLib.nsh"
!include "nsDialogs.nsh"
Name "LOGAR Client" Name "LOGAR Client"
OutFile "../dist/LOGAR-Client-Setup.exe" OutFile "../dist/LOGAR-Client-Setup.exe"
InstallDir "$PROGRAMFILES64\LOGAR" InstallDir "$PROGRAMFILES64\LOGAR"
RequestExecutionLevel admin RequestExecutionLevel admin
BrandingText "LOGAR Enterprise SIEM & Edge Forwarder" BrandingText "LOGAR Centralized Log Aggregator & Forwarder"
VIProductVersion "0.3.1.0" VIProductVersion "0.3.2.0"
VIAddVersionKey "ProductName" "LOGAR Client" VIAddVersionKey "ProductName" "LOGAR Client"
VIAddVersionKey "CompanyName" "LOGAR Security Systems" VIAddVersionKey "CompanyName" "LOGAR Security Systems"
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems" VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup" VIAddVersionKey "FileDescription" "LOGAR Windows Edge Log Forwarder Setup"
VIAddVersionKey "FileVersion" "0.3.1.0" VIAddVersionKey "FileVersion" "0.3.2.0"
VIAddVersionKey "ProductVersion" "0.3.1.0" VIAddVersionKey "ProductVersion" "0.3.2.0"
# --- Visual Graphics & Branding ---
!define MUI_ICON "assets/logo.ico"
!define MUI_UNICON "assets/logo.ico"
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "assets/wizard_header.bmp"
!define MUI_WELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
Var DATADIR Var DATADIR
Var CFGFILE
Var CfgDialog
Var CfgFileInput
Var CfgBrowseBtn
# --- UI Pages --- # --- UI Pages ---
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
# Welcome Page # Welcome Page
!define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Client Setup" !define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Client Setup"
!define MUI_WELCOMEPAGE_TEXT "LOGAR Client provides secure, encrypted Windows Event Log forwarding with automatic certificate enrollment and state tracking.\r\n\r\nThis wizard will guide you through installing the forwarder service." !define MUI_WELCOMEPAGE_TEXT "LOGAR Client provides secure, encrypted Windows Event Log forwarding with automatic certificate enrollment and state tracking.$\r$\n$\r$\nThis wizard will guide you through installing the forwarder service."
!insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_WELCOME
# Directory Page 1: Installation Directory # Directory Page 1: Installation Directory
@@ -40,6 +53,9 @@ Var DATADIR
!define MUI_DIRECTORYPAGE_VARIABLE $DATADIR !define MUI_DIRECTORYPAGE_VARIABLE $DATADIR
!insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_DIRECTORY
# Custom Page: Config File Selection
Page custom ConfigPageCreate ConfigPageLeave
# Instfiles Page # Instfiles Page
!insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_INSTFILES
@@ -57,10 +73,54 @@ Var DATADIR
Function .onInit Function .onInit
SetShellVarContext all SetShellVarContext all
StrCpy $DATADIR "$APPDATA\LOGAR" StrCpy $DATADIR "$APPDATA\LOGAR"
StrCpy $CFGFILE ""
FunctionEnd
Function ConfigPageCreate
nsDialogs::Create 1018
Pop $CfgDialog
${If} $CfgDialog == error
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Import Client Configuration (Optional)" "Choose a client_config.json profile exported from LOGAR Server."
${NSD_CreateLabel} 0 0 100% 30u "If you have exported a client_config.json profile bundle from your LOGAR Server dashboard, select it below to configure the forwarder immediately.$\r$\n$\r$\nLeave this field blank to configure later."
Pop $0
${NSD_CreateText} 0 38u 75% 12u "$CFGFILE"
Pop $CfgFileInput
${NSD_CreateButton} 78% 37u 22% 14u "Browse..."
Pop $CfgBrowseBtn
${NSD_OnClick} $CfgBrowseBtn OnBrowseCfg
nsDialogs::Show
FunctionEnd
Function OnBrowseCfg
nsDialogs::SelectFileDialog open "" "LOGAR Client Profiles (*.json)|*.json|All Files (*.*)|*.*"
Pop $0
${If} $0 != ""
StrCpy $CFGFILE $0
${NSD_SetText} $CfgFileInput $CFGFILE
${EndIf}
FunctionEnd
Function ConfigPageLeave
${NSD_GetText} $CfgFileInput $CFGFILE
FunctionEnd FunctionEnd
Section "Install" Section "Install"
SetShellVarContext all SetShellVarContext all
# Terminate any existing service or running processes before replacing files
ExecWait 'net stop LOGAR_Client'
ExecWait 'sc stop LOGAR_Client'
ExecWait 'taskkill /F /T /IM Win_Client.exe'
ExecWait 'taskkill /F /T /IM nssm.exe'
Sleep 500
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
File "../dist/Win_Client.exe" File "../dist/Win_Client.exe"
File "../compilation/nssm.exe" File "../compilation/nssm.exe"
@@ -69,12 +129,18 @@ Section "Install"
CreateDirectory "$DATADIR" CreateDirectory "$DATADIR"
ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q' ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q'
# If client config file was selected, copy to data directory
${If} $CFGFILE != ""
${AndIf} ${FileExists} "$CFGFILE"
CopyFiles /SILENT "$CFGFILE" "$DATADIR\client_config.json"
${EndIf}
# Install and configure service via NSSM # Install and configure service via NSSM
ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"' ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Client "$INSTDIR\Win_Client.exe"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppDirectory "$INSTDIR"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStdout "$DATADIR\client.log"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStdout "$DATADIR\client.log"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStderr "$DATADIR\client_err.log"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppStderr "$DATADIR\client_err.log"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppParameters "--config \"$DATADIR\client_config.json\""' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Client AppParameters "--config $\"$DATADIR\client_config.json$\" --interval 30"'
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Client' ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Client'
WriteUninstaller "$INSTDIR\uninstall.exe" WriteUninstaller "$INSTDIR\uninstall.exe"
@@ -88,8 +154,22 @@ Section "Uninstall"
StrCpy $DATADIR "$APPDATA\LOGAR" StrCpy $DATADIR "$APPDATA\LOGAR"
${EndIf} ${EndIf}
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client' # 1. Stop and remove service
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm' ExecWait 'net stop LOGAR_Client'
ExecWait 'sc stop LOGAR_Client'
${If} ${FileExists} "$INSTDIR\nssm.exe"
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Client'
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Client confirm'
${EndIf}
ExecWait 'sc delete LOGAR_Client'
# 2. Force terminate any lingering processes
ExecWait 'taskkill /F /T /IM Win_Client.exe'
ExecWait 'taskkill /F /T /IM nssm.exe'
ExecWait `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$INSTDIR\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
Sleep 1200
# 3. Clean up all installation and data files
RMDir /r "$INSTDIR\runtime" RMDir /r "$INSTDIR\runtime"
RMDir /r "$INSTDIR\certs" RMDir /r "$INSTDIR\certs"
Delete "$INSTDIR\client_config.json" Delete "$INSTDIR\client_config.json"
@@ -101,4 +181,5 @@ Section "Uninstall"
RMDir /r "$INSTDIR" RMDir /r "$INSTDIR"
RMDir /r "$DATADIR" RMDir /r "$DATADIR"
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client" DeleteRegKey HKLM "Software\LOGAR\LOGAR-Client"
DeleteRegKey /ifempty HKLM "Software\LOGAR"
SectionEnd SectionEnd
+64 -7
View File
@@ -1,6 +1,6 @@
[Setup] [Setup]
AppName=LOGAR Server AppName=LOGAR Server
AppVersion=0.3.1 AppVersion=0.3.2
AppPublisher=LOGAR Security Systems AppPublisher=LOGAR Security Systems
AppPublisherURL=https://logar.eibl.tech AppPublisherURL=https://logar.eibl.tech
AppSupportURL=https://logar.eibl.tech AppSupportURL=https://logar.eibl.tech
@@ -12,11 +12,14 @@ PrivilegesRequired=admin
Compression=lzma Compression=lzma
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
SetupIconFile=assets\logo.ico
WizardImageFile=assets\wizard_sidebar.bmp
WizardSmallImageFile=assets\wizard_small.bmp
VersionInfoCompany=LOGAR Security Systems VersionInfoCompany=LOGAR Security Systems
VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup VersionInfoDescription=LOGAR Cloud Hub & TCP Ingestion Server Setup
VersionInfoVersion=0.3.1.0 VersionInfoVersion=0.3.2.0
VersionInfoProductName=LOGAR Server VersionInfoProductName=LOGAR Server
VersionInfoProductVersion=0.3.1 VersionInfoProductVersion=0.3.2
[Files] [Files]
Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "..\dist\Server.exe"; DestDir: "{app}"; Flags: ignoreversion
@@ -75,8 +78,18 @@ begin
end; end;
procedure CurStepChanged(CurStep: TSetupStep); procedure CurStepChanged(CurStep: TSetupStep);
var
ResultCode: Integer;
begin begin
if CurStep = ssPostInstall then if CurStep = ssInstall then
begin
Exec('net.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('sc.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Sleep(500);
end
else if CurStep = ssPostInstall then
begin begin
RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', GetDataDir('')); RegWriteStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', GetDataDir(''));
end; end;
@@ -84,15 +97,59 @@ end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var var
CustomDataDir: String; CustomDataDir, AppDir, PowerShellCmd: String;
I, ResultCode: Integer;
begin begin
if CurUninstallStep = usPostUninstall then if CurUninstallStep = usUninstall then
begin begin
AppDir := ExpandConstant('{app}');
Exec('net.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('sc.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if FileExists(AppDir + '\nssm.exe') then
begin
Exec(AppDir + '\nssm.exe', 'stop LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec(AppDir + '\nssm.exe', 'remove LOGAR_Server confirm', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
Exec('sc.exe', 'delete LOGAR_Server', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
PowerShellCmd := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "' +
'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like ''' + AppDir + '\*'' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"';
Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Sleep(1200);
end
else if CurUninstallStep = usPostUninstall then
begin
AppDir := ExpandConstant('{app}');
Exec('taskkill.exe', '/F /T /IM Server.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/F /T /IM nssm.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', CustomDataDir) then if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server', 'DataDir', CustomDataDir) then
begin begin
if (CustomDataDir <> '') and DirExists(CustomDataDir) then if (CustomDataDir <> '') and DirExists(CustomDataDir) then
DelTree(CustomDataDir, True, True, True); begin
for I := 1 to 5 do
begin
if DelTree(CustomDataDir, True, True, True) or not DirExists(CustomDataDir) then
Break;
Sleep(300);
end;
end;
RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server'); RegDeleteKeyIncludingSubkeys(HKEY_LOCAL_MACHINE, 'Software\LOGAR\LOGAR-Server');
end; end;
if (AppDir <> '') and DirExists(AppDir) then
begin
for I := 1 to 10 do
begin
DeleteFile(AppDir + '\nssm.exe');
DeleteFile(AppDir + '\Server.exe');
DelTree(AppDir + '\runtime', True, True, True);
DelTree(AppDir + '\certs', True, True, True);
if DelTree(AppDir, True, True, True) or not DirExists(AppDir) then
Break;
Sleep(300);
end;
end;
end; end;
end; end;
+40 -9
View File
@@ -6,15 +6,23 @@ OutFile "../dist/LOGAR-Server-Setup.exe"
InstallDir "$PROGRAMFILES64\LOGAR-Server" InstallDir "$PROGRAMFILES64\LOGAR-Server"
RequestExecutionLevel admin RequestExecutionLevel admin
BrandingText "LOGAR Enterprise SIEM & Log Ingestion" BrandingText "LOGAR Centralized Log Aggregator & Ingestion Hub"
VIProductVersion "0.3.1.0" VIProductVersion "0.3.2.0"
VIAddVersionKey "ProductName" "LOGAR Server" VIAddVersionKey "ProductName" "LOGAR Server"
VIAddVersionKey "CompanyName" "LOGAR Security Systems" VIAddVersionKey "CompanyName" "LOGAR Security Systems"
VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems" VIAddVersionKey "LegalCopyright" "© 2026 LOGAR Security Systems"
VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup" VIAddVersionKey "FileDescription" "LOGAR Cloud Hub & TCP Ingestion Server Setup"
VIAddVersionKey "FileVersion" "0.3.1.0" VIAddVersionKey "FileVersion" "0.3.2.0"
VIAddVersionKey "ProductVersion" "0.3.1.0" VIAddVersionKey "ProductVersion" "0.3.2.0"
# --- Visual Graphics & Branding ---
!define MUI_ICON "assets/logo.ico"
!define MUI_UNICON "assets/logo.ico"
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "assets/wizard_header.bmp"
!define MUI_WELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "assets/wizard_sidebar.bmp"
Var DATADIR Var DATADIR
@@ -23,7 +31,7 @@ Var DATADIR
# Welcome Page # Welcome Page
!define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Server Setup" !define MUI_WELCOMEPAGE_TITLE "Welcome to LOGAR Server Setup"
!define MUI_WELCOMEPAGE_TEXT "LOGAR provides high-performance encrypted log ingestion, temporal analysis, and mTLS client enrollment.\r\n\r\nThis wizard will guide you through installing LOGAR Server and configuring it as an automated Windows service." !define MUI_WELCOMEPAGE_TEXT "LOGAR provides high-performance encrypted log ingestion, temporal analysis, and mTLS client enrollment.$\r$\n$\r$\nThis wizard will guide you through installing LOGAR Server and configuring it as an automated Windows service."
!insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_WELCOME
# Directory Page 1: Installation Directory # Directory Page 1: Installation Directory
@@ -45,7 +53,7 @@ Var DATADIR
# Finish Page # Finish Page
!define MUI_FINISHPAGE_TITLE "Installation Complete" !define MUI_FINISHPAGE_TITLE "Installation Complete"
!define MUI_FINISHPAGE_TEXT "LOGAR Server has been successfully installed and started as a background Windows service.\r\n\r\nWeb Hub Dashboard: http://localhost:8443\r\nTCP Ingestion Port: 9443" !define MUI_FINISHPAGE_TEXT "LOGAR Server has been successfully installed and started as a background Windows service.$\r$\n$\r$\nWeb Hub Dashboard: http://localhost:8443$\r$\nTCP Ingestion Port: 9443"
!insertmacro MUI_PAGE_FINISH !insertmacro MUI_PAGE_FINISH
# Uninstaller Pages # Uninstaller Pages
@@ -61,6 +69,14 @@ FunctionEnd
Section "Install" Section "Install"
SetShellVarContext all SetShellVarContext all
# Terminate any existing service or running processes before replacing files
ExecWait 'net stop LOGAR_Server'
ExecWait 'sc stop LOGAR_Server'
ExecWait 'taskkill /F /T /IM Server.exe'
ExecWait 'taskkill /F /T /IM nssm.exe'
Sleep 500
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
File "../dist/Server.exe" File "../dist/Server.exe"
File "../compilation/nssm.exe" File "../compilation/nssm.exe"
@@ -74,7 +90,7 @@ Section "Install"
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppDirectory "$INSTDIR"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppDirectory "$INSTDIR"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStdout "$DATADIR\server.log"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStdout "$DATADIR\server.log"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStderr "$DATADIR\server_err.log"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppStderr "$DATADIR\server_err.log"'
ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppParameters "--config \"$DATADIR\server_config.json\""' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppParameters "--config $\"$DATADIR\server_config.json$\""'
ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Server' ExecWait '"$INSTDIR\nssm.exe" start LOGAR_Server'
WriteUninstaller "$INSTDIR\uninstall.exe" WriteUninstaller "$INSTDIR\uninstall.exe"
@@ -88,8 +104,22 @@ Section "Uninstall"
StrCpy $DATADIR "$APPDATA\LOGAR-Server" StrCpy $DATADIR "$APPDATA\LOGAR-Server"
${EndIf} ${EndIf}
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Server' # 1. Stop and remove service
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Server confirm' ExecWait 'net stop LOGAR_Server'
ExecWait 'sc stop LOGAR_Server'
${If} ${FileExists} "$INSTDIR\nssm.exe"
ExecWait '"$INSTDIR\nssm.exe" stop LOGAR_Server'
ExecWait '"$INSTDIR\nssm.exe" remove LOGAR_Server confirm'
${EndIf}
ExecWait 'sc delete LOGAR_Server'
# 2. Force terminate any lingering processes
ExecWait 'taskkill /F /T /IM Server.exe'
ExecWait 'taskkill /F /T /IM nssm.exe'
ExecWait `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -like '$INSTDIR\*' } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
Sleep 1200
# 3. Clean up all installation and data files
RMDir /r "$INSTDIR\runtime" RMDir /r "$INSTDIR\runtime"
RMDir /r "$INSTDIR\certs" RMDir /r "$INSTDIR\certs"
Delete "$INSTDIR\server_config.json" Delete "$INSTDIR\server_config.json"
@@ -102,4 +132,5 @@ Section "Uninstall"
RMDir /r "$INSTDIR" RMDir /r "$INSTDIR"
RMDir /r "$DATADIR" RMDir /r "$DATADIR"
DeleteRegKey HKLM "Software\LOGAR\LOGAR-Server" DeleteRegKey HKLM "Software\LOGAR\LOGAR-Server"
DeleteRegKey /ifempty HKLM "Software\LOGAR"
SectionEnd SectionEnd
+25 -3
View File
@@ -15,12 +15,34 @@ BUILD_TEMP = os.path.join(ROOT_DIR, "build_temp")
def clean_and_prep(): def clean_and_prep():
if os.path.exists(DIST_DIR): if os.path.exists(DIST_DIR):
shutil.rmtree(DIST_DIR) for item in os.listdir(DIST_DIR):
os.makedirs(DIST_DIR, exist_ok=True) if item == "runtime":
continue
item_path = os.path.join(DIST_DIR, item)
try:
if os.path.isdir(item_path):
shutil.rmtree(item_path)
else:
os.remove(item_path)
except Exception:
pass
else:
os.makedirs(DIST_DIR, exist_ok=True)
if os.path.exists(BUILD_TEMP): if os.path.exists(BUILD_TEMP):
shutil.rmtree(BUILD_TEMP) try:
shutil.rmtree(BUILD_TEMP)
except Exception:
pass
os.makedirs(BUILD_TEMP, exist_ok=True) os.makedirs(BUILD_TEMP, exist_ok=True)
# Generate or verify branding images and icons
branding_script = os.path.join(ROOT_DIR, "compilation", "generate_branding_assets.py")
if os.path.exists(branding_script):
try:
subprocess.run([sys.executable, branding_script], cwd=os.path.join(ROOT_DIR, "compilation"), check=False)
except Exception:
pass
def calculate_sha256(filepath): def calculate_sha256(filepath):
h = hashlib.sha256() h = hashlib.sha256()
+84 -437
View File
@@ -1,447 +1,72 @@
"""
LOGAR Linux Edge Log Forwarder
Collects Linux systemd journalctl / syslog entries and securely streams them
to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
"""
import os import os
import sys import sys
import json import time
import socket
import ssl
import struct
import argparse import argparse
import subprocess
import urllib.request
import warnings import warnings
from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
# Ensure script directory and repo root are in sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
# Suppress cryptography / pgpy deprecation notices # Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
import pgpy import pgpy
from log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path,
get_state_path,
load_state,
save_state,
commit_state,
LinuxLogCollector,
BaseLogCollector
)
from client_transport import (
is_cert_expiring_soon,
enroll_client_if_needed,
get_tls_socket,
load_config,
ensure_enrolled as transport_ensure_enrolled,
send_encrypted_logs_over_socket as transport_send_encrypted_logs
)
CONFIG_FILE_NAME = "client_config.json" CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json" STATE_FILE_NAME = "client_state.json"
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
"""Checks if client certificate at cert_path is expiring within threshold_days."""
if not os.path.exists(cert_path):
return True
try:
from cryptography import x509
with open(cert_path, "r", encoding="utf-8") as f:
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
expiry = getattr(cert, "not_valid_after_utc", None)
if expiry is None:
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
return expiry <= (now + timedelta(days=threshold_days))
except Exception:
return True
def enroll_client_if_needed(
hub_url: str,
enrollment_secret: str,
cert_dir: str,
client_id: str,
hostname: str,
os_type: str = "linux",
force_renew: bool = False,
threshold_days: int = 30
):
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
os.makedirs(cert_dir, exist_ok=True)
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
return True
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
payload = {
"client_id": client_id,
"hostname": hostname,
"os": os_type,
"enrollment_secret": enrollment_secret
}
req = urllib.request.Request(
enroll_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
data = json.loads(resp.read().decode("utf-8"))
with open(ca_path, "w", encoding="utf-8") as f:
f.write(data["ca_cert"])
with open(cert_path, "w", encoding="utf-8") as f:
f.write(data["client_cert"])
with open(key_path, "w", encoding="utf-8") as f:
f.write(data["client_key"])
try:
os.chmod(key_path, 0o600)
except Exception:
pass
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
return True
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.check_hostname = False
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
if custom_state_path:
return custom_state_path
config_dir = os.path.dirname(os.path.abspath(config_path))
return os.path.join(config_dir, STATE_FILE_NAME)
def load_state(state_path: str) -> dict:
if os.path.exists(state_path):
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
return {}
return {}
def save_state(state_path: str, state: dict):
try:
temp_path = f"{state_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
os.replace(temp_path, state_path)
except Exception as e:
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
def commit_state(state: dict, state_path: str):
if "new_last_cursor" in state:
val = state.pop("new_last_cursor")
if val:
state["last_cursor"] = val
if "new_last_timestamp_us" in state:
val = state.pop("new_last_timestamp_us")
if val:
state["last_timestamp_us"] = val
if "new_sent_cursors" in state:
state["sent_cursors"] = state.pop("new_sent_cursors")
save_state(state_path, state)
def load_config(config_path: str = CONFIG_FILE_NAME):
if not os.path.exists(config_path):
raise FileNotFoundError(
f"Client configuration file not found at: {config_path}\n"
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
)
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def get_machine_identifier() -> str:
"""
Returns the hostname of the machine sending the logs,
and appends the network/DNS domain if available.
"""
# 1. Try fully-qualified domain name (FQDN)
fqdn = socket.getfqdn()
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
return fqdn
hostname = socket.gethostname()
# 2. Check /etc/resolv.conf domain or search directive
try:
if os.path.exists("/etc/resolv.conf"):
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
domain = parts[1]
if domain and not domain.startswith("."):
return f"{hostname}.{domain}"
except Exception:
pass
# 3. Try reverse DNS lookup
try:
host_ip = socket.gethostbyname(hostname)
canonical_name = socket.gethostbyaddr(host_ip)[0]
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
return canonical_name
except Exception:
pass
return hostname
def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list: def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
""" """
Collects info, warnings, and errors from systemd journalctl over the lookback window. Collects info, warnings, and errors from systemd journalctl over the lookback window.
Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG (priority 7) and skips events older than lookback window (default: 24h). Edge Filtering: Retains INFO, WARNING, and ERROR. Strips DEBUG noise.
State Tracking: Skips events older than lookback window (default 24h) and events State Tracking: Skips events older than lookback window and already sent cursors.
that have already been sent in previous runs.
""" """
last_cursor = None collector = LinuxLogCollector(state=state)
last_timestamp_us = 0.0 return collector.collect_logs(hours=hours)
sent_cursors = set()
if state:
last_cursor = state.get("last_cursor")
try:
last_timestamp_us = float(state.get("last_timestamp_us", 0))
except (ValueError, TypeError):
last_timestamp_us = 0.0
sent_cursors = set(state.get("sent_cursors", []))
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
result = None
if last_cursor:
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
try:
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
if res.returncode == 0:
result = res
except FileNotFoundError:
print("[!] journalctl command not found. Ensure this script runs on a systemd-enabled Linux system.")
return []
except Exception:
pass
if result is None:
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
except FileNotFoundError:
print("[!] journalctl command not found. Ensure this script runs on a systemd-enabled Linux system.")
return []
except Exception as e:
print(f"[!] Error running journalctl: {e}")
return []
logs = []
machine_id = get_machine_identifier()
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
newest_cursor = None
newest_timestamp_us = last_timestamp_us
collected_cursors = []
for line in result.stdout.splitlines():
line_str = line.strip()
if not line_str:
continue
try:
entry = json.loads(line_str)
entry_cursor = entry.get("__CURSOR")
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
entry_ts_us = 0.0
if entry_ts_us_raw:
try:
entry_ts_us = float(entry_ts_us_raw)
except (ValueError, TypeError):
pass
# 1. Skip entries older than lookback window (default: 24h)
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
continue
# 2. Skip already sent events
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
continue
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
continue
# Advance newest tracking for new entries
if entry_cursor:
newest_cursor = entry_cursor
collected_cursors.append(entry_cursor)
if entry_ts_us > newest_timestamp_us:
newest_timestamp_us = entry_ts_us
priority = int(entry.get("PRIORITY", "6"))
# Priority 0: Emerg, 1: Alert, 2: Crit, 3: Err (-> ERROR)
# Priority 4: Warning, 5: Notice (-> WARNING)
# Priority 6: Info (-> INFO)
# Priority 7: Debug (skip)
if priority > 6:
continue
if priority <= 3:
sev = "ERROR"
elif priority in (4, 5):
sev = "WARNING"
else:
sev = "INFO"
logs.append({
"server": machine_id,
"os_type": "linux",
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
"severity": sev,
"message": entry.get("MESSAGE", "")[:2048]
})
except (json.JSONDecodeError, ValueError):
continue
if state is not None:
state["new_last_cursor"] = newest_cursor or last_cursor
state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
def send_encrypted_logs_over_socket(config: dict, logs: list): def send_encrypted_logs_over_socket(config: dict, logs: list):
""" """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
Streams logs to the LOGAR hub. return transport_send_encrypted_logs(config=config, logs=logs, os_type="linux")
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
or falls back to OpenPGP encrypted envelope over TCP.
"""
server_host = config["server_host"]
server_port = int(config["server_port"])
cert_dir = config.get("cert_dir", "certs")
enrollment_secret = config.get("enrollment_secret")
machine_id = get_machine_identifier()
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
hub_url = None
if enrollment_secret:
hermes_host = config.get("hermes_host", server_host)
if hermes_host in ("0.0.0.0", "::", None):
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux")
except Exception as e:
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
ca_path = os.path.join(cert_dir, "ca.crt") def ensure_enrolled(config: dict, machine_id: str) -> bool:
cert_path = os.path.join(cert_dir, "client.crt") """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
key_path = os.path.join(cert_dir, "client.key") return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="linux")
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
sock = None
try:
sock = get_tls_socket(server_host, server_port, cert_dir)
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
if enrollment_secret and hub_url:
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="linux", force_renew=True)
sock = get_tls_socket(server_host, server_port, cert_dir)
except Exception as retry_err:
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
raise
else:
raise
with sock:
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed mTLS connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
# Fallback to OpenPGP envelope over plain TCP socket
auth_token = config.get("auth_token", "")
pub_key_armored = config.get("server_public_key")
if not pub_key_armored:
raise ValueError("No server public key or mTLS certificates available for connection.")
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
if expected_fp and actual_fp != expected_fp:
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_json = json.dumps(payload)
pgp_msg = pgpy.PGPMessage.new(payload_json)
encrypted_msg = pub_key.encrypt(pgp_msg)
encrypted_armored = str(encrypted_msg)
envelope = {
"auth_token": auth_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
"encrypted_payload": encrypted_armored
}
envelope_bytes = json.dumps(envelope).encode("utf-8")
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(15.0)
sock.connect((server_host, server_port))
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
def main(): def main():
@@ -450,6 +75,7 @@ def main():
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)") parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for journalctl logs (default: 24)")
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)") parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window") parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)")
args = parser.parse_args() args = parser.parse_args()
try: try:
@@ -461,31 +87,52 @@ def main():
state_path = get_state_path(args.config, args.state_file) state_path = get_state_path(args.config, args.state_file)
state = None if args.no_state else load_state(state_path) state = None if args.no_state else load_state(state_path)
machine_id = get_machine_identifier() machine_id = config.get("client_id") or get_machine_identifier()
print(f"[*] Edge Forwarder Node: {machine_id}") print(f"[*] Edge Forwarder Node: {machine_id}")
if state and ("last_cursor" in state or "last_timestamp_us" in state): if state and ("last_cursor" in state or "last_timestamp_us" in state):
print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})") print(f"[*] State tracking active: resuming after previous cursor/timestamp (state file: {state_path})")
elif not args.no_state: elif not args.no_state:
print(f"[*] State tracking initialized (state file: {state_path})") print(f"[*] State tracking initialized (state file: {state_path})")
print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") # Proactively ensure dynamic PKI certificate enrollment with Server Hub
candidate_logs = get_recent_linux_logs(hours=args.hours, state=state) ensure_enrolled(config, machine_id)
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
if not candidate_logs: collector = LinuxLogCollector(state=state, machine_id=machine_id)
print("[*] No new unsent events to transmit.")
if state is not None:
commit_state(state, state_path)
return
try: def run_forwarder_cycle():
resp = send_encrypted_logs_over_socket(config, candidate_logs) # Continuously verify/renew PKI enrollment if server is licensed & online
if state is not None and resp and resp.get("status") == "success": ensure_enrolled(config, machine_id)
commit_state(state, state_path)
print(f"[+] State successfully committed to {state_path}") print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...")
except Exception as e: candidate_logs = collector.collect_logs(hours=args.hours)
print(f"[!] Failed to stream logs to server: {e}") print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
sys.exit(1)
if not candidate_logs:
print("[*] No new unsent events to transmit.")
if state is not None:
commit_state(state, state_path)
return
try:
resp = send_encrypted_logs_over_socket(config, candidate_logs)
if state is not None and resp and resp.get("status") == "success":
commit_state(state, state_path)
print(f"[+] State successfully committed to {state_path}")
except Exception as e:
print(f"[!] Failed to stream logs to server: {e}")
if not args.interval:
sys.exit(1)
if args.interval and args.interval > 0:
print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
while True:
try:
run_forwarder_cycle()
except Exception as e:
print(f"[!] Error in forwarder cycle: {e}")
time.sleep(args.interval)
else:
run_forwarder_cycle()
if __name__ == "__main__": if __name__ == "__main__":
+237 -62
View File
@@ -17,6 +17,14 @@ import threading
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
# Ensure script directory and repo root are in sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
# Suppress cryptography / pgpy deprecation notices for a clean terminal output # Suppress cryptography / pgpy deprecation notices for a clean terminal output
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
@@ -29,16 +37,37 @@ from pgpy.constants import (
CompressionAlgorithm CompressionAlgorithm
) )
import ssl import ssl
import subprocess
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, Request, Depends from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
import uvicorn import uvicorn
try:
import win32evtlog
except ImportError:
win32evtlog = None
try: try:
from src import server_enrollment as enrollment from src import server_enrollment as enrollment
except ImportError: except ImportError:
import server_enrollment as enrollment import server_enrollment as enrollment
try:
from src.log_collector import (
get_machine_identifier,
WindowsLogCollector,
LinuxLogCollector,
get_log_collector
)
except ImportError:
from log_collector import (
get_machine_identifier,
WindowsLogCollector,
LinuxLogCollector,
get_log_collector
)
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
@@ -768,11 +797,12 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in
conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')", conn.execute("INSERT INTO hermes_tokens (token, name, created_at, status) VALUES (?, 'Default Hermes Agent', ?, 'active')",
(initial_token, now_iso)) (initial_token, now_iso))
status = "active" if max_seats > 0 else "unlicensed"
if enrollment_secret: if enrollment_secret:
conn.execute(""" conn.execute("""
INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status) INSERT OR IGNORE INTO license_config (id, max_seats, enrollment_secret, license_status)
VALUES (1, ?, ?, 'unlicensed') VALUES (1, ?, ?, ?)
""", (max_seats, enrollment_secret)) """, (max_seats, enrollment_secret, status))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -900,6 +930,109 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
} }
def get_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Collects local Windows Application and System event logs."""
collector = WindowsLogCollector(state=state, channels=["Application", "System"])
return collector.collect_logs(hours=hours)
def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Collects local Linux journalctl / syslog events."""
collector = LinuxLogCollector(state=state)
return collector.collect_logs(hours=hours)
def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Platform-agnostic local host log collector."""
collector = get_log_collector(state=state)
return collector.collect_logs(hours=hours)
def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_runs: int = 1, state: Optional[dict] = None) -> int:
"""
Executes one cycle of local host log collection and direct ingestion into SQLite.
Does not require a license seat, certificate enrollment, or mTLS socket connection.
"""
machine_id = get_machine_identifier()
os_type = "windows" if sys.platform == "win32" else "linux"
now_sql = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# 1. Ensure local node is registered in clients table with LOCAL-HOST-BUILTIN fingerprint
try:
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("SELECT client_id FROM clients WHERE client_id = ?", (machine_id,))
if c.fetchone():
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, machine_id))
else:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql))
conn.commit()
conn.close()
except Exception as e:
print(f"[!] Warning: Failed to update local hub client record: {e}")
# 2. Collect local logs
logs = get_local_system_logs(hours=window_hours, state=state)
if not logs:
return 0
# 3. Direct ingestion without network overhead or seat checks
payload = {
"server": machine_id,
"site_name": machine_id.split(".", 1)[1] if "." in machine_id else "local-hub",
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
res = process_ingested_logs(payload, db_path=db_path, window_hours=window_hours, min_runs=min_runs)
print(f"[+] Integrated Local Collector: Processed {len(logs)} local host events ({res.get('promoted_verified', 0)} verified anomalies).")
return len(logs)
async def local_collector_loop(interval_seconds: int = 30):
"""
Background worker that periodically ingests local host logs without requiring a client license.
"""
config = SERVER_STATE.get("config", {})
db_path = config.get("db_path", DEFAULT_DB_FILE)
window_hours = config.get("evaluation_window_hours", 12)
min_runs = config.get("min_persistence_runs", 1)
state_file = resolve_writable_path("local_collector_state.json", "LOGAR-Server", "local_collector_state.json")
state = {}
if os.path.exists(state_file):
try:
with open(state_file, "r", encoding="utf-8") as f:
state = json.load(f)
except Exception:
state = {}
# Run initial cycle immediately
try:
run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state)
with open(state_file, "w", encoding="utf-8") as f:
json.dump(state, f)
except Exception as e:
print(f"[!] Error in initial local log collection cycle: {e}")
while True:
try:
await asyncio.sleep(interval_seconds)
run_local_log_collection_cycle(db_path, window_hours=window_hours, min_runs=min_runs, state=state)
with open(state_file, "w", encoding="utf-8") as f:
json.dump(state, f)
except asyncio.CancelledError:
break
except Exception as e:
print(f"[!] Exception in local_collector_loop: {e}")
def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext: def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext:
"""Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS).""" """Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS)."""
ca_file = os.path.join(cert_dir, "ca.crt") ca_file = os.path.join(cert_dir, "ca.crt")
@@ -909,6 +1042,12 @@ def init_mtls_server_context(cert_dir: str = "certs") -> ssl.SSLContext:
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
ctx.load_verify_locations(cafile=ca_file) ctx.load_verify_locations(cafile=ca_file)
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_file):
try:
ctx.load_verify_locations(cafile=upstream_file)
except Exception:
pass
ctx.verify_mode = ssl.CERT_REQUIRED ctx.verify_mode = ssl.CERT_REQUIRED
ctx.minimum_version = ssl.TLSVersion.TLSv1_3 ctx.minimum_version = ssl.TLSVersion.TLSv1_3
return ctx return ctx
@@ -925,6 +1064,12 @@ def reload_mtls_context(ssl_ctx: ssl.SSLContext, cert_dir: str = "certs"):
ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key) ssl_ctx.load_cert_chain(certfile=srv_cert, keyfile=srv_key)
ssl_ctx.load_verify_locations(cafile=ca_file) ssl_ctx.load_verify_locations(cafile=ca_file)
upstream_file = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_file):
try:
ssl_ctx.load_verify_locations(cafile=upstream_file)
except Exception:
pass
def check_and_rotate_server_certs( def check_and_rotate_server_certs(
@@ -1022,7 +1167,7 @@ def report_seat_quota_to_authority_sync():
if os.path.exists(db_path): if os.path.exists(db_path):
try: try:
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'").fetchone() row = conn.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone()
if row: if row:
active_seats = row[0] active_seats = row[0]
conn.close() conn.close()
@@ -1167,7 +1312,7 @@ def login_page(request: Request):
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.login-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 36px; width: 100%; max-width: 420px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); } .login-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 36px; width: 100%; max-width: 420px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); }
.logo-header { text-align: center; margin-bottom: 24px; } .logo-header { text-align: center; margin-bottom: 24px; }
.logo-icon { width: 48px; height: 48px; background: linear-gradient(135deg, #3b82f6, #8b5cf6); border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; color: #fff; margin-bottom: 12px; } .logo-icon { width: 56px; height: 56px; background: transparent; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 12px; }
h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; } h1 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: var(--text-muted); } .subtitle { font-size: 13px; color: var(--text-muted); }
.form-group { margin-bottom: 18px; text-align: left; } .form-group { margin-bottom: 18px; text-align: left; }
@@ -1182,7 +1327,15 @@ def login_page(request: Request):
<body> <body>
<div class="login-card"> <div class="login-card">
<div class="logo-header"> <div class="logo-header">
<div class="logo-icon">🛰️</div> <div class="logo-icon">
<svg width="28" height="28" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139" fill="#0E1A2E" stroke="#00E5FF" stroke-width="24"/>
<rect x="140" y="140" width="46" height="232" rx="10" fill="#00E5FF"/>
<rect x="140" y="326" width="220" height="46" rx="10" fill="#00E5FF"/>
<rect x="220" y="210" width="36" height="80" rx="8" fill="#00FFA3"/>
<rect x="286" y="150" width="36" height="140" rx="8" fill="#00E5FF"/>
</svg>
</div>
<h1>LOGAR Server Hub</h1> <h1>LOGAR Server Hub</h1>
<div class="subtitle">Fleet Ingestion & Centralized Provisioning</div> <div class="subtitle">Fleet Ingestion & Centralized Provisioning</div>
</div> </div>
@@ -1325,8 +1478,8 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
raise HTTPException(status_code=500, detail="License configuration not initialized") raise HTTPException(status_code=500, detail="License configuration not initialized")
expected_secret, max_seats = row[0], row[1] expected_secret, max_seats = row[0], row[1]
license_status = row[2] if len(row) > 2 and row[2] else "unlicensed" license_status = row[2] if len(row) > 2 and row[2] else ("active" if max_seats > 0 else "unlicensed")
if max_seats <= 0 or license_status != "active": if max_seats <= 0 or license_status in ("unlicensed", "revoked", "expired"):
conn.close() conn.close()
raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.") raise HTTPException(status_code=403, detail="Server is unlicensed. Import an activated .lic file via the WebUI before enrolling edge clients.")
@@ -1346,6 +1499,18 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
conn.close() conn.close()
raise HTTPException(status_code=500, detail="Root CA not loaded on server") raise HTTPException(status_code=500, detail="Root CA not loaded on server")
# Ensure full trust chain (subordinate CA + upstream root CA if present) is delivered to edge clients
cert_dir = SERVER_STATE.get("config", {}).get("cert_dir", "certs")
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_ca_path):
try:
with open(upstream_ca_path, "r", encoding="utf-8") as f:
upstream_pem = f.read().strip()
if upstream_pem and upstream_pem not in ca_cert_pem:
ca_cert_pem = ca_cert_pem.strip() + "\n" + upstream_pem
except Exception:
pass
# 2. Check if client_id already registered # 2. Check if client_id already registered
c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,)) c.execute("SELECT status FROM clients WHERE client_id = ?", (req.client_id,))
client_row = c.fetchone() client_row = c.fetchone()
@@ -1373,7 +1538,7 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
} }
# 3. New client: check seat limits # 3. New client: check seat limits
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'") c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
active_count = c.fetchone()[0] active_count = c.fetchone()[0]
if active_count >= max_seats: if active_count >= max_seats:
conn.close() conn.close()
@@ -1458,7 +1623,7 @@ def list_clients():
} }
for r in rows for r in rows
] ]
active_count = sum(1 for cl in clients if cl["status"] == "active") active_count = sum(1 for cl in clients if cl["status"] == "active" and cl.get("cert_fingerprint") != "LOCAL-HOST-BUILTIN")
return { return {
"active_seats": active_count, "active_seats": active_count,
"max_seats": max_seats, "max_seats": max_seats,
@@ -1477,7 +1642,7 @@ def approve_client(action: ClientActionRequest):
row = c.fetchone() row = c.fetchone()
max_seats = row[0] if row else 10 max_seats = row[0] if row else 10
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'") c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
active_count = c.fetchone()[0] active_count = c.fetchone()[0]
if active_count >= max_seats: if active_count >= max_seats:
conn.close() conn.close()
@@ -1502,10 +1667,12 @@ reactivate_client = approve_client
@app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)]) @app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)])
def revoke_client(action: ClientActionRequest): def revoke_client(action: ClientActionRequest):
"""Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes.""" """Revokes a client certificate/license seat. Revoked clients are immediately rejected during mTLS handshakes."""
if action.client_id == get_machine_identifier():
raise HTTPException(status_code=400, detail="Cannot revoke integrated local hub collector")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
c = conn.cursor() c = conn.cursor()
c.execute("UPDATE clients SET status = 'revoked' WHERE client_id = ?", (action.client_id,)) c.execute("UPDATE clients SET status = 'revoked' WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,))
affected = c.rowcount affected = c.rowcount
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1520,10 +1687,12 @@ def revoke_client(action: ClientActionRequest):
@app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)]) @app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)])
def delete_client(action: ClientActionRequest): def delete_client(action: ClientActionRequest):
"""Permanently removes a client forwarder record from SQLite, immediately freeing its seat.""" """Permanently removes a client forwarder record from SQLite, immediately freeing its seat."""
if action.client_id == get_machine_identifier():
raise HTTPException(status_code=400, detail="Cannot remove integrated local hub collector")
db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
c = conn.cursor() c = conn.cursor()
c.execute("DELETE FROM clients WHERE client_id = ?", (action.client_id,)) c.execute("DELETE FROM clients WHERE client_id = ? AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'", (action.client_id,))
affected = c.rowcount affected = c.rowcount
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1665,9 +1834,9 @@ def get_license_summary() -> Dict[str, Any]:
max_seats = row[0] max_seats = row[0]
if len(row) > 1 and row[1]: if len(row) > 1 and row[1]:
license_status_db = row[1] license_status_db = row[1]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'") c.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
active_seats = c.fetchone()[0] active_seats = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked'") c.execute("SELECT COUNT(*) FROM clients WHERE status = 'revoked' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
revoked_seats = c.fetchone()[0] revoked_seats = c.fetchone()[0]
conn.close() conn.close()
except Exception: except Exception:
@@ -1694,10 +1863,14 @@ def get_license_summary() -> Dict[str, Any]:
if info.get("is_expired"): if info.get("is_expired"):
status_label = "EXPIRED" status_label = "EXPIRED"
elif max_seats <= 0 or license_status_db != "active" or info.get("is_self_signed") or not info.get("seats"): elif info.get("seats") and not info.get("is_self_signed"):
status_label = "UNLICENSED"
else:
status_label = "ACTIVE" status_label = "ACTIVE"
elif max_seats > 0 and license_status_db == "active":
status_label = "ACTIVE"
elif info.get("is_self_signed"):
status_label = "SELF-SIGNED" if max_seats > 0 else "UNLICENSED"
else:
status_label = "UNLICENSED" if max_seats <= 0 else "ACTIVE"
pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0 pct = int((active_seats / max_seats * 100)) if max_seats > 0 else 0
return { return {
@@ -1761,14 +1934,17 @@ async def import_signed_license(req: Request):
except Exception: except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") raise HTTPException(status_code=400, detail="Invalid JSON body")
raw_input = body.get("ca_cert") or body.get("lic") or body.get("raw") or body.get("license") or body.get("license_file") if isinstance(body, dict):
if not raw_input: if "master_root_ca" in body or "upstream_ca" in body:
raise HTTPException(status_code=400, detail="Missing 'lic' or 'ca_cert' payload in request") raw_text = json.dumps(body)
elif "lic" in body:
if isinstance(raw_input, dict): raw_text = json.dumps(body["lic"]) if isinstance(body["lic"], dict) else str(body["lic"])
raw_text = json.dumps(raw_input) elif "ca_cert" in body and not body.get("master_root_ca"):
raw_text = str(body["ca_cert"])
else:
raw_text = json.dumps(body)
else: else:
raw_text = str(raw_input) raw_text = str(body)
config = SERVER_STATE.get("config", {}) config = SERVER_STATE.get("config", {})
cert_dir = config.get("cert_dir", "certs") cert_dir = config.get("cert_dir", "certs")
@@ -1848,7 +2024,7 @@ async def trigger_license_renewal(request: Request):
try: try:
conn_cnt = sqlite3.connect(db_path) conn_cnt = sqlite3.connect(db_path)
c_cnt = conn_cnt.cursor() c_cnt = conn_cnt.cursor()
c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'") c_cnt.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'")
row = c_cnt.fetchone() row = c_cnt.fetchone()
if row: if row:
active_seats = row[0] active_seats = row[0]
@@ -1949,12 +2125,20 @@ def serve_webui(request: Request = None):
rel_time = format_relative_time(last) rel_time = format_relative_time(last)
last_display = f"<div><strong>{html.escape(str(last[:19]))}</strong></div><div style='font-size:11px; color:#94a3b8;'>{html.escape(rel_time)}</div>" last_display = f"<div><strong>{html.escape(str(last[:19]))}</strong></div><div style='font-size:11px; color:#94a3b8;'>{html.escape(rel_time)}</div>"
btn_action = ( if fp == "LOCAL-HOST-BUILTIN":
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>' esc_st = "LOCAL HUB HOST"
if st == "active" else badge_cls = "badge-active"
f'<button class="btn btn-accent" style="padding:4px 8px; font-size:11px;" onclick="approveClient(\'{esc_cid}\')">Approve</button>' fp_display = '<span style="font-size:11px; color:#38bdf8; font-weight:600;">⚡ Built-in Log Collector</span>'
) btn_action = '<span style="font-size:11px; color:#94a3b8;">Integrated (0 Seats)</span>'
btn_del = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteClient(\'{esc_cid}\')" title="Remove client and free seat">Remove</button>' btn_del = ''
else:
fp_display = f"<code title='{esc_fp}' style='cursor:pointer;' onclick=\"copyText('{esc_fp}', 'Cert Fingerprint')\">{esc_fp[:16]}...</code>"
btn_action = (
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>'
if st == "active" else
f'<button class="btn btn-accent" style="padding:4px 8px; font-size:11px;" onclick="approveClient(\'{esc_cid}\')">Approve</button>'
)
btn_del = f'<button class="btn btn-danger" style="padding:4px 8px; font-size:11px;" onclick="deleteClient(\'{esc_cid}\')" title="Remove client and free seat">Remove</button>'
client_rows.append(f""" client_rows.append(f"""
<tr> <tr>
@@ -1962,7 +2146,7 @@ def serve_webui(request: Request = None):
<td><strong>{esc_host}</strong></td> <td><strong>{esc_host}</strong></td>
<td><code>{esc_cid}</code></td> <td><code>{esc_cid}</code></td>
<td><span class="badge badge-os">{esc_ost.upper()}</span></td> <td><span class="badge badge-os">{esc_ost.upper()}</span></td>
<td><code title="{esc_fp}" style="cursor:pointer;" onclick="copyText('{esc_fp}', 'Cert Fingerprint')">{esc_fp[:16]}...</code></td> <td>{fp_display}</td>
<td>{last_display}</td> <td>{last_display}</td>
<td> <td>
<div style="display:flex; gap:6px; align-items:center;"> <div style="display:flex; gap:6px; align-items:center;">
@@ -2042,7 +2226,6 @@ def serve_webui(request: Request = None):
</div> </div>
</div> </div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> <div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button onclick="downloadLicRequest()" class="btn btn-secondary" style="font-size: 12px; padding: 7px 14px;">📤 Export License Request</button>
<button onclick="openImportLicModal()" class="btn btn-accent" style="font-size: 12px; padding: 7px 14px;">📥 Import License (.lic)</button> <button onclick="openImportLicModal()" class="btn btn-accent" style="font-size: 12px; padding: 7px 14px;">📥 Import License (.lic)</button>
</div> </div>
</div> </div>
@@ -2095,11 +2278,8 @@ def serve_webui(request: Request = None):
.logo-group {{ display: flex; align-items: center; gap: 14px; }} .logo-group {{ display: flex; align-items: center; gap: 14px; }}
.logo-icon {{ .logo-icon {{
width: 44px; height: 44px; width: 44px; height: 44px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6); background: transparent;
border-radius: 12px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 800; color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
}} }}
h1 {{ font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }} h1 {{ font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }}
.subtitle {{ font-size: 13px; color: var(--text-muted); margin-top: 2px; }} .subtitle {{ font-size: 13px; color: var(--text-muted); margin-top: 2px; }}
@@ -2243,10 +2423,18 @@ def serve_webui(request: Request = None):
<div class="container"> <div class="container">
<header> <header>
<div class="logo-group"> <div class="logo-group">
<div class="logo-icon">🛰️</div> <div class="logo-icon">
<svg width="26" height="26" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<polygon points="256,24 456,139 456,373 256,488 56,373 56,139" fill="#0E1A2E" stroke="#00E5FF" stroke-width="24"/>
<rect x="140" y="140" width="46" height="232" rx="10" fill="#00E5FF"/>
<rect x="140" y="326" width="220" height="46" rx="10" fill="#00E5FF"/>
<rect x="220" y="210" width="36" height="80" rx="8" fill="#00FFA3"/>
<rect x="286" y="150" width="36" height="140" rx="8" fill="#00E5FF"/>
</svg>
</div>
<div> <div>
<h1>LOGAR Fleet Hub</h1> <h1>LOGAR Fleet Hub</h1>
<div class="subtitle">Fleet Log Aggregation & Centralized AI Provisioning</div> <div class="subtitle">Fleet Log Aggregation & Centralized Provisioning</div>
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
@@ -2421,8 +2609,6 @@ def serve_webui(request: Request = None):
<div style="font-weight: 600; font-size: 13px; color: var(--text-muted);">PKI & Seat Synchronization:</div> <div style="font-weight: 600; font-size: 13px; color: var(--text-muted);">PKI & Seat Synchronization:</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;"> <div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="btn btn-accent" onclick="openImportLicModal()">📥 Import License (.lic)</button> <button class="btn btn-accent" onclick="openImportLicModal()">📥 Import License (.lic)</button>
<button class="btn btn-secondary" onclick="downloadLicRequest()">📤 Export License Request (.lic)</button>
<button class="btn btn-outline" onclick="copyCaCsr()">📋 Copy CA CSR</button>
<button class="btn btn-secondary" onclick="triggerMtlsRenewal()">🔄 Sync & Renew via mTLS</button> <button class="btn btn-secondary" onclick="triggerMtlsRenewal()">🔄 Sync & Renew via mTLS</button>
</div> </div>
</div> </div>
@@ -2580,21 +2766,6 @@ def serve_webui(request: Request = None):
window.location.href = '/api/license/export-lic'; window.location.href = '/api/license/export-lic';
}} }}
async function copyCaCsr() {{
try {{
const res = await fetch('/api/license/csr');
if (res.ok) {{
const data = await res.json();
navigator.clipboard.writeText(data.csr);
showAlert('CA CSR copied to clipboard!');
}} else {{
alert('Failed to fetch CA CSR');
}}
}} catch (err) {{
alert('Error fetching CA CSR: ' + err.message);
}}
}}
function handleLicFile(event) {{ function handleLicFile(event) {{
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) return;
@@ -2814,19 +2985,23 @@ async def run_server():
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll") print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
watchdog_task = asyncio.create_task(cert_validity_watchdog()) watchdog_task = asyncio.create_task(cert_validity_watchdog())
collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30))
try: try:
await asyncio.gather( await asyncio.gather(
tcp_server.serve_forever(), tcp_server.serve_forever(),
uv_server.serve(), uv_server.serve(),
watchdog_task watchdog_task,
collector_task
) )
finally: finally:
watchdog_task.cancel() watchdog_task.cancel()
try: collector_task.cancel()
await watchdog_task for t in [watchdog_task, collector_task]:
except asyncio.CancelledError: try:
pass await t
except asyncio.CancelledError:
pass
def main(): def main():
@@ -2913,7 +3088,7 @@ def main():
if os.path.exists(config.get("db_path", "server_logs.db")): if os.path.exists(config.get("db_path", "server_logs.db")):
try: try:
conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db")) conn_tmp = sqlite3.connect(config.get("db_path", "server_logs.db"))
row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active'").fetchone() row = conn_tmp.execute("SELECT COUNT(*) FROM clients WHERE status = 'active' AND cert_fingerprint != 'LOCAL-HOST-BUILTIN'").fetchone()
if row: if row:
active_seats = row[0] active_seats = row[0]
conn_tmp.close() conn_tmp.close()
+144 -540
View File
@@ -1,540 +1,144 @@
import os """
import sys LOGAR Windows Edge Log Forwarder
import json Collects Windows Event Logs (Application/System) and securely streams them
import socket to the central LOGAR Hub via mutual TLS (mTLS 1.3) or OpenPGP envelope.
import ssl """
import struct
import argparse import os
import urllib.request import sys
import warnings import time
from datetime import datetime, timezone, timedelta import argparse
from typing import Optional import warnings
from typing import Optional
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore") # Ensure script directory and repo root are in sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
import pgpy if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
try: REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
import win32evtlog if REPO_ROOT not in sys.path:
except ImportError: sys.path.insert(0, REPO_ROOT)
win32evtlog = None
# Suppress cryptography / pgpy deprecation notices
CONFIG_FILE_NAME = "client_config.json" warnings.filterwarnings("ignore")
STATE_FILE_NAME = "client_state.json"
import pgpy
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool: try:
"""Checks if client certificate at cert_path is expiring within threshold_days.""" import win32evtlog
if not os.path.exists(cert_path): except ImportError:
return True win32evtlog = None
try:
from cryptography import x509 from log_collector import (
with open(cert_path, "r", encoding="utf-8") as f: get_machine_identifier,
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8")) is_path_writable,
expiry = getattr(cert, "not_valid_after_utc", None) get_default_data_dir,
if expiry is None: resolve_writable_path,
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc) get_state_path,
now = datetime.now(timezone.utc) load_state,
return expiry <= (now + timedelta(days=threshold_days)) save_state,
except Exception: commit_state,
return True WindowsLogCollector,
BaseLogCollector
)
def enroll_client_if_needed( from client_transport import (
hub_url: str, is_cert_expiring_soon,
enrollment_secret: str, enroll_client_if_needed,
cert_dir: str, get_tls_socket,
client_id: str, load_config,
hostname: str, ensure_enrolled as transport_ensure_enrolled,
os_type: str = "windows", send_encrypted_logs_over_socket as transport_send_encrypted_logs
force_renew: bool = False, )
threshold_days: int = 30
): CONFIG_FILE_NAME = "client_config.json"
"""Bootstraps client enrollment if certificates are missing or expiring soon.""" STATE_FILE_NAME = "client_state.json"
os.makedirs(cert_dir, exist_ok=True)
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt") def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
key_path = os.path.join(cert_dir, "client.key") """
Scans the Windows Application Event Log backwards for events within the window.
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path): Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days): State Tracking: Skips events older than lookback window and already sent records.
return True """
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...") collector = WindowsLogCollector(state=state, channels=["Application"])
return collector.collect_logs(hours=hours)
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll" def send_encrypted_logs_over_socket(config: dict, logs: list):
payload = { """Streams logs to the LOGAR hub over mTLS or encrypted TCP envelope."""
"client_id": client_id, return transport_send_encrypted_logs(config=config, logs=logs, os_type="windows")
"hostname": hostname,
"os": os_type,
"enrollment_secret": enrollment_secret def ensure_enrolled(config: dict, machine_id: str) -> bool:
} """Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
req = urllib.request.Request( return transport_ensure_enrolled(config=config, machine_id=machine_id, os_type="windows")
enroll_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"} def main():
) parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
with urllib.request.urlopen(req, timeout=10) as resp: parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
if resp.status != 200: parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
raise RuntimeError(f"Enrollment failed with status code {resp.status}") parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
data = json.loads(resp.read().decode("utf-8")) parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
parser.add_argument("--interval", type=int, default=0, help="Continuous polling interval in seconds (0 = run once and exit, >0 = service loop)")
with open(ca_path, "w", encoding="utf-8") as f: args = parser.parse_args()
f.write(data["ca_cert"])
with open(cert_path, "w", encoding="utf-8") as f: try:
f.write(data["client_cert"]) config = load_config(args.config)
with open(key_path, "w", encoding="utf-8") as f: except Exception as e:
f.write(data["client_key"]) print(f"[!] Configuration error: {e}")
sys.exit(1)
try:
os.chmod(key_path, 0o600) state_path = get_state_path(args.config, args.state_file)
except Exception: state = None if args.no_state else load_state(state_path)
pass
machine_id = config.get("client_id") or get_machine_identifier()
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}") print(f"[*] Edge Forwarder Node: {machine_id}")
return True if state and "last_record_number" in state:
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
elif not args.no_state:
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str): print(f"[*] State tracking initialized (state file: {state_path})")
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
ca_path = os.path.join(cert_dir, "ca.crt") # Proactively ensure dynamic PKI certificate enrollment with Server Hub
cert_path = os.path.join(cert_dir, "client.crt") ensure_enrolled(config, machine_id)
key_path = os.path.join(cert_dir, "client.key")
collector = WindowsLogCollector(state=state, machine_id=machine_id, channels=["Application"])
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.") def run_forwarder_cycle():
# Continuously verify/renew PKI enrollment if server is licensed & online
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path) ensure_enrolled(config, machine_id)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3 print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
ctx.check_hostname = False candidate_logs = collector.collect_logs(hours=args.hours)
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
return ctx.wrap_socket(raw_sock, server_hostname=hub_host) if not candidate_logs:
print("[*] No new unsent events to transmit.")
if state is not None:
def is_path_writable(filepath: str) -> bool: commit_state(state, state_path)
"""Checks if a file or directory path is actually writable by the current process.""" return
try:
if os.path.exists(filepath): try:
if os.path.isdir(filepath): resp = send_encrypted_logs_over_socket(config, candidate_logs)
test_f = os.path.join(filepath, f".perm_{os.getpid()}") if state is not None and resp and resp.get("status") == "success":
with open(test_f, "w") as f: commit_state(state, state_path)
f.write("1") print(f"[+] State successfully committed to {state_path}")
os.remove(test_f) except Exception as e:
return True print(f"[!] Failed to stream logs to server: {e}")
else: if not args.interval:
with open(filepath, "a") as f: sys.exit(1)
pass
return True if args.interval and args.interval > 0:
else: print(f"[*] Starting continuous forwarder service loop (Interval: {args.interval}s)...")
parent = os.path.dirname(os.path.abspath(filepath)) or "." while True:
os.makedirs(parent, exist_ok=True) try:
test_f = os.path.join(parent, f".perm_{os.getpid()}") run_forwarder_cycle()
with open(test_f, "w") as f: except Exception as e:
f.write("1") print(f"[!] Error in forwarder cycle: {e}")
os.remove(test_f) time.sleep(args.interval)
return True else:
except Exception: run_forwarder_cycle()
return False
if __name__ == "__main__":
def get_default_data_dir(app_name: str = "LOGAR") -> str: main()
"""Returns a suitable writable application data directory."""
if sys.platform == "win32":
# 1. ProgramData (C:\ProgramData\LOGAR)
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
target_prog = os.path.join(prog_data, app_name)
if is_path_writable(target_prog):
return target_prog
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
target_local = os.path.join(local_app, app_name)
if is_path_writable(target_local):
return target_local
# 3. Temp directory (%TEMP%\LOGAR)
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
if is_path_writable(temp_dir):
return temp_dir
cwd = os.getcwd()
if is_path_writable(cwd):
return cwd
return os.path.expanduser("~")
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
if not path:
path = default_filename
if os.path.isabs(path) and is_path_writable(path):
return path
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
return os.path.abspath(path)
data_dir = get_default_data_dir(app_name)
fname = os.path.basename(path)
return os.path.join(data_dir, fname)
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
if custom_state_path:
return custom_state_path
config_dir = os.path.dirname(os.path.abspath(config_path))
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
return os.path.join(config_dir, STATE_FILE_NAME)
def load_state(state_path: str) -> dict:
if os.path.exists(state_path):
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
return {}
return {}
def save_state(state_path: str, state: dict):
try:
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
temp_path = f"{state_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
os.replace(temp_path, state_path)
except Exception as e:
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
def commit_state(state: dict, state_path: str):
if "new_last_record_number" in state:
val = state.pop("new_last_record_number")
if val:
state["last_record_number"] = val
if "new_sent_record_ids" in state:
state["sent_record_ids"] = state.pop("new_sent_record_ids")
save_state(state_path, state)
def load_config(config_path: str = CONFIG_FILE_NAME):
actual_config_path = config_path
if not os.path.exists(actual_config_path):
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
if os.path.exists(data_dir_cfg):
actual_config_path = data_dir_cfg
if not os.path.exists(actual_config_path):
raise FileNotFoundError(
f"Client configuration file not found at: {config_path}\n"
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
)
with open(actual_config_path, "r", encoding="utf-8") as f:
config = json.load(f)
if "cert_dir" in config:
config["cert_dir"] = resolve_writable_path(config["cert_dir"], "LOGAR", "certs")
return config
def get_machine_identifier() -> str:
"""
Returns the hostname of the machine sending the logs,
and appends the network/DNS domain if available.
"""
fqdn = socket.getfqdn()
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
return fqdn
hostname = socket.gethostname()
user_dns_domain = os.environ.get("USERDNSDOMAIN")
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
return f"{hostname}.{user_dns_domain.lower()}"
try:
host_ip = socket.gethostbyname(hostname)
canonical_name = socket.gethostbyaddr(host_ip)[0]
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
return canonical_name
except Exception:
pass
return hostname
def get_recent_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""
Scans the Windows Application Event Log backwards for events within the window.
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
State Tracking: Skips events older than lookback window (default 24h) and events
that have already been sent in previous runs.
"""
if win32evtlog is None:
print("[!] pywin32 is not installed or not running on Windows. Returning mock/empty candidate list.")
return []
server = "localhost"
log_type = "Application"
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
try:
hand = win32evtlog.OpenEventLog(server, log_type)
except Exception as e:
print(f"[!] Error opening Windows event log: {e}")
return []
logs = []
cutoff_time = datetime.now() - timedelta(hours=hours)
machine_id = get_machine_identifier()
last_record_number = 0
sent_record_ids = set()
if state:
last_record_number = int(state.get("last_record_number", 0))
sent_record_ids = set(state.get("sent_record_ids", []))
# Windows Event Log EventTypes:
# 1: EVENTLOG_ERROR_TYPE -> ERROR
# 2: EVENTLOG_WARNING_TYPE -> WARNING
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
# Excludes: 8 (Audit Success), 16 (Audit Failure), and other verbose noise
sev_map = {
1: "ERROR",
2: "WARNING",
4: "INFO"
}
newest_record_number = 0
collected_record_ids = []
while True:
events = win32evtlog.ReadEventLog(hand, flags, 0)
if not events:
break
for event in events:
rec_num = int(event.RecordNumber)
if newest_record_number == 0:
newest_record_number = rec_num
# 1. Skip entries older than lookback window (default: 24h)
if event.TimeGenerated < cutoff_time:
break
# 2. Skip already sent events if we've reached records <= last_record_number
# (unless the log was cleared and numbers wrapped, i.e. newest_record_number < last_record_number)
if last_record_number > 0 and newest_record_number >= last_record_number:
if rec_num <= last_record_number:
break
rec_id = f"{rec_num}:{event.TimeGenerated.isoformat()}"
if rec_id in sent_record_ids:
continue
# Filter: upload everything from INFO to ERROR only
if event.EventType in sev_map:
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
logs.append({
"server": machine_id,
"os_type": "windows",
"signature": event.SourceName or "Windows-Event",
"severity": sev_map[event.EventType],
"message": msg[:2048] # Limit message length
})
collected_record_ids.append(rec_id)
if events[-1].TimeGenerated < cutoff_time:
break
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
break
win32evtlog.CloseEventLog(hand)
if state is not None:
target_rec = max(newest_record_number, last_record_number)
state["new_last_record_number"] = target_rec
state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
def send_encrypted_logs_over_socket(config: dict, logs: list):
"""
Streams logs to the LOGAR hub.
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
or falls back to OpenPGP encrypted envelope over TCP.
"""
server_host = config["server_host"]
server_port = int(config["server_port"])
cert_dir = config.get("cert_dir", "certs")
enrollment_secret = config.get("enrollment_secret")
machine_id = get_machine_identifier()
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
hub_url = None
if enrollment_secret:
hermes_host = config.get("hermes_host", server_host)
if hermes_host in ("0.0.0.0", "::", None):
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows")
except Exception as e:
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
sock = None
try:
sock = get_tls_socket(server_host, server_port, cert_dir)
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
if enrollment_secret and hub_url:
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type="windows", force_renew=True)
sock = get_tls_socket(server_host, server_port, cert_dir)
except Exception as retry_err:
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
raise
else:
raise
with sock:
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed mTLS connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
# Fallback to OpenPGP envelope over plain TCP socket
auth_token = config.get("auth_token", "")
pub_key_armored = config.get("server_public_key")
if not pub_key_armored:
raise ValueError("No server public key or mTLS certificates available for connection.")
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
if expected_fp and actual_fp != expected_fp:
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
payload = {
"server": machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_json = json.dumps(payload)
pgp_msg = pgpy.PGPMessage.new(payload_json)
encrypted_msg = pub_key.encrypt(pgp_msg)
encrypted_armored = str(encrypted_msg)
envelope = {
"auth_token": auth_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
"encrypted_payload": encrypted_armored
}
envelope_bytes = json.dumps(envelope).encode("utf-8")
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(15.0)
sock.connect((server_host, server_port))
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
def main():
parser = argparse.ArgumentParser(description="LOGAR Windows Edge Log Forwarder with State Tracking")
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
parser.add_argument("--hours", type=int, default=24, help="Lookback window in hours for event logs (default: 24)")
parser.add_argument("--state-file", default=None, help="Path to state tracking file (default: client_state.json next to config)")
parser.add_argument("--no-state", action="store_true", help="Disable state tracking and send all events matching lookback window")
args = parser.parse_args()
try:
config = load_config(args.config)
except Exception as e:
print(f"[!] Configuration error: {e}")
sys.exit(1)
state_path = get_state_path(args.config, args.state_file)
state = None if args.no_state else load_state(state_path)
machine_id = get_machine_identifier()
print(f"[*] Edge Forwarder Node: {machine_id}")
if state and "last_record_number" in state:
print(f"[*] State tracking active: resuming from record #{state['last_record_number']} (state file: {state_path})")
elif not args.no_state:
print(f"[*] State tracking initialized (state file: {state_path})")
print(f"[*] Scanning Windows Application event log for unsent entries (last {args.hours} hours)...")
candidate_logs = get_recent_windows_logs(hours=args.hours, state=state)
print(f"[*] Found {len(candidate_logs)} unsent candidate entries (INFO to ERROR, entries > {args.hours}h and already-sent skipped).")
if not candidate_logs:
print("[*] No new unsent events to transmit.")
if state is not None:
commit_state(state, state_path)
return
try:
resp = send_encrypted_logs_over_socket(config, candidate_logs)
if state is not None and resp and resp.get("status") == "success":
commit_state(state, state_path)
print(f"[+] State successfully committed to {state_path}")
except Exception as e:
print(f"[!] Failed to stream logs to server: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+322
View File
@@ -0,0 +1,322 @@
"""
LOGAR - Unified Client Transport & PKI Enrollment Module
Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing,
and dynamic certificate enrollment with the LOGAR Server Hub.
"""
import os
import sys
import json
import socket
import ssl
import struct
import urllib.request
import warnings
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict, Any
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore")
import pgpy
from log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path
)
CONFIG_FILE_NAME = "client_config.json"
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
"""Checks if client certificate at cert_path is expiring within threshold_days."""
if not os.path.exists(cert_path):
return True
try:
from cryptography import x509
with open(cert_path, "r", encoding="utf-8") as f:
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
expiry = getattr(cert, "not_valid_after_utc", None)
if expiry is None:
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
return expiry <= (now + timedelta(days=threshold_days))
except Exception:
return True
def enroll_client_if_needed(
hub_url: str,
enrollment_secret: str,
cert_dir: str,
client_id: str,
hostname: str,
os_type: str = "windows",
force_renew: bool = False,
threshold_days: int = 30
) -> bool:
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
os.makedirs(cert_dir, exist_ok=True)
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
return True
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
print(f"[*] Bootstrapping client {action_name} with LOGAR Hub at {hub_url}...")
enroll_endpoint = f"{hub_url.rstrip('/')}/api/client/enroll"
payload = {
"client_id": client_id,
"hostname": hostname,
"os": os_type,
"enrollment_secret": enrollment_secret
}
req = urllib.request.Request(
enroll_endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status != 200:
raise RuntimeError(f"Enrollment failed with status code {resp.status}")
data = json.loads(resp.read().decode("utf-8"))
with open(ca_path, "w", encoding="utf-8") as f:
f.write(data["ca_cert"])
with open(cert_path, "w", encoding="utf-8") as f:
f.write(data["client_cert"])
with open(key_path, "w", encoding="utf-8") as f:
f.write(data["client_key"])
try:
os.chmod(key_path, 0o600)
except Exception:
pass
print(f"[+] Client certificates updated successfully in {os.path.abspath(cert_dir)}")
return True
def get_tls_socket(hub_host: str, hub_port: int, cert_dir: str):
"""Establishes an mTLS connection with the LOGAR hub using client certificates."""
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
if not (os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)):
raise FileNotFoundError(f"mTLS certificates not found in '{cert_dir}'. Enroll client first.")
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca_path)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.check_hostname = False
raw_sock = socket.create_connection((hub_host, hub_port), timeout=15)
return ctx.wrap_socket(raw_sock, server_hostname=hub_host)
def load_config(config_path: str = CONFIG_FILE_NAME) -> dict:
"""Loads and normalizes client configuration JSON."""
actual_config_path = config_path
if not os.path.exists(actual_config_path):
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
if os.path.exists(data_dir_cfg):
actual_config_path = data_dir_cfg
if not os.path.exists(actual_config_path):
raise FileNotFoundError(
f"Client configuration file not found at: {config_path}\n"
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
)
with open(actual_config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# Resolve cert_dir to a writable directory
cfg_dir = os.path.dirname(os.path.abspath(actual_config_path))
raw_cert_dir = config.get("cert_dir", "certs")
if os.path.isabs(raw_cert_dir) and is_path_writable(raw_cert_dir):
config["cert_dir"] = raw_cert_dir
else:
candidate = os.path.join(cfg_dir, os.path.basename(raw_cert_dir))
if is_path_writable(candidate) or is_path_writable(cfg_dir):
config["cert_dir"] = candidate
else:
config["cert_dir"] = resolve_writable_path("certs", "LOGAR", "certs")
# If ca_cert is present in config and ca.crt doesn't exist yet, save it
ca_cert_pem = config.get("ca_cert")
if ca_cert_pem and isinstance(ca_cert_pem, str):
ca_file = os.path.join(config["cert_dir"], "ca.crt")
if not os.path.exists(ca_file):
try:
os.makedirs(config["cert_dir"], exist_ok=True)
with open(ca_file, "w", encoding="utf-8") as f:
f.write(ca_cert_pem)
except Exception:
pass
return config
def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> bool:
"""Proactively ensures client certificate enrollment with the Hub if enrollment_secret is available."""
cert_dir = config.get("cert_dir", "certs")
enrollment_secret = config.get("enrollment_secret")
if not enrollment_secret:
return False
server_host = config.get("server_host", "127.0.0.1")
hermes_host = config.get("hermes_host", server_host)
if hermes_host in ("0.0.0.0", "::", None, ""):
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None, "") else "127.0.0.1"
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
return enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, machine_id, machine_id, os_type=os_type)
except Exception as e:
print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}")
return False
def send_encrypted_logs_over_socket(
config: dict,
logs: list,
os_type: Optional[str] = None,
machine_id: Optional[str] = None
) -> dict:
"""
Streams logs to the LOGAR hub.
Uses mutual TLS 1.3 (mTLS) with client certificates if available,
or falls back to OpenPGP encrypted envelope over TCP socket.
"""
node_os = os_type or ("windows" if sys.platform == "win32" else "linux")
server_host = config["server_host"]
server_port = int(config["server_port"])
cert_dir = config.get("cert_dir", "certs")
enrollment_secret = config.get("enrollment_secret")
actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier()
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
hub_url = None
if enrollment_secret:
hermes_host = config.get("hermes_host", server_host)
if hermes_host in ("0.0.0.0", "::", None):
hermes_host = server_host if server_host not in ("0.0.0.0", "::", None) else "127.0.0.1"
hermes_port = config.get("hermes_port", 8443)
hub_url = f"http://{hermes_host}:{hermes_port}"
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
except Exception as e:
print(f"[!] Warning: Enrollment bootstrap failed: {e}")
ca_path = os.path.join(cert_dir, "ca.crt")
cert_path = os.path.join(cert_dir, "client.crt")
key_path = os.path.join(cert_dir, "client.key")
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
if not has_mtls_certs and enrollment_secret and hub_url:
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
except Exception as e:
print(f"[!] Warning: Enrollment before TLS connection failed: {e}")
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
sock = None
try:
sock = get_tls_socket(server_host, server_port, cert_dir)
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
if enrollment_secret and hub_url:
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True)
sock = get_tls_socket(server_host, server_port, cert_dir)
except Exception as retry_err:
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
raise
else:
raise
with sock:
payload = {
"server": actual_machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_bytes = json.dumps(payload).encode("utf-8")
frame = struct.pack(">I", len(payload_bytes)) + payload_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed mTLS connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
# Fallback to OpenPGP envelope over plain TCP socket
auth_token = config.get("auth_token", "")
pub_key_armored = config.get("server_public_key")
if not pub_key_armored:
raise ValueError("No server public key or mTLS certificates available for connection.")
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
if expected_fp and actual_fp != expected_fp:
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
payload = {
"server": actual_machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs
}
payload_json = json.dumps(payload)
pgp_msg = pgpy.PGPMessage.new(payload_json)
encrypted_msg = pub_key.encrypt(pgp_msg)
encrypted_armored = str(encrypted_msg)
envelope = {
"auth_token": auth_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
"encrypted_payload": encrypted_armored
}
envelope_bytes = json.dumps(envelope).encode("utf-8")
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over secure TCP socket...")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(15.0)
sock.connect((server_host, server_port))
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed connection without response.")
resp_len = struct.unpack(">I", resp_len_bytes)[0]
resp_bytes = bytearray()
while len(resp_bytes) < resp_len:
chunk = sock.recv(min(4096, resp_len - len(resp_bytes)))
if not chunk:
break
resp_bytes.extend(chunk)
response = json.loads(resp_bytes.decode("utf-8"))
print(f"[+] Server response: {response}")
return response
+542
View File
@@ -0,0 +1,542 @@
"""
LOGAR - Unified Cross-Platform Log Collector & State Management
Provides modular log collection classes and state management for Windows and Linux,
reusable across Client Agents (Win_Client, Linux_Client) and Server Hubs (Windows & Linux).
"""
import os
import sys
import json
import socket
import subprocess
import warnings
from abc import ABC, abstractmethod
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict, Any, Set
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore")
try:
import win32evtlog
except ImportError:
win32evtlog = None
CONFIG_FILE_NAME = "client_config.json"
STATE_FILE_NAME = "client_state.json"
def get_machine_identifier() -> str:
"""
Returns the fully qualified hostname of the machine sending/collecting logs.
Cross-platform resolution:
1. FQDN (if non-local)
2. Windows USERDNSDOMAIN (on Windows)
3. Linux /etc/resolv.conf domain/search directive (on Linux)
4. Reverse DNS lookup
5. socket.gethostname() fallback
"""
fqdn = socket.getfqdn()
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
return fqdn
hostname = socket.gethostname()
# Windows DNS domain
if sys.platform == "win32":
user_dns_domain = os.environ.get("USERDNSDOMAIN")
if user_dns_domain and user_dns_domain.lower() != hostname.lower():
return f"{hostname}.{user_dns_domain.lower()}"
# Linux resolv.conf search / domain
if sys.platform.startswith("linux") or os.path.exists("/etc/resolv.conf"):
try:
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
domain = parts[1]
if domain and not domain.startswith("."):
return f"{hostname}.{domain}"
except Exception:
pass
# Reverse DNS lookup
try:
host_ip = socket.gethostbyname(hostname)
canonical_name = socket.gethostbyaddr(host_ip)[0]
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
return canonical_name
except Exception:
pass
return hostname
def is_path_writable(filepath: str) -> bool:
"""Checks if a file or directory path is actually writable by the current process."""
try:
if os.path.exists(filepath):
if os.path.isdir(filepath):
test_f = os.path.join(filepath, f".perm_{os.getpid()}")
with open(test_f, "w") as f:
f.write("1")
os.remove(test_f)
return True
else:
with open(filepath, "a") as f:
pass
return True
else:
parent = os.path.dirname(os.path.abspath(filepath)) or "."
os.makedirs(parent, exist_ok=True)
test_f = os.path.join(parent, f".perm_{os.getpid()}")
with open(test_f, "w") as f:
f.write("1")
os.remove(test_f)
return True
except Exception:
return False
def get_default_data_dir(app_name: str = "LOGAR") -> str:
"""Returns a suitable writable application data directory."""
if sys.platform == "win32":
# 1. ProgramData (C:\ProgramData\LOGAR)
prog_data = os.environ.get("ProgramData", r"C:\ProgramData")
target_prog = os.path.join(prog_data, app_name)
if is_path_writable(target_prog):
return target_prog
# 2. LocalAppData (C:\Users\<user>\AppData\Local\LOGAR)
local_app = os.environ.get("LOCALAPPDATA") or os.path.expanduser(r"~\AppData\Local")
target_local = os.path.join(local_app, app_name)
if is_path_writable(target_local):
return target_local
# 3. Temp directory (%TEMP%\LOGAR)
temp_dir = os.path.join(os.environ.get("TEMP", r"C:\Windows\Temp"), app_name)
if is_path_writable(temp_dir):
return temp_dir
else:
# Linux / Unix candidates: /var/lib/<app_name>, /tmp/<app_name>, ~/.local/share/<app_name>
var_lib = f"/var/lib/{app_name.lower()}"
if is_path_writable(var_lib):
return var_lib
user_share = os.path.expanduser(f"~/.local/share/{app_name.lower()}")
if is_path_writable(user_share):
return user_share
cwd = os.getcwd()
if is_path_writable(cwd):
return cwd
return os.path.expanduser("~")
def resolve_writable_path(path: str, app_name: str = "LOGAR", default_filename: str = "client_state.json") -> str:
"""Resolves a file or directory path, ensuring it is writable or falling back to a writable data directory."""
if not path:
path = default_filename
if os.path.isabs(path) and is_path_writable(path):
return path
if not os.path.isabs(path) and "Program Files" not in os.getcwd() and is_path_writable(os.getcwd()):
return os.path.abspath(path)
data_dir = get_default_data_dir(app_name)
fname = os.path.basename(path)
return os.path.join(data_dir, fname)
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
if custom_state_path:
return custom_state_path
config_dir = os.path.dirname(os.path.abspath(config_path))
if sys.platform == "win32" and ("Program Files" in config_dir or not is_path_writable(config_dir)):
return os.path.join(get_default_data_dir("LOGAR"), STATE_FILE_NAME)
return os.path.join(config_dir, STATE_FILE_NAME)
def load_state(state_path: str) -> dict:
"""Loads state tracking dictionary from disk."""
if os.path.exists(state_path):
try:
with open(state_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[!] Warning: Failed to read state file '{state_path}': {e}")
return {}
return {}
def save_state(state_path: str, state: dict):
"""Atomically saves state dictionary to disk."""
try:
os.makedirs(os.path.dirname(os.path.abspath(state_path)), exist_ok=True)
temp_path = f"{state_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
os.replace(temp_path, state_path)
except Exception as e:
print(f"[!] Warning: Could not save client state to '{state_path}': {e}")
def commit_state(state: dict, state_path: str):
"""
Commits staged cursor/record state keys to persistent fields and writes to state_path.
Supports both Windows record numbers and Linux cursors/timestamps.
"""
if "new_last_record_number" in state:
val = state.pop("new_last_record_number")
if val:
state["last_record_number"] = val
if "new_sent_record_ids" in state:
state["sent_record_ids"] = state.pop("new_sent_record_ids")
if "new_last_cursor" in state:
val = state.pop("new_last_cursor")
if val:
state["last_cursor"] = val
if "new_last_timestamp_us" in state:
val = state.pop("new_last_timestamp_us")
if val:
state["last_timestamp_us"] = val
if "new_sent_cursors" in state:
state["sent_cursors"] = state.pop("new_sent_cursors")
save_state(state_path, state)
class BaseLogCollector(ABC):
"""
Abstract base class for platform-specific edge and hub log collectors.
Manages state persistence, machine identifiers, lookback windows, and normalization.
"""
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
self.state = state if state is not None else {}
self.machine_id = machine_id or get_machine_identifier()
@abstractmethod
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
"""
Collects normalized logs within the given lookback window.
Returns a list of dicts:
[
{
"server": str,
"os_type": "windows"|"linux",
"signature": str,
"severity": "ERROR"|"WARNING"|"INFO",
"message": str
},
...
]
"""
pass
def commit(self, state_path: Optional[str] = None):
"""Commits staged state keys and optionally writes to disk."""
if state_path:
commit_state(self.state, state_path)
else:
if "new_last_record_number" in self.state:
val = self.state.pop("new_last_record_number")
if val:
self.state["last_record_number"] = val
if "new_sent_record_ids" in self.state:
self.state["sent_record_ids"] = self.state.pop("new_sent_record_ids")
if "new_last_cursor" in self.state:
val = self.state.pop("new_last_cursor")
if val:
self.state["last_cursor"] = val
if "new_last_timestamp_us" in self.state:
val = self.state.pop("new_last_timestamp_us")
if val:
self.state["last_timestamp_us"] = val
if "new_sent_cursors" in self.state:
self.state["sent_cursors"] = self.state.pop("new_sent_cursors")
class WindowsLogCollector(BaseLogCollector):
"""
Windows Event Log Collector.
Reads Application and System (or custom channels) backwards.
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops Audit and Debug noise.
State Tracking: Skips events older than lookback window and already sent records.
"""
def __init__(
self,
state: Optional[dict] = None,
machine_id: Optional[str] = None,
channels: Optional[List[str]] = None
):
super().__init__(state=state, machine_id=machine_id)
self.channels = channels or ["Application"]
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
if win32evtlog is None:
return []
logs = []
cutoff_time = datetime.now() - timedelta(hours=hours)
last_record_number = 0
sent_record_ids: Set[str] = set()
if self.state:
last_record_number = int(self.state.get("last_record_number", 0))
sent_record_ids = set(self.state.get("sent_record_ids", []))
# Windows Event Log EventTypes:
# 1: EVENTLOG_ERROR_TYPE -> ERROR
# 2: EVENTLOG_WARNING_TYPE -> WARNING
# 4: EVENTLOG_INFORMATION_TYPE -> INFO
sev_map = {
1: "ERROR",
2: "WARNING",
4: "INFO"
}
newest_record_number = 0
collected_record_ids = []
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
for log_type in self.channels:
try:
hand = win32evtlog.OpenEventLog("localhost", log_type)
except Exception as e:
continue
while True:
try:
events = win32evtlog.ReadEventLog(hand, flags, 0)
except Exception:
break
if not events:
break
for event in events:
rec_num = int(event.RecordNumber)
if newest_record_number == 0:
newest_record_number = rec_num
# 1. Skip entries older than lookback window
if event.TimeGenerated < cutoff_time:
break
# 2. Skip already sent events
if last_record_number > 0 and newest_record_number >= last_record_number:
if rec_num <= last_record_number:
break
rec_id = f"{log_type}:{rec_num}:{event.TimeGenerated.isoformat()}" if len(self.channels) > 1 else f"{rec_num}:{event.TimeGenerated.isoformat()}"
if rec_id in sent_record_ids:
continue
# Filter: upload everything from INFO to ERROR
if event.EventType in sev_map:
msg = " ".join(event.StringInserts) if event.StringInserts else "Event Log Entry"
sig = f"{log_type}/{event.SourceName or 'Windows-Event'}" if len(self.channels) > 1 else (event.SourceName or "Windows-Event")
logs.append({
"server": self.machine_id,
"os_type": "windows",
"signature": sig,
"severity": sev_map[event.EventType],
"message": msg[:2048]
})
collected_record_ids.append(rec_id)
if events[-1].TimeGenerated < cutoff_time:
break
if last_record_number > 0 and newest_record_number >= last_record_number and events[-1].RecordNumber <= last_record_number:
break
try:
win32evtlog.CloseEventLog(hand)
except Exception:
pass
if self.state is not None:
target_rec = max(newest_record_number, last_record_number)
self.state["new_last_record_number"] = target_rec
self.state["new_sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
# Also update direct keys for immediate server-side ingestion
self.state["last_record_number"] = target_rec
self.state["sent_record_ids"] = self.state["new_sent_record_ids"]
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
class LinuxLogCollector(BaseLogCollector):
"""
Linux Systemd Journal / Syslog Log Collector.
Collects info, warnings, and errors from journalctl (or syslog fallback).
Edge Filtering: Retains INFO, WARNING, and ERROR. Drops DEBUG noise.
State Tracking: Skips events older than lookback window and already sent cursors.
"""
def __init__(self, state: Optional[dict] = None, machine_id: Optional[str] = None):
super().__init__(state=state, machine_id=machine_id)
def collect_logs(self, hours: int = 24) -> List[Dict[str, Any]]:
last_cursor = None
last_timestamp_us = 0.0
sent_cursors: Set[str] = set()
if self.state:
last_cursor = self.state.get("last_cursor")
try:
last_timestamp_us = float(self.state.get("last_timestamp_us", 0))
except (ValueError, TypeError):
last_timestamp_us = 0.0
sent_cursors = set(self.state.get("sent_cursors", []))
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "info", "--output=json"]
result = None
if last_cursor:
cmd_with_cursor = ["journalctl", "--since", f"{hours} hours ago", "--after-cursor", str(last_cursor), "-p", "info", "--output=json"]
try:
res = subprocess.run(cmd_with_cursor, capture_output=True, text=True, check=False)
if res.returncode == 0:
result = res
except Exception:
pass
if result is None:
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
except FileNotFoundError:
# journalctl not present, attempt fallback to /var/log/syslog or messages
return self._collect_syslog_fallback(hours=hours)
except Exception as e:
print(f"[!] Error running journalctl: {e}")
return []
if not result or result.returncode != 0:
return []
logs = []
cutoff_epoch_us = (datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1_000_000
newest_cursor = None
newest_timestamp_us = last_timestamp_us
collected_cursors = []
for line in result.stdout.splitlines():
line_str = line.strip()
if not line_str:
continue
try:
entry = json.loads(line_str)
entry_cursor = entry.get("__CURSOR")
entry_ts_us_raw = entry.get("__REALTIME_TIMESTAMP")
entry_ts_us = 0.0
if entry_ts_us_raw:
try:
entry_ts_us = float(entry_ts_us_raw)
except (ValueError, TypeError):
pass
# 1. Skip entries older than lookback window
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
continue
# 2. Skip already sent events
if entry_cursor and (entry_cursor in sent_cursors or entry_cursor == last_cursor):
continue
if last_timestamp_us > 0 and entry_ts_us > 0 and entry_ts_us < last_timestamp_us:
continue
# Advance tracking
if entry_cursor:
newest_cursor = entry_cursor
collected_cursors.append(entry_cursor)
if entry_ts_us > newest_timestamp_us:
newest_timestamp_us = entry_ts_us
priority = int(entry.get("PRIORITY", "6"))
if priority > 6:
continue
if priority <= 3:
sev = "ERROR"
elif priority in (4, 5):
sev = "WARNING"
else:
sev = "INFO"
logs.append({
"server": self.machine_id,
"os_type": "linux",
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
"severity": sev,
"message": entry.get("MESSAGE", "")[:2048]
})
except (json.JSONDecodeError, ValueError):
continue
if self.state is not None:
self.state["new_last_cursor"] = newest_cursor or last_cursor
self.state["new_last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
self.state["new_sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
# Also update direct keys for immediate server-side ingestion
self.state["last_cursor"] = self.state["new_last_cursor"]
self.state["last_timestamp_us"] = self.state["new_last_timestamp_us"]
self.state["sent_cursors"] = self.state["new_sent_cursors"]
self.state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
def _collect_syslog_fallback(self, hours: int = 24) -> List[Dict[str, Any]]:
"""Fallback log reader for non-systemd Linux environments."""
candidates = ["/var/log/syslog", "/var/log/messages"]
target = next((f for f in candidates if os.path.exists(f) and os.access(f, os.R_OK)), None)
if not target:
return []
logs = []
try:
with open(target, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()[-500:] # Read recent 500 lines
for line in lines:
line_str = line.strip()
if not line_str:
continue
sev = "INFO"
lower_l = line_str.lower()
if any(w in lower_l for w in ["err", "crit", "alert", "emerg", "fail"]):
sev = "ERROR"
elif any(w in lower_l for w in ["warn", "notice"]):
sev = "WARNING"
logs.append({
"server": self.machine_id,
"os_type": "linux",
"signature": "syslog",
"severity": sev,
"message": line_str[:2048]
})
except Exception:
pass
return logs
def get_log_collector(
os_type: Optional[str] = None,
state: Optional[dict] = None,
machine_id: Optional[str] = None,
channels: Optional[List[str]] = None
) -> BaseLogCollector:
"""
Factory helper that returns the appropriate BaseLogCollector instance.
If os_type is omitted, auto-detects based on sys.platform.
"""
target_os = os_type.lower() if os_type else ("windows" if sys.platform == "win32" else "linux")
if target_os == "windows":
chans = channels or (["Application", "System"] if channels is None else ["Application"])
return WindowsLogCollector(state=state, machine_id=machine_id, channels=chans)
else:
return LinuxLogCollector(state=state, machine_id=machine_id)
+17 -1
View File
@@ -251,8 +251,24 @@ def generate_server_cert_if_needed(
encryption_algorithm=serialization.NoEncryption() encryption_algorithm=serialization.NoEncryption()
).decode("utf-8") ).decode("utf-8")
# Bundle subordinate CA and upstream CA into server.crt for complete TLS handshake chain
full_server_cert_pem = server_cert_pem
ca_cert_path = os.path.join(cert_dir, "ca.crt")
if os.path.exists(ca_cert_path):
with open(ca_cert_path, "r", encoding="utf-8") as f:
ca_pem = f.read().strip()
if ca_pem and ca_pem not in full_server_cert_pem:
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + ca_pem
upstream_ca_path = os.path.join(cert_dir, "upstream_ca.crt")
if os.path.exists(upstream_ca_path):
with open(upstream_ca_path, "r", encoding="utf-8") as f:
upstream_pem = f.read().strip()
if upstream_pem and upstream_pem not in full_server_cert_pem:
full_server_cert_pem = full_server_cert_pem.strip() + "\n" + upstream_pem
with open(server_cert_path, "w", encoding="utf-8") as f: with open(server_cert_path, "w", encoding="utf-8") as f:
f.write(server_cert_pem) f.write(full_server_cert_pem)
with open(server_key_path, "w", encoding="utf-8") as f: with open(server_key_path, "w", encoding="utf-8") as f:
f.write(server_key_pem) f.write(server_key_pem)
+108
View File
@@ -0,0 +1,108 @@
"""
Unit tests for LOGAR log_collector and client_transport modules.
"""
import os
import sys
import json
import unittest
import warnings
from datetime import datetime, timezone, timedelta
warnings.filterwarnings("ignore")
warnings.simplefilter("ignore")
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src")))
from log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path,
get_state_path,
load_state,
save_state,
commit_state,
BaseLogCollector,
WindowsLogCollector,
LinuxLogCollector,
get_log_collector
)
from client_transport import (
is_cert_expiring_soon,
load_config
)
class TestLogCollectorModule(unittest.TestCase):
def test_machine_identifier(self):
mid = get_machine_identifier()
self.assertIsInstance(mid, str)
self.assertTrue(len(mid) > 0)
self.assertNotEqual(mid, "localhost")
def test_path_writable_and_data_dir(self):
data_dir = get_default_data_dir("LOGAR_TEST")
self.assertIsInstance(data_dir, str)
self.assertTrue(is_path_writable(data_dir))
def test_state_lifecycle_windows_and_linux(self):
state_file = "test_shared_state.json"
try:
state = load_state(state_file)
self.assertEqual(state, {})
# Windows staged state
state["new_last_record_number"] = 100
state["new_sent_record_ids"] = ["100:2026-09-27T00:00:00"]
commit_state(state, state_file)
self.assertNotIn("new_last_record_number", state)
self.assertEqual(state["last_record_number"], 100)
# Linux staged state
state["new_last_cursor"] = "c_xyz"
state["new_last_timestamp_us"] = 123456789.0
state["new_sent_cursors"] = ["c_xyz"]
commit_state(state, state_file)
self.assertNotIn("new_last_cursor", state)
self.assertEqual(state["last_cursor"], "c_xyz")
self.assertEqual(state["last_timestamp_us"], 123456789.0)
# Reload and verify
reloaded = load_state(state_file)
self.assertEqual(reloaded["last_record_number"], 100)
self.assertEqual(reloaded["last_cursor"], "c_xyz")
finally:
if os.path.exists(state_file):
try:
os.remove(state_file)
except Exception:
pass
def test_windows_log_collector_severity_mapping(self):
state = {}
collector = WindowsLogCollector(state=state, machine_id="test-win-node", channels=["Application"])
self.assertEqual(collector.machine_id, "test-win-node")
self.assertEqual(collector.channels, ["Application"])
def test_linux_log_collector_priority_normalization(self):
state = {}
collector = LinuxLogCollector(state=state, machine_id="test-linux-node")
self.assertEqual(collector.machine_id, "test-linux-node")
def test_get_log_collector_factory(self):
win_col = get_log_collector(os_type="windows", machine_id="win-1")
self.assertIsInstance(win_col, WindowsLogCollector)
self.assertEqual(win_col.machine_id, "win-1")
lin_col = get_log_collector(os_type="linux", machine_id="lin-1")
self.assertIsInstance(lin_col, LinuxLogCollector)
self.assertEqual(lin_col.machine_id, "lin-1")
auto_col = get_log_collector()
self.assertIsInstance(auto_col, BaseLogCollector)
if __name__ == "__main__":
unittest.main()
+84 -3
View File
@@ -8,6 +8,7 @@ import struct
import sqlite3 import sqlite3
import urllib.request import urllib.request
import urllib.error import urllib.error
import shutil
import warnings import warnings
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -30,8 +31,14 @@ HERMES_PORT = 8443
def run_tests(): def run_tests():
print("=== [1] Verifying server_config.json & client_config.json ===") server_cfg_path = "server_config.json"
server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json") if not os.path.exists(server_cfg_path):
server_cfg_path = os.path.join(ROOT_DIR, "server_config.json")
if not os.path.exists(server_cfg_path):
progdata_path = os.path.join(os.environ.get("ProgramData", r"C:\ProgramData"), "LOGAR-Server", "server_config.json")
if os.path.exists(progdata_path):
server_cfg_path = progdata_path
client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json") client_cfg_path = "client_config.json" if os.path.exists("client_config.json") else os.path.join(ROOT_DIR, "client_config.json")
assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist" assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"
@@ -51,9 +58,84 @@ def run_tests():
print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}") print(f"[OK] Fingerprint verified: {client_conf['server_fingerprint']}")
cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs") cert_dir = os.path.join(ROOT_DIR, "test_pipeline_certs")
shutil.rmtree(cert_dir, ignore_errors=True)
os.makedirs(cert_dir, exist_ok=True) os.makedirs(cert_dir, exist_ok=True)
client_id = "test-edge-node.corp.internal" client_id = "test-edge-node.corp.internal"
enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret") enrollment_secret = server_conf.get("enrollment_secret") or client_conf.get("enrollment_secret")
auth_token = server_conf.get("auth_token", "")
print("\n=== [1.5] Testing Server License Activation via Web API (/api/license/import) ===")
import server_enrollment
# 1. Check current license status
req_status_init = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
with urllib.request.urlopen(req_status_init, timeout=5) as resp:
init_lic_stat = json.loads(resp.read().decode("utf-8"))
if init_lic_stat.get("status") == "UNLICENSED":
unlicensed_enroll_payload = {
"client_id": client_id,
"hostname": client_id,
"os": "linux",
"enrollment_secret": enrollment_secret
}
req_unlicensed = urllib.request.Request(
f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll",
data=json.dumps(unlicensed_enroll_payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req_unlicensed, timeout=5):
assert False, "Expected HTTP 403 on unlicensed server enrollment"
except urllib.error.HTTPError as e:
assert e.code == 403, f"Expected HTTP 403 on unlicensed server, got {e.code}"
print("[OK] Unlicensed server correctly rejected edge client enrollment.")
# 2. Fetch CA CSR from Server
req_csr = urllib.request.Request(
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/csr",
headers={"Authorization": f"Bearer {auth_token}"}
)
with urllib.request.urlopen(req_csr, timeout=5) as resp:
assert resp.status == 200
csr_data = json.loads(resp.read().decode("utf-8"))
csr_pem = csr_data["csr"]
print("[OK] Fetched subordinate CA CSR from server.")
# 3. Generate test master CA and sign subordinate CA with 10 seats
master_ca_dir = os.path.join(ROOT_DIR, "test_pipeline_master_ca")
master_cert, master_key, master_pem, master_key_pem = server_enrollment.generate_ca_if_needed(cert_dir=master_ca_dir, common_name="LOGAR-Master-Root-CA")
sub_pem = server_enrollment.sign_ca_csr_with_seats(
csr_pem=csr_pem,
master_ca_cert_pem=master_pem,
master_ca_key_pem=master_key_pem,
license_id="LIC-TEST-PIPELINE-10",
seats=10,
days_valid=365
)
# 4. Import signed license package to server
import_payload = {"ca_cert": sub_pem, "master_root_ca": master_pem}
req_import = urllib.request.Request(
f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/import",
data=json.dumps(import_payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {auth_token}"}
)
with urllib.request.urlopen(req_import, timeout=5) as resp:
assert resp.status == 200
import_res = json.loads(resp.read().decode("utf-8"))
assert import_res["status"] == "success"
assert import_res["seats"] == 10
print("[OK] Successfully imported signed license (.lic). Server activated with 10 seats.")
# 5. Verify license status
req_status = urllib.request.Request(f"http://{HERMES_HOST}:{HERMES_PORT}/api/license/status")
with urllib.request.urlopen(req_status, timeout=5) as resp:
assert resp.status == 200
lic_stat = json.loads(resp.read().decode("utf-8"))
assert lic_stat["status"] == "ACTIVE"
assert lic_stat["max_seats"] == 10
print("[OK] Verified server license status is ACTIVE.")
print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===") print("\n=== [2] Testing Client Dynamic PKI Enrollment API (/api/client/enroll) ===")
enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll" enroll_url = f"http://{HERMES_HOST}:{HERMES_PORT}/api/client/enroll"
@@ -244,7 +326,6 @@ def run_tests():
linux_logs = get_recent_linux_logs(hours=24) linux_logs = get_recent_linux_logs(hours=24)
print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.") print(f"[Linux_Client] Successfully queried Linux logs: {len(linux_logs)} candidate entries.")
import shutil
if os.path.exists(cert_dir): if os.path.exists(cert_dir):
shutil.rmtree(cert_dir, ignore_errors=True) shutil.rmtree(cert_dir, ignore_errors=True)
+40 -10
View File
@@ -6,6 +6,7 @@ import struct
import sqlite3 import sqlite3
import unittest import unittest
import urllib.request import urllib.request
import uuid
import warnings import warnings
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -21,12 +22,8 @@ import pgpy
class TestServerComponent(unittest.TestCase): class TestServerComponent(unittest.TestCase):
def setUp(self): def setUp(self):
self.test_db = "test_server_state.db" self.test_db = f"test_server_state_{uuid.uuid4().hex[:8]}.db"
self.test_config = "test_server_config.json" self.test_config = f"test_server_config_{uuid.uuid4().hex[:8]}.json"
if os.path.exists(self.test_db):
os.remove(self.test_db)
if os.path.exists(self.test_config):
os.remove(self.test_config)
def tearDown(self): def tearDown(self):
if os.path.exists(self.test_db): if os.path.exists(self.test_db):
@@ -223,7 +220,7 @@ class TestServerComponent(unittest.TestCase):
test_cert_dir = "test_certs_enroll" test_cert_dir = "test_certs_enroll"
try: try:
ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir) ca_cert, ca_key, ca_pem, _ = Server.enrollment.generate_ca_if_needed(cert_dir=test_cert_dir)
Server.SERVER_STATE["config"] = {"db_path": self.test_db} Server.SERVER_STATE["config"] = {"db_path": self.test_db, "cert_dir": test_cert_dir}
Server.SERVER_STATE["ca_cert"] = ca_cert Server.SERVER_STATE["ca_cert"] = ca_cert
Server.SERVER_STATE["ca_key"] = ca_key Server.SERVER_STATE["ca_key"] = ca_key
Server.SERVER_STATE["ca_cert_pem"] = ca_pem Server.SERVER_STATE["ca_cert_pem"] = ca_pem
@@ -249,7 +246,7 @@ class TestServerComponent(unittest.TestCase):
resp1 = Server.enroll_client(req1) resp1 = Server.enroll_client(req1)
self.assertIn("client_cert", resp1) self.assertIn("client_cert", resp1)
self.assertIn("client_key", resp1) self.assertIn("client_key", resp1)
self.assertEqual(resp1["ca_cert"], ca_pem) self.assertTrue(resp1["ca_cert"].strip().startswith(ca_pem.strip()))
# 3. Valid enrollment for client 2 # 3. Valid enrollment for client 2
req2 = Server.ClientEnrollRequest( req2 = Server.ClientEnrollRequest(
@@ -438,10 +435,43 @@ class TestServerComponent(unittest.TestCase):
self.assertEqual(Server.format_relative_time(None), "Never") self.assertEqual(Server.format_relative_time(None), "Never")
self.assertEqual(Server.format_relative_time(""), "Never") self.assertEqual(Server.format_relative_time(""), "Never")
self.assertEqual(Server.format_relative_time("invalid-date-string"), "invalid-date-str") self.assertEqual(Server.format_relative_time("invalid-date-string"), "invalid-date-str")
just_now = now.strftime("%Y-%m-%d %H:%M:%S") now_fresh = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
self.assertIn(Server.format_relative_time(just_now), ("0s ago", "1s ago", "Just now")) rel = Server.format_relative_time(now_fresh)
self.assertTrue(rel.endswith("s ago") or rel == "Just now", f"Unexpected relative time: {rel}")
def test_integrated_local_log_collector_zero_seats(self):
"""Tests that local hub log collection runs without license, registering a 0-seat built-in client."""
Server.init_db(self.test_db, enrollment_secret="test-sec-456", max_seats=0)
Server.SERVER_STATE["config"] = {
"db_path": self.test_db,
"evaluation_window_hours": 12,
"min_persistence_runs": 1
}
# Run cycle
state = {}
processed = Server.run_local_log_collection_cycle(self.test_db, window_hours=12, min_runs=1, state=state)
# Verify local hub client entry exists in clients table
machine_id = Server.get_machine_identifier()
conn = sqlite3.connect(self.test_db)
c = conn.cursor()
c.execute("SELECT client_id, cert_fingerprint, status FROM clients WHERE client_id = ?", (machine_id,))
row = c.fetchone()
conn.close()
self.assertIsNotNone(row)
self.assertEqual(row[0], machine_id)
self.assertEqual(row[1], "LOCAL-HOST-BUILTIN")
self.assertEqual(row[2], "active")
# Verify active seat count is STILL 0 (exempt from seat accounting)
client_data = Server.list_clients()
self.assertEqual(client_data["active_seats"], 0)
self.assertEqual(len(client_data["clients"]), 1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()