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
+349 -22
View File
@@ -37,11 +37,17 @@ from pgpy.constants import (
CompressionAlgorithm
)
import ssl
import subprocess
from pydantic import BaseModel
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
import uvicorn
try:
import win32evtlog
except ImportError:
win32evtlog = None
try:
from src import server_enrollment as enrollment
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:
"""Initializes TLS 1.3 server SSLContext with client certificate requirement (mTLS)."""
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):
try:
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:
active_seats = row[0]
conn.close()
@@ -1406,7 +1717,7 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
}
# 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]
if active_count >= max_seats:
conn.close()
@@ -1491,7 +1802,7 @@ def list_clients():
}
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 {
"active_seats": active_count,
"max_seats": max_seats,
@@ -1510,7 +1821,7 @@ def approve_client(action: ClientActionRequest):
row = c.fetchone()
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]
if active_count >= max_seats:
conn.close()
@@ -1535,10 +1846,12 @@ reactivate_client = approve_client
@app.post("/api/clients/revoke", dependencies=[Depends(require_admin_auth)])
def revoke_client(action: ClientActionRequest):
"""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)
conn = sqlite3.connect(db_path)
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
conn.commit()
conn.close()
@@ -1553,10 +1866,12 @@ def revoke_client(action: ClientActionRequest):
@app.post("/api/clients/remove", dependencies=[Depends(require_admin_auth)])
def delete_client(action: ClientActionRequest):
"""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)
conn = sqlite3.connect(db_path)
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
conn.commit()
conn.close()
@@ -1698,9 +2013,9 @@ def get_license_summary() -> Dict[str, Any]:
max_seats = row[0]
if len(row) > 1 and 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]
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]
conn.close()
except Exception:
@@ -1888,7 +2203,7 @@ async def trigger_license_renewal(request: Request):
try:
conn_cnt = sqlite3.connect(db_path)
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()
if row:
active_seats = row[0]
@@ -1989,12 +2304,20 @@ def serve_webui(request: Request = None):
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>"
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>'
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 = (
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"""
<tr>
@@ -2002,7 +2325,7 @@ def serve_webui(request: Request = None):
<td><strong>{esc_host}</strong></td>
<td><code>{esc_cid}</code></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>
<div style="display:flex; gap:6px; align-items:center;">
@@ -2852,19 +3175,23 @@ async def run_server():
print(f"[*] Client Enrollment API available at http://{hermes_host}:{hermes_port}/api/client/enroll")
watchdog_task = asyncio.create_task(cert_validity_watchdog())
collector_task = asyncio.create_task(local_collector_loop(interval_seconds=30))
try:
await asyncio.gather(
tcp_server.serve_forever(),
uv_server.serve(),
watchdog_task
watchdog_task,
collector_task
)
finally:
watchdog_task.cancel()
try:
await watchdog_task
except asyncio.CancelledError:
pass
collector_task.cancel()
for t in [watchdog_task, collector_task]:
try:
await t
except asyncio.CancelledError:
pass
def main():
@@ -2951,7 +3278,7 @@ def main():
if os.path.exists(config.get("db_path", "server_logs.db")):
try:
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:
active_seats = row[0]
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(""), "Never")
self.assertEqual(Server.format_relative_time("invalid-date-string"), "invalid-date-str")
just_now = now.strftime("%Y-%m-%d %H:%M:%S")
self.assertIn(Server.format_relative_time(just_now), ("0s ago", "1s ago", "Just now"))
now_fresh = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
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__":
unittest.main()