From ccb23d65e50881b440d73270844904fe63922cde Mon Sep 17 00:00:00 2001 From: max Date: Fri, 4 Sep 2026 20:54:00 +0200 Subject: [PATCH] feat(linux_client): add mTLS connection and automatic enrollment in src/Linux_Client.py --- src/Linux_Client.py | 138 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/src/Linux_Client.py b/src/Linux_Client.py index 930d618..2a0765c 100644 --- a/src/Linux_Client.py +++ b/src/Linux_Client.py @@ -2,9 +2,11 @@ import os import sys import json import socket +import ssl import struct import argparse import subprocess +import urllib.request import warnings from datetime import datetime, timezone, timedelta from typing import Optional, Dict, Any, List @@ -18,6 +20,68 @@ CONFIG_FILE_NAME = "client_config.json" STATE_FILE_NAME = "client_state.json" +def enroll_client_if_needed(hub_url: str, enrollment_secret: str, cert_dir: str, client_id: str, hostname: str, os_type: str = "linux"): + """Bootstraps client enrollment if certificates are missing.""" + 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 os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path): + return True + + print(f"[*] Bootstrapping client enrollment 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 enrolled successfully! Certificates saved to {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 @@ -227,39 +291,80 @@ def get_recent_linux_logs(hours: int = 24, state: Optional[dict] = None) -> list def send_encrypted_logs_over_socket(config: dict, logs: list): """ - Encrypts the payload using the server's OpenPGP public key and streams - over an authenticated TCP socket. + 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"]) - auth_token = config["auth_token"] - pub_key_armored = config["server_public_key"] - expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper() + cert_dir = config.get("cert_dir", "certs") + enrollment_secret = config.get("enrollment_secret") + machine_id = get_machine_identifier() - # Load and verify server public key + # Attempt automatic enrollment bootstrap if certs are missing and secret is provided + if enrollment_secret: + hermes_host = config.get("hermes_host", server_host) + 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") + 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)...") + with get_tls_socket(server_host, server_port, cert_dir) as 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}." - ) + raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.") - machine_id = get_machine_identifier() - - # Prepare batch payload = { "server": machine_id, "timestamp": datetime.now(timezone.utc).isoformat(), "logs": logs } payload_json = json.dumps(payload) - - # Encrypt payload with server's encryption-only key pgp_msg = pgpy.PGPMessage.new(payload_json) encrypted_msg = pub_key.encrypt(pgp_msg) encrypted_armored = str(encrypted_msg) - # Envelope with socket authentication header envelope = { "auth_token": auth_token, "timestamp": datetime.now(timezone.utc).isoformat(), @@ -267,17 +372,14 @@ def send_encrypted_logs_over_socket(config: dict, logs: list): } envelope_bytes = json.dumps(envelope).encode("utf-8") - # Connect over TCP socket and transmit with 4-byte length prefix framing 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)) - # Send frame: length (4 bytes big-endian) + envelope frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes sock.sendall(frame) - # Receive response length resp_len_bytes = sock.recv(4) if not resp_len_bytes: raise ConnectionError("Server closed connection without response.")