feat(linux_client): add mTLS connection and automatic enrollment in src/Linux_Client.py
This commit is contained in:
+120
-18
@@ -2,9 +2,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import struct
|
import struct
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
@@ -18,6 +20,68 @@ CONFIG_FILE_NAME = "client_config.json"
|
|||||||
STATE_FILE_NAME = "client_state.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:
|
def get_state_path(config_path: str, custom_state_path: Optional[str] = None) -> str:
|
||||||
if custom_state_path:
|
if custom_state_path:
|
||||||
return 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):
|
def send_encrypted_logs_over_socket(config: dict, logs: list):
|
||||||
"""
|
"""
|
||||||
Encrypts the payload using the server's OpenPGP public key and streams
|
Streams logs to the LOGAR hub.
|
||||||
over an authenticated TCP socket.
|
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_host = config["server_host"]
|
||||||
server_port = int(config["server_port"])
|
server_port = int(config["server_port"])
|
||||||
auth_token = config["auth_token"]
|
cert_dir = config.get("cert_dir", "certs")
|
||||||
pub_key_armored = config["server_public_key"]
|
enrollment_secret = config.get("enrollment_secret")
|
||||||
expected_fp = config.get("server_fingerprint", "").replace(" ", "").upper()
|
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)
|
pub_key, _ = pgpy.PGPKey.from_blob(pub_key_armored)
|
||||||
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
actual_fp = str(pub_key.fingerprint).replace(" ", "").upper()
|
||||||
if expected_fp and actual_fp != expected_fp:
|
if expected_fp and actual_fp != expected_fp:
|
||||||
raise ValueError(
|
raise ValueError(f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}.")
|
||||||
f"Server fingerprint mismatch! Expected {expected_fp}, but key has {actual_fp}."
|
|
||||||
)
|
|
||||||
|
|
||||||
machine_id = get_machine_identifier()
|
|
||||||
|
|
||||||
# Prepare batch
|
|
||||||
payload = {
|
payload = {
|
||||||
"server": machine_id,
|
"server": machine_id,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"logs": logs
|
"logs": logs
|
||||||
}
|
}
|
||||||
payload_json = json.dumps(payload)
|
payload_json = json.dumps(payload)
|
||||||
|
|
||||||
# Encrypt payload with server's encryption-only key
|
|
||||||
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
pgp_msg = pgpy.PGPMessage.new(payload_json)
|
||||||
encrypted_msg = pub_key.encrypt(pgp_msg)
|
encrypted_msg = pub_key.encrypt(pgp_msg)
|
||||||
encrypted_armored = str(encrypted_msg)
|
encrypted_armored = str(encrypted_msg)
|
||||||
|
|
||||||
# Envelope with socket authentication header
|
|
||||||
envelope = {
|
envelope = {
|
||||||
"auth_token": auth_token,
|
"auth_token": auth_token,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"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")
|
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...")
|
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:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
sock.settimeout(15.0)
|
sock.settimeout(15.0)
|
||||||
sock.connect((server_host, server_port))
|
sock.connect((server_host, server_port))
|
||||||
|
|
||||||
# Send frame: length (4 bytes big-endian) + envelope
|
|
||||||
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
frame = struct.pack(">I", len(envelope_bytes)) + envelope_bytes
|
||||||
sock.sendall(frame)
|
sock.sendall(frame)
|
||||||
|
|
||||||
# Receive response length
|
|
||||||
resp_len_bytes = sock.recv(4)
|
resp_len_bytes = sock.recv(4)
|
||||||
if not resp_len_bytes:
|
if not resp_len_bytes:
|
||||||
raise ConnectionError("Server closed connection without response.")
|
raise ConnectionError("Server closed connection without response.")
|
||||||
|
|||||||
Reference in New Issue
Block a user