195 lines
6.4 KiB
Python
195 lines
6.4 KiB
Python
import os
|
|
import sys
|
|
import json
|
|
import socket
|
|
import struct
|
|
import argparse
|
|
import subprocess
|
|
import warnings
|
|
from datetime import datetime, timezone
|
|
|
|
# Suppress cryptography / pgpy deprecation notices
|
|
warnings.filterwarnings("ignore")
|
|
|
|
import pgpy
|
|
|
|
CONFIG_FILE_NAME = "client_config.json"
|
|
|
|
|
|
def load_config(config_path: str = CONFIG_FILE_NAME):
|
|
if not os.path.exists(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(config_path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def get_machine_identifier() -> str:
|
|
"""
|
|
Returns the hostname of the machine sending the logs,
|
|
and appends the network/DNS domain if available.
|
|
"""
|
|
fqdn = socket.getfqdn()
|
|
if fqdn and "." in fqdn and not fqdn.startswith("localhost"):
|
|
return fqdn
|
|
|
|
hostname = socket.gethostname()
|
|
|
|
try:
|
|
if os.path.exists("/etc/resolv.conf"):
|
|
with open("/etc/resolv.conf", "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
parts = line.strip().split()
|
|
if parts and parts[0] in ["domain", "search"] and len(parts) > 1:
|
|
domain = parts[1]
|
|
if domain and not domain.startswith("."):
|
|
return f"{hostname}.{domain}"
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
host_ip = socket.gethostbyname(hostname)
|
|
canonical_name = socket.gethostbyaddr(host_ip)[0]
|
|
if canonical_name and "." in canonical_name and not canonical_name.startswith("localhost"):
|
|
return canonical_name
|
|
except Exception:
|
|
pass
|
|
|
|
return hostname
|
|
|
|
|
|
def get_recent_linux_logs(hours: int = 6):
|
|
"""
|
|
Collects warnings and errors from systemd journalctl over the lookback window.
|
|
Edge Thinness: Drops INFO and DEBUG entries at the source.
|
|
"""
|
|
cmd = ["journalctl", "--since", f"{hours} hours ago", "-p", "warning", "--output=json"]
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
except FileNotFoundError:
|
|
print("[!] journalctl command not found. Ensure this script runs on a systemd-enabled Linux system.")
|
|
return []
|
|
except Exception as e:
|
|
print(f"[!] Error running journalctl: {e}")
|
|
return []
|
|
|
|
logs = []
|
|
machine_id = get_machine_identifier()
|
|
|
|
for line in result.stdout.splitlines():
|
|
line_str = line.strip()
|
|
if not line_str:
|
|
continue
|
|
try:
|
|
entry = json.loads(line_str)
|
|
priority = str(entry.get("PRIORITY", "4"))
|
|
if int(priority) > 4:
|
|
continue
|
|
|
|
sev = "WARNING" if priority == "4" else "ERROR"
|
|
logs.append({
|
|
"server": machine_id,
|
|
"os_type": "linux",
|
|
"signature": entry.get("SYSLOG_IDENTIFIER", "unknown"),
|
|
"severity": sev,
|
|
"message": entry.get("MESSAGE", "")[:2048]
|
|
})
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
|
|
return logs
|
|
|
|
|
|
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. Zero local state is maintained on the client.
|
|
"""
|
|
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()
|
|
|
|
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}."
|
|
)
|
|
|
|
machine_id = get_machine_identifier()
|
|
|
|
payload = {
|
|
"server": machine_id,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"logs": logs
|
|
}
|
|
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 main():
|
|
parser = argparse.ArgumentParser(description="LOGAR Linux Edge Log Forwarder (Zero State)")
|
|
parser.add_argument("--config", default=CONFIG_FILE_NAME, help="Path to client_config.json")
|
|
parser.add_argument("--hours", type=int, default=6, help="Lookback window in hours for journalctl logs")
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
config = load_config(args.config)
|
|
except Exception as e:
|
|
print(f"[!] Configuration error: {e}")
|
|
sys.exit(1)
|
|
|
|
machine_id = get_machine_identifier()
|
|
print(f"[*] Edge Forwarder Node: {machine_id}")
|
|
print(f"[*] Scanning Linux journalctl for candidate anomalies (last {args.hours} hours)...")
|
|
candidate_logs = get_recent_linux_logs(hours=args.hours)
|
|
print(f"[*] Found {len(candidate_logs)} candidate anomalies (noise stripped at source).")
|
|
|
|
try:
|
|
send_encrypted_logs_over_socket(config, candidate_logs)
|
|
except Exception as e:
|
|
print(f"[!] Failed to stream logs to server: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|