diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9d349b7..cd8d3c5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,22 @@ # LOGAR Release Notes +## [v0.3.5.2] - 2026-10-02 + +### 🛡️ Windows Defender Firewall Automation (Fixes Issue #1) +- **Automated Port Exemptions**: Inno Setup (`installer_server.iss`) and NSIS (`installer_server.nsi`) server installers automatically configure inbound Windows Defender Firewall rules for WebUI (`TCP 8443`) and mTLS log ingestion (`TCP 9443`). +- **Clean Uninstallation**: Uninstallers automatically remove the created firewall rules cleanly upon server decommissioning. + +### 📊 Forwarder Liveness, Silent Server Detection & System Telemetry (Fixes Issue #2) +- **Silent Node & Crash Detection**: Resolved the "Silent Server Down Bug" by tracking forwarder heartbeats (`last_seen`). Hermes reporting (`GET /api/hermes/report`) automatically flags edge nodes that have gone silent for >15 minutes as `CRITICAL` `NODE_OFFLINE` alerts. +- **Troubleshooting System Telemetry**: Cross-platform system telemetry metrics (CPU utilization %, Memory load %, and Free Disk space %) are collected and attached to client heartbeats and Hermes anomaly records without external dependencies. +- **Benign Service Noise Suppression**: Filtered routine repetitive Windows and Linux background service lifecycle events (e.g., `sppsvc`, `edgeupdate`, `trustedinstaller`, `wuauserv`, `backgroundTaskHost`) from false-positive anomaly alert fatigue. + +### 🔑 Dynamic Enrollment Key Rotation & WebUI Key Management +- **Automatic Key Invalidation**: Clicking "Download Client Config" (`GET /api/client/export-profile`) automatically generates a fresh enrollment secret and invalidates previous secrets, preventing reuse of leaked keys. +- **WebUI Reset Shared Key**: Added "🔄 Reset Shared Key" controls in the WebUI header, Fleet tab, and Export Modal, backed by `POST /api/server/reset-enrollment-secret` to rotate keys and update quick-deploy command snippets on demand. + +--- + ## [v0.3.5.1] - 2026-09-30 ### 🗄️ Multi-Engine Storage Architecture diff --git a/compilation/installer_server.iss b/compilation/installer_server.iss index b50fe01..41ebe4b 100644 --- a/compilation/installer_server.iss +++ b/compilation/installer_server.iss @@ -45,9 +45,13 @@ Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppDirectory ""{app}"" Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStdout ""{code:GetDataDir}\server.log"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppStderr ""{code:GetDataDir}\server_err.log"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "set LOGAR_Server AppParameters ""--config """"{code:GetDataDir}\server_config.json"""""""; Flags: runhidden +Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""LOGAR Server WebUI (8443)"" dir=in action=allow protocol=TCP localport=8443"; Flags: runhidden +Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""LOGAR Server Ingestion (9443)"" dir=in action=allow protocol=TCP localport=9443"; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "start LOGAR_Server"; Flags: runhidden [UninstallRun] +Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LOGAR Server WebUI (8443)"""; Flags: runhidden +Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LOGAR Server Ingestion (9443)"""; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "stop LOGAR_Server"; Flags: runhidden Filename: "{app}\nssm.exe"; Parameters: "remove LOGAR_Server confirm"; Flags: runhidden diff --git a/compilation/installer_server.nsi b/compilation/installer_server.nsi index 498927c..dae36dc 100644 --- a/compilation/installer_server.nsi +++ b/compilation/installer_server.nsi @@ -134,6 +134,10 @@ Section "Install" CreateDirectory "$DATADIR" ExecWait 'icacls "$DATADIR" /grant "*S-1-5-32-545:(OI)(CI)M" /T /Q' + # Configure Windows Defender Firewall inbound rules for WebUI (8443) and Ingestion (9443) + ExecWait 'netsh advfirewall firewall add rule name="LOGAR Server WebUI (8443)" dir=in action=allow protocol=TCP localport=8443' + ExecWait 'netsh advfirewall firewall add rule name="LOGAR Server Ingestion (9443)" dir=in action=allow protocol=TCP localport=9443' + # Install and configure service via NSSM ExecWait '"$INSTDIR\nssm.exe" install LOGAR_Server "$INSTDIR\Server.exe"' ExecWait '"$INSTDIR\nssm.exe" set LOGAR_Server AppDirectory "$INSTDIR"' @@ -154,7 +158,9 @@ Section "Uninstall" StrCpy $DATADIR "$APPDATA\LOGAR-Server" ${EndIf} - # 1. Stop and remove service + # 1. Stop and remove service & clean firewall rules + ExecWait 'netsh advfirewall firewall delete rule name="LOGAR Server WebUI (8443)"' + ExecWait 'netsh advfirewall firewall delete rule name="LOGAR Server Ingestion (9443)"' ExecWait 'net stop LOGAR_Server' ExecWait 'sc stop LOGAR_Server' ${If} ${FileExists} "$INSTDIR\nssm.exe" diff --git a/src/Server.py b/src/Server.py index 98275d1..7bd51de 100644 --- a/src/Server.py +++ b/src/Server.py @@ -60,14 +60,16 @@ try: get_machine_identifier, WindowsLogCollector, LinuxLogCollector, - get_log_collector + get_log_collector, + is_routine_service_noise ) except ImportError: from log_collector import ( get_machine_identifier, WindowsLogCollector, LinuxLogCollector, - get_log_collector + get_log_collector, + is_routine_service_noise ) CONFIG_FILE_NAME = "server_config.json" @@ -891,6 +893,42 @@ def load_or_init_config(config_path: str = CONFIG_FILE_NAME) -> Dict[str, Any]: return config +def rotate_enrollment_secret(config_dict: Optional[Dict[str, Any]] = None) -> str: + """ + Generates a fresh enrollment secret, invalidates the previous secret, + and atomically persists the change to server_config.json and database license_config. + """ + cfg = config_dict if config_dict is not None else SERVER_STATE.get("config", {}) + new_secret = secrets.token_hex(24) + cfg["enrollment_secret"] = new_secret + + # Update config file on disk if exists + config_file_path = cfg.get("config_file_path") or CONFIG_FILE_NAME + try: + if os.path.exists(config_file_path): + with open(config_file_path, "r", encoding="utf-8") as f: + saved = json.load(f) + saved["enrollment_secret"] = new_secret + with open(config_file_path, "w", encoding="utf-8") as f: + json.dump(saved, f, indent=2) + except Exception as e: + print(f"[!] Warning: Could not write new enrollment secret to config file: {e}") + + # Update database license_config table + db_path = cfg.get("db_path", DEFAULT_DB_FILE) + try: + conn = sqlite3.connect(db_path, timeout=5.0) + c = conn.cursor() + c.execute("UPDATE license_config SET enrollment_secret = ? WHERE id = 1", (new_secret,)) + conn.commit() + conn.close() + except Exception as e: + print(f"[!] Warning: Could not update database with new enrollment secret: {e}") + + SERVER_STATE["config"] = cfg + return new_secret + + def generate_client_profile_dict( server_host: str, server_port: int, @@ -1079,6 +1117,18 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in ) """) + # Migrations for clients table + for col, col_def in [ + ("metrics_json", "TEXT DEFAULT '{}'"), + ("cpu_percent", "REAL DEFAULT 0.0"), + ("memory_percent", "REAL DEFAULT 0.0"), + ("disk_percent_free", "REAL DEFAULT 0.0") + ]: + try: + conn.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_def}") + except sqlite3.OperationalError: + pass + # 5. Users Table conn.execute(""" CREATE TABLE IF NOT EXISTS users ( @@ -1541,6 +1591,17 @@ def init_postgres_db(pg_config: Dict[str, Any]) -> Tuple[bool, str]: value TEXT NOT NULL ); """) + # Migrations for Postgres clients table + for col, col_def in [ + ("metrics_json", "TEXT DEFAULT '{}'"), + ("cpu_percent", "DOUBLE PRECISION DEFAULT 0.0"), + ("memory_percent", "DOUBLE PRECISION DEFAULT 0.0"), + ("disk_percent_free", "DOUBLE PRECISION DEFAULT 0.0") + ]: + try: + cur.execute(f"ALTER TABLE clients ADD COLUMN IF NOT EXISTS {col} {col_def}") + except Exception: + pass conn.commit() conn.close() return True, "PostgreSQL schema successfully initialized" @@ -1682,6 +1743,11 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i server = log.get("server", client_server) message = log.get("message", "") os_type = log.get("os_type", "unknown") + + # Suppress repetitive benign routine service start/stop cycles + if is_routine_service_noise(signature, message, severity): + continue + fp = f"{site_name}:{server}:{signature}" stored_message = encrypt_field_for_storage(message) @@ -1726,6 +1792,21 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i processed_count += 1 + # Update client node metrics if attached + metrics = payload.get("metrics") + if metrics and isinstance(metrics, dict) and client_server: + cpu_p = float(metrics.get("cpu_percent", 0.0)) + mem_p = float(metrics.get("memory_percent", 0.0)) + disk_p = float(metrics.get("disk_percent_free", 0.0)) + try: + cursor.execute(""" + UPDATE clients + SET last_seen = %s, metrics_json = %s, cpu_percent = %s, memory_percent = %s, disk_percent_free = %s + WHERE client_id = %s OR hostname = %s + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + except Exception: + pass + conn.commit() conn.close() return { @@ -1758,6 +1839,11 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i server = log.get("server", client_server) message = log.get("message", "") os_type = log.get("os_type", "unknown") + + # Suppress repetitive benign routine service start/stop cycles + if is_routine_service_noise(signature, message, severity): + continue + fp = f"{site_name}:{server}:{signature}" stored_message = encrypt_field_for_storage(message) @@ -1798,10 +1884,25 @@ def process_ingested_logs(payload: Dict[str, Any], db_path: str, window_hours: i INSERT INTO active_issues (fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status, last_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (fp, site_name, server, signature, severity, message, os_type, now_iso, now_iso, 1, initial_status, run_id)) + """, (fp, site_name, server, signature, severity, stored_message, os_type, now_iso, now_iso, 1, initial_status, run_id)) processed_count += 1 + # Update client node metrics if attached + metrics = payload.get("metrics") + if metrics and isinstance(metrics, dict) and client_server: + cpu_p = float(metrics.get("cpu_percent", 0.0)) + mem_p = float(metrics.get("memory_percent", 0.0)) + disk_p = float(metrics.get("disk_percent_free", 0.0)) + try: + cursor.execute(""" + UPDATE clients + SET last_seen = ?, metrics_json = ?, cpu_percent = ?, memory_percent = ?, disk_percent_free = ? + WHERE client_id = ? OR hostname = ? + """, (now_iso, json.dumps(metrics), cpu_p, mem_p, disk_p, client_server, client_server)) + except Exception: + pass + conn.commit() conn.close() @@ -2559,8 +2660,12 @@ def export_client_profile( """ Exports a standalone client profile JSON configuration bundle for zero-touch edge client bootstrapping. + Generates a fresh shared enrollment key and invalidates previous keys. """ config = SERVER_STATE.get("config", {}) + # Generate new shared enrollment secret and invalidate old key + rotate_enrollment_secret(config) + host = server_host or config.get("tcp_host", "127.0.0.1") if host in ("0.0.0.0", ""): host = socket.gethostname() @@ -2581,6 +2686,19 @@ def export_client_profile( ) +@app.post("/api/server/reset-enrollment-secret", dependencies=[Depends(require_admin_auth)]) +def reset_server_enrollment_secret(): + """ + Resets the shared enrollment secret key and immediately invalidates all previous keys. + """ + new_secret = rotate_enrollment_secret() + return { + "status": "success", + "enrollment_secret": new_secret, + "message": "Shared enrollment key has been reset and previous keys invalidated." + } + + @app.get("/api/clients", dependencies=[Depends(require_admin_auth)]) def list_clients(): """Returns all registered clients, seat usage, and activity timestamps.""" @@ -2731,13 +2849,14 @@ def api_delete_hermes_token(req: HermesTokenRevokeRequest): def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admin_auth)): """ Ingestion endpoint for Hermes agentic workflows. - Returns verified anomalies within the active 12-hour evaluation window - for Hermes AI direct reasoning and autonomous decision-making. + Returns verified anomalies within the active 12-hour evaluation window, + system resource context, and silent/offline forwarder alerts for Hermes AI reasoning. """ db_path = SERVER_STATE.get("config", {}).get("db_path", DEFAULT_DB_FILE) conn = sqlite3.connect(db_path) cursor = conn.cursor() + # 1. Fetch verified active issues cursor.execute(""" SELECT fingerprint, site_name, server, signature, severity, message, os_type, first_seen, last_seen, run_count, status FROM active_issues @@ -2745,15 +2864,69 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi ORDER BY last_seen DESC """) rows = cursor.fetchall() + + # 2. Check for offline / silent edge nodes (Heartbeat Liveness Check) + now = datetime.now(timezone.utc) + try: + cursor.execute("SELECT client_id, hostname, os_type, last_seen, metrics_json, cpu_percent, memory_percent, disk_percent_free FROM clients WHERE status = 'active'") + client_rows = cursor.fetchall() + except Exception: + client_rows = [] conn.close() window_hours = SERVER_STATE.get("config", {}).get("evaluation_window_hours", 12) report = [] + + # Map node telemetry and detect silent nodes + client_metrics_map = {} + for cl in client_rows: + cid, chost, cost, clast, cmetrics_raw, ccpu, cmem, cdisk = cl + try: + m_dict = json.loads(cmetrics_raw) if cmetrics_raw else {} + except Exception: + m_dict = {} + if not m_dict: + m_dict = { + "cpu_percent": ccpu or 0.0, + "memory_percent": cmem or 0.0, + "disk_percent_free": cdisk or 0.0 + } + client_metrics_map[cid] = m_dict + if chost: + client_metrics_map[chost] = m_dict + + # Liveness check: If active client has not checked in for > 15 minutes, report CRITICAL offline anomaly + if clast: + try: + clast_clean = str(clast).replace("T", " ").split("+")[0].split("Z")[0].strip() + last_dt = datetime.fromisoformat(clast_clean).replace(tzinfo=timezone.utc) + if (now - last_dt) > timedelta(minutes=15): + offline_mins = int((now - last_dt).total_seconds() // 60) + report.append({ + "fingerprint": f"offline-node-{cid}", + "site": "default", + "server": chost or cid, + "signature": "LOGAR/NodeSilent", + "severity": "CRITICAL", + "message": f"Forwarder node '{chost or cid}' has gone silent! Last heartbeat was {offline_mins} minutes ago ({clast}). Potential node crash, network partition, or power failure.", + "os_type": cost or "unknown", + "first_seen": clast, + "last_seen": clast, + "consecutive_runs": 1, + "evaluation_window": f"{window_hours}h", + "verified": True, + "status": "NODE_OFFLINE", + "metrics": m_dict + }) + except Exception: + pass + for r in rows: + srv_name = r[2] report.append({ "fingerprint": r[0], "site": r[1], - "server": r[2], + "server": srv_name, "signature": r[3], "severity": r[4], "message": decrypt_field_from_storage(r[5]), @@ -2763,7 +2936,8 @@ def get_verified_anomalies_for_hermes(auth: str = Depends(require_hermes_or_admi "consecutive_runs": r[9], "evaluation_window": f"{window_hours}h", "verified": True, - "status": r[10] + "status": r[10], + "metrics": client_metrics_map.get(srv_name, {}) }) return report @@ -3847,6 +4021,7 @@ def serve_webui(request: Request = None):