Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900d03b7f7 | ||
|
|
a8d59b2f14 | ||
|
|
b79022bc41 | ||
|
|
fcba813edd | ||
|
|
72e3527c79 | ||
|
|
4a40b4d395 | ||
|
|
399cf469c5 | ||
|
|
ce7d2b97d2 | ||
|
|
2e256baf4a | ||
|
|
1a984caaf7 | ||
|
|
96756ca5db | ||
|
|
157229a84e | ||
|
|
8287af3129 | ||
|
|
a133050cb5 |
@@ -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*
|
||||||
|
|
||||||
[](RELEASE_NOTES.md)
|
[](RELEASE_NOTES.md)
|
||||||
[](tests/)
|
[](tests/)
|
||||||
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
[](https://gitea.eibl.tech/me0nline/LOGAR/wiki/PKI-and-Licensing)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -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()
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||