diff --git a/compilation/installer_client.iss b/compilation/installer_client.iss index 770bcc4..bc2dd5d 100644 --- a/compilation/installer_client.iss +++ b/compilation/installer_client.iss @@ -31,7 +31,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 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 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 [UninstallRun] diff --git a/compilation/installer_client.nsi b/compilation/installer_client.nsi index cc414be..5683dd5 100644 --- a/compilation/installer_client.nsi +++ b/compilation/installer_client.nsi @@ -124,7 +124,7 @@ Section "Install" 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 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' WriteUninstaller "$INSTDIR\uninstall.exe" diff --git a/src/Linux_Client.py b/src/Linux_Client.py index 86202fe..b5c49f8 100644 --- a/src/Linux_Client.py +++ b/src/Linux_Client.py @@ -1,6 +1,7 @@ import os import sys import json +import time import socket import ssl import struct @@ -168,7 +169,25 @@ def load_config(config_path: str = CONFIG_FILE_NAME): 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) + config = json.load(f) + + cfg_dir = os.path.dirname(os.path.abspath(config_path)) + raw_cert_dir = config.get("cert_dir", "certs") + if not os.path.isabs(raw_cert_dir): + config["cert_dir"] = os.path.join(cfg_dir, raw_cert_dir) + + 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 get_machine_identifier() -> str: @@ -452,12 +471,33 @@ def send_encrypted_logs_over_socket(config: dict, logs: list): return response +def ensure_enrolled(config: dict, machine_id: str) -> 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="linux") + except Exception as e: + print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}") + return False + + def main(): parser = argparse.ArgumentParser(description="LOGAR Linux 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 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("--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() try: @@ -469,31 +509,47 @@ def main(): 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() + machine_id = config.get("client_id") or get_machine_identifier() print(f"[*] Edge Forwarder Node: {machine_id}") 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})") elif not args.no_state: print(f"[*] State tracking initialized (state file: {state_path})") - print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") - candidate_logs = get_recent_linux_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).") + # Proactively ensure dynamic PKI certificate enrollment with Server Hub + ensure_enrolled(config, machine_id) - if not candidate_logs: - print("[*] No new unsent events to transmit.") - if state is not None: - commit_state(state, state_path) - return + def run_forwarder_cycle(): + print(f"[*] Scanning Linux journalctl for unsent entries (last {args.hours} hours)...") + candidate_logs = get_recent_linux_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).") - 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 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__": diff --git a/src/Server.py b/src/Server.py index ccf90bc..4a09381 100644 --- a/src/Server.py +++ b/src/Server.py @@ -2082,7 +2082,6 @@ def serve_webui(request: Request = None):
-
@@ -2461,7 +2460,6 @@ def serve_webui(request: Request = None):
PKI & Seat Synchronization:
-
diff --git a/src/Win_Client.py b/src/Win_Client.py index 982351d..d1bc7f6 100644 --- a/src/Win_Client.py +++ b/src/Win_Client.py @@ -1,6 +1,7 @@ import os import sys import json +import time import socket import ssl import struct @@ -247,8 +248,30 @@ def load_config(config_path: str = CONFIG_FILE_NAME): 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") + # Resolve cert_dir to a writable directory (defaulting next to the config file if relative) + 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 @@ -500,12 +523,33 @@ def send_encrypted_logs_over_socket(config: dict, logs: list): return response +def ensure_enrolled(config: dict, machine_id: str) -> 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="windows") + except Exception as e: + print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}") + return False + + 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") + 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() try: @@ -517,31 +561,47 @@ def main(): 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() + machine_id = config.get("client_id") or 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).") + # Proactively ensure dynamic PKI certificate enrollment with Server Hub + ensure_enrolled(config, machine_id) - if not candidate_logs: - print("[*] No new unsent events to transmit.") - if state is not None: - commit_state(state, state_path) - return + def run_forwarder_cycle(): + 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).") - 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 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__": diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 249a9fd..59f54dd 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -31,8 +31,14 @@ HERMES_PORT = 8443 def run_tests(): - print("=== [1] Verifying server_config.json & client_config.json ===") - server_cfg_path = "server_config.json" if os.path.exists("server_config.json") else os.path.join(ROOT_DIR, "server_config.json") + server_cfg_path = "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") assert os.path.exists(server_cfg_path), f"{server_cfg_path} must exist"