Files
LOGAR/src/client_transport.py
T
me0nline 2c08f9fc15
CI Test Suite / Run Component Tests & Pipeline Verification (push) Failing after 1m14s
Add server update alerting and fleet client auto-update system (synchronized release lockstep)
2026-10-02 22:08:51 +02:00

341 lines
14 KiB
Python

"""
LOGAR - Unified Client Transport & PKI Enrollment Module
Encapsulates mutual TLS (mTLS 1.3), OpenPGP fallback envelope framing,
and dynamic certificate enrollment with the LOGAR Server Hub.
"""
import os
import sys
import json
import socket
import ssl
import struct
import urllib.request
import warnings
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Dict, Any
# Suppress cryptography / pgpy deprecation notices
warnings.filterwarnings("ignore")
import pgpy
from log_collector import (
get_machine_identifier,
is_path_writable,
get_default_data_dir,
resolve_writable_path,
collect_system_metrics
)
try:
from src.version import LOGAR_VERSION
from src.client_updater import ClientUpdater
except ImportError:
from version import LOGAR_VERSION
from client_updater import ClientUpdater
CONFIG_FILE_NAME = "client_config.json"
def is_cert_expiring_soon(cert_path: str, threshold_days: int = 30) -> bool:
"""Checks if client certificate at cert_path is expiring within threshold_days."""
if not os.path.exists(cert_path):
return True
try:
from cryptography import x509
with open(cert_path, "r", encoding="utf-8") as f:
cert = x509.load_pem_x509_certificate(f.read().encode("utf-8"))
expiry = getattr(cert, "not_valid_after_utc", None)
if expiry is None:
expiry = cert.not_valid_after.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
return expiry <= (now + timedelta(days=threshold_days))
except Exception:
return True
def enroll_client_if_needed(
hub_url: str,
enrollment_secret: str,
cert_dir: str,
client_id: str,
hostname: str,
os_type: str = "windows",
force_renew: bool = False,
threshold_days: int = 30
) -> bool:
"""Bootstraps client enrollment if certificates are missing or expiring soon."""
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 not force_renew and os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path):
if not is_cert_expiring_soon(cert_path, threshold_days=threshold_days):
return True
print(f"[*] Client certificate at {cert_path} is expiring within {threshold_days} days. Auto-renewing...")
action_name = "re-enrolling" if os.path.exists(cert_path) else "enrolling"
print(f"[*] Bootstrapping client {action_name} 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,
"version": LOGAR_VERSION
}
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 certificates updated successfully in {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 load_config(config_path: str = CONFIG_FILE_NAME) -> dict:
"""Loads and normalizes client configuration JSON."""
actual_config_path = config_path
if not os.path.exists(actual_config_path):
data_dir_cfg = os.path.join(get_default_data_dir("LOGAR"), os.path.basename(config_path))
if os.path.exists(data_dir_cfg):
actual_config_path = data_dir_cfg
if not os.path.exists(actual_config_path):
raise FileNotFoundError(
f"Client configuration file not found at: {config_path}\n"
f"Generate one from the server using: python Server.py --create-client-config --client-out {config_path}"
)
with open(actual_config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# Resolve cert_dir to a writable directory
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
def ensure_enrolled(config: dict, machine_id: str, os_type: str = "windows") -> 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=os_type)
except Exception as e:
print(f"[!] Warning: Initial client enrollment bootstrap with Hub at {hub_url} failed: {e}")
return False
def send_encrypted_logs_over_socket(
config: dict,
logs: list,
os_type: Optional[str] = None,
machine_id: Optional[str] = None
) -> dict:
"""
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 socket.
"""
node_os = os_type or ("windows" if sys.platform == "win32" else "linux")
server_host = config["server_host"]
server_port = int(config["server_port"])
cert_dir = config.get("cert_dir", "certs")
enrollment_secret = config.get("enrollment_secret")
actual_machine_id = machine_id or config.get("client_id") or get_machine_identifier()
# Attempt automatic enrollment bootstrap if certs are missing and secret is provided
hub_url = None
if enrollment_secret:
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:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
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 not has_mtls_certs and enrollment_secret and hub_url:
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os)
has_mtls_certs = os.path.exists(ca_path) and os.path.exists(cert_path) and os.path.exists(key_path)
except Exception as e:
print(f"[!] Warning: Enrollment before TLS connection failed: {e}")
if has_mtls_certs:
print(f"[*] Connecting to LOGAR server at {server_host}:{server_port} over mTLS (TLS 1.3)...")
sock = None
try:
sock = get_tls_socket(server_host, server_port, cert_dir)
except (ssl.SSLError, ssl.CertificateError, ConnectionResetError) as tls_err:
if enrollment_secret and hub_url:
print(f"[!] TLS handshake error ({tls_err}). Re-enrolling with LOGAR Hub...")
try:
enroll_client_if_needed(hub_url, enrollment_secret, cert_dir, actual_machine_id, actual_machine_id, os_type=node_os, force_renew=True)
sock = get_tls_socket(server_host, server_port, cert_dir)
except Exception as retry_err:
print(f"[!] Re-enrollment or reconnection retry failed: {retry_err}")
raise
else:
raise
with sock:
payload = {
"server": actual_machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs,
"metrics": collect_system_metrics(),
"version": LOGAR_VERSION
}
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}.")
payload = {
"server": actual_machine_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"logs": logs,
"metrics": collect_system_metrics(),
"version": LOGAR_VERSION
}
payload_json = json.dumps(payload)
pgp_msg = pgpy.PGPMessage.new(payload_json)
encrypted_msg = pub_key.encrypt(pgp_msg)
encrypted_armored = str(encrypted_msg)
envelope = {
"auth_token": auth_token,
"timestamp": datetime.now(timezone.utc).isoformat(),
"encrypted_payload": encrypted_armored
}
envelope_bytes = json.dumps(envelope).encode("utf-8")
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))
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
sock.sendall(frame)
resp_len_bytes = sock.recv(4)
if not resp_len_bytes:
raise ConnectionError("Server closed 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
def check_and_apply_auto_update(config: dict, machine_id: str, os_type: Optional[str] = None, auto_apply: bool = True) -> Dict[str, Any]:
"""Helper to check and apply auto updates for edge forwarders."""
updater = ClientUpdater(config=config, os_type=os_type)
return updater.check_and_apply_auto_update(client_id=machine_id, auto_apply=auto_apply)