windows installer fixes
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 1m41s
Release Binaries & Packages / Build & Release Standalone Binaries (Linux & Windows Cross-Compilation) (push) Successful in 4m47s

This commit is contained in:
2026-09-27 15:00:26 +02:00
parent 399cf469c5
commit 4a40b4d395
2 changed files with 384 additions and 24 deletions
+340 -13
View File
@@ -37,11 +37,17 @@ 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:
@@ -909,6 +915,311 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i
} }
# --- Integrated Local Hub Host Log Collector ---
def get_machine_identifier() -> str:
"""Returns the hostname / FQDN of the local server hub host."""
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_local_windows_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Collects local Windows Application and System event logs."""
if win32evtlog is None:
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", []))
sev_map = {
1: "ERROR",
2: "WARNING",
4: "INFO"
}
newest_record_number = 0
collected_record_ids = []
for log_type in ["Application", "System"]:
flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ
try:
hand = win32evtlog.OpenEventLog("localhost", log_type)
except Exception:
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
if event.TimeGenerated < cutoff_time:
break
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 rec_id in sent_record_ids:
continue
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": f"{log_type}/{event.SourceName or 'Windows-Event'}",
"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 state is not None:
target_rec = max(newest_record_number, last_record_number)
state["last_record_number"] = target_rec
state["sent_record_ids"] = (list(sent_record_ids) + collected_record_ids)[-1000:]
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
def get_local_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Collects local Linux journalctl events."""
last_cursor = None
last_timestamp_us = 0.0
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 Exception:
pass
if result is None:
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
except Exception:
return []
if not result or result.returncode != 0:
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
if entry_ts_us and entry_ts_us < cutoff_epoch_us:
continue
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
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": machine_id,
"os_type": "linux",
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
"severity": sev,
"message": entry.get("MESSAGE", "")[:2048]
})
except Exception:
continue
if state is not None:
state["last_cursor"] = newest_cursor or last_cursor
state["last_timestamp_us"] = max(newest_timestamp_us, last_timestamp_us)
state["sent_cursors"] = (list(sent_cursors) + collected_cursors)[-1000:]
state["last_run_timestamp"] = datetime.now(timezone.utc).isoformat()
return logs
def get_local_system_logs(hours: int = 24, state: Optional[dict] = None) -> list:
"""Platform-agnostic local host log collector."""
if sys.platform == "win32":
return get_local_windows_logs(hours=hours, state=state)
elif sys.platform.startswith("linux"):
return get_local_linux_logs(hours=hours, state=state)
return []
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")
@@ -1043,7 +1354,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()
@@ -1406,7 +1717,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()
@@ -1491,7 +1802,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,
@@ -1510,7 +1821,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()
@@ -1535,10 +1846,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()
@@ -1553,10 +1866,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()
@@ -1698,9 +2013,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:
@@ -1888,7 +2203,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]
@@ -1989,6 +2304,14 @@ 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>"
if fp == "LOCAL-HOST-BUILTIN":
esc_st = "LOCAL HUB HOST"
badge_cls = "badge-active"
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 = ''
else:
fp_display = f"<code title='{esc_fp}' style='cursor:pointer;' onclick=\"copyText('{esc_fp}', 'Cert Fingerprint')\">{esc_fp[:16]}...</code>"
btn_action = ( btn_action = (
f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>' f'<button class="btn btn-outline" style="padding:4px 8px; font-size:11px;" onclick="revokeClient(\'{esc_cid}\')">Revoke</button>'
if st == "active" else if st == "active" else
@@ -2002,7 +2325,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;">
@@ -2852,17 +3175,21 @@ 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()
collector_task.cancel()
for t in [watchdog_task, collector_task]:
try: try:
await watchdog_task await t
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
@@ -2951,7 +3278,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()
+35 -2
View File
@@ -435,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()