Add Phase 1 core infrastructure: event bus, state manager, engine, capture bus, tool manager, scheduler, resource monitor, kill switch
Core framework (8 files in core/) + BaseModule ABC (2 files in modules/): - bus.py: Multiprocess-safe pub/sub event bus using mp.Queue with background dispatcher thread, 20 canonical event types - state.py: SQLite persistent state with WAL mode, dedicated writer thread with 500ms coalesce, module status + generic key-value store - engine.py: Module lifecycle manager with multiprocess model, dependency resolution (topological sort), hardware tier detection (opi_zero3/pi_zero/generic), resource budgeting, scope enforcement, engagement phase gating - capture_bus.py: Single AF_PACKET socket packet demux with per-module subscriber queues, BPF filter matching, drop-oldest backpressure - tool_manager.py: Subprocess lifecycle for external tools (bettercap/tcpdump/Responder/mitmproxy/ntlmrelayx) with exponential backoff restart, /proc resource monitoring, process disguise via prctl, health check callbacks, graceful SIGTERM->SIGKILL shutdown - scheduler.py: Cron-like task scheduler with jitter, thread pool execution, enable/disable/run_now controls - resource_monitor.py: System + per-process RAM/CPU/disk/thermal monitoring, OOM kill lowest-priority module, thermal shedding at 70C - kill_switch.py: 7-phase wipe sequence (stop procs, corrective ARP, LUKS destroy, shred keys, zero-fill, clear RAM, reboot), boot flag for interrupted wipe resume, dead man's switch - modules/base.py: BaseModule ABC with start/stop/status/configure/health_check interface contract
This commit is contained in:
+433
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Module lifecycle manager.
|
||||
|
||||
Each BigBrother module runs in its own multiprocessing.Process.
|
||||
The Engine handles:
|
||||
- Loading module classes from the modules/ tree
|
||||
- Dependency resolution (topological sort)
|
||||
- Start / stop / restart / start_all / stop_all
|
||||
- Resource budgeting per hardware tier
|
||||
- Interface conflict detection
|
||||
- Scope enforcement
|
||||
- Engagement phase gating (passive_only for N days, then active_allowed)
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
import importlib
|
||||
import multiprocessing
|
||||
from collections import defaultdict, deque
|
||||
from typing import Optional, Type
|
||||
|
||||
from core.bus import EventBus, Event
|
||||
from core.state import StateManager
|
||||
from modules.base import BaseModule
|
||||
|
||||
logger = logging.getLogger("bb.engine")
|
||||
|
||||
# Hardware tier definitions — module limits and resource ceilings
|
||||
HARDWARE_TIERS = {
|
||||
"opi_zero3": {
|
||||
"max_passive": 16,
|
||||
"max_active": 6,
|
||||
"max_ram_mb": 3072,
|
||||
"max_cpu_pct": 80,
|
||||
"allow_mitmproxy": True,
|
||||
"allow_bettercap": True,
|
||||
},
|
||||
"pi_zero": {
|
||||
"max_passive": 4,
|
||||
"max_active": 0,
|
||||
"max_ram_mb": 400,
|
||||
"max_cpu_pct": 70,
|
||||
"allow_mitmproxy": False,
|
||||
"allow_bettercap": False,
|
||||
},
|
||||
"generic": {
|
||||
"max_passive": 16,
|
||||
"max_active": 99,
|
||||
"max_ram_mb": 0, # 0 = no limit
|
||||
"max_cpu_pct": 90,
|
||||
"allow_mitmproxy": True,
|
||||
"allow_bettercap": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_hardware_tier() -> str:
|
||||
"""Detect hardware tier from /proc/device-tree/model and /proc/cpuinfo."""
|
||||
model = ""
|
||||
try:
|
||||
with open("/proc/device-tree/model", "r") as f:
|
||||
model = f.read().strip().lower()
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
|
||||
if "orange pi zero3" in model or "orange pi zero 3" in model:
|
||||
return "opi_zero3"
|
||||
|
||||
# Check cpuinfo for known SoCs
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
cpuinfo = f.read().lower()
|
||||
except (FileNotFoundError, PermissionError):
|
||||
cpuinfo = ""
|
||||
|
||||
if "allwinner" in cpuinfo and "h618" in cpuinfo:
|
||||
return "opi_zero3"
|
||||
|
||||
if "raspberry pi zero 2" in model or "bcm2710" in cpuinfo:
|
||||
return "pi_zero"
|
||||
|
||||
return "generic"
|
||||
|
||||
|
||||
def _topo_sort(modules: dict[str, "ModuleEntry"]) -> list[str]:
|
||||
"""Topological sort of modules by dependency. Returns ordered list of names."""
|
||||
in_degree: dict[str, int] = defaultdict(int)
|
||||
graph: dict[str, list[str]] = defaultdict(list)
|
||||
all_names = set(modules.keys())
|
||||
|
||||
for name, entry in modules.items():
|
||||
for dep in entry.module_class.dependencies:
|
||||
if dep in all_names:
|
||||
graph[dep].append(name)
|
||||
in_degree[name] += 1
|
||||
if name not in in_degree:
|
||||
in_degree[name] = 0
|
||||
|
||||
queue = deque(n for n, d in in_degree.items() if d == 0)
|
||||
order = []
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
order.append(node)
|
||||
for neighbor in graph[node]:
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
if len(order) != len(all_names):
|
||||
missing = all_names - set(order)
|
||||
logger.error("Circular dependency detected involving: %s", missing)
|
||||
# Append remaining modules anyway (best-effort)
|
||||
order.extend(missing)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
class ModuleEntry:
|
||||
"""Registry entry for a loaded module."""
|
||||
__slots__ = ("module_class", "config", "process", "started_at")
|
||||
|
||||
def __init__(self, module_class: Type[BaseModule], config: dict):
|
||||
self.module_class = module_class
|
||||
self.config = config
|
||||
self.process: Optional[multiprocessing.Process] = None
|
||||
self.started_at: Optional[float] = None
|
||||
|
||||
|
||||
def _module_runner(module_class: Type[BaseModule], bus_queue: multiprocessing.Queue,
|
||||
state_db_path: str, config: dict, engine_pipe) -> None:
|
||||
"""Entry point for each module process.
|
||||
|
||||
Instantiates the module, wires up a local EventBus that forwards
|
||||
events through the shared mp.Queue, and calls module.start().
|
||||
"""
|
||||
# Build a lightweight bus proxy that pushes to the shared queue
|
||||
class _BusProxy:
|
||||
def __init__(self, queue):
|
||||
self._q = queue
|
||||
|
||||
def publish(self, event):
|
||||
try:
|
||||
self._q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def emit(self, event_type, payload, source_module=""):
|
||||
self.publish(Event(
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
source_module=source_module,
|
||||
))
|
||||
|
||||
def subscribe(self, *a, **kw):
|
||||
pass # Subscriptions only work in the main process
|
||||
|
||||
bus_proxy = _BusProxy(bus_queue)
|
||||
state = StateManager(db_path=state_db_path)
|
||||
state.start()
|
||||
|
||||
module = module_class(bus=bus_proxy, state=state, config=config)
|
||||
|
||||
# Signal handlers for graceful shutdown
|
||||
def _handle_term(sig, frame):
|
||||
try:
|
||||
module.stop()
|
||||
except Exception:
|
||||
pass
|
||||
state.stop()
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_term)
|
||||
signal.signal(signal.SIGINT, _handle_term)
|
||||
|
||||
try:
|
||||
module.start()
|
||||
except Exception:
|
||||
logger.exception("Module %s crashed in start()", module.name)
|
||||
bus_proxy.emit("MODULE_ERROR", {"module": module.name, "error": "start_crash"},
|
||||
source_module=module.name)
|
||||
finally:
|
||||
state.stop()
|
||||
|
||||
|
||||
class Engine:
|
||||
"""Module lifecycle manager.
|
||||
|
||||
Manages multiprocess module instances with dependency resolution,
|
||||
hardware tier enforcement, scope checking, and phase gating.
|
||||
"""
|
||||
|
||||
def __init__(self, bus: EventBus, state: StateManager, config: dict):
|
||||
self.bus = bus
|
||||
self.state = state
|
||||
self.config = config
|
||||
self._modules: dict[str, ModuleEntry] = {}
|
||||
self._tier = config.get("device", {}).get("platform", "auto")
|
||||
if self._tier == "auto":
|
||||
self._tier = detect_hardware_tier()
|
||||
self._tier_limits = HARDWARE_TIERS.get(self._tier, HARDWARE_TIERS["generic"])
|
||||
self._scope = config.get("scope", {})
|
||||
self._phase = config.get("engagement_phase", {}).get("mode", "passive_only")
|
||||
self._lock = multiprocessing.Lock()
|
||||
logger.info("Engine initialized (tier=%s, phase=%s)", self._tier, self._phase)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Module registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def register(self, module_class: Type[BaseModule], config: dict = None) -> None:
|
||||
"""Register a module class for management."""
|
||||
name = module_class.name
|
||||
if name in self._modules:
|
||||
logger.warning("Module %s already registered, replacing", name)
|
||||
self._modules[name] = ModuleEntry(module_class, config or {})
|
||||
logger.debug("Registered module: %s (type=%s, priority=%d)",
|
||||
name, module_class.module_type, module_class.priority)
|
||||
|
||||
def load_module(self, module_path: str, config: dict = None) -> None:
|
||||
"""Dynamically load a module from a dotted Python path.
|
||||
|
||||
e.g. "modules.passive.dns_logger.DnsLogger"
|
||||
"""
|
||||
parts = module_path.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Expected 'package.ClassName', got: {module_path}")
|
||||
mod = importlib.import_module(parts[0])
|
||||
cls = getattr(mod, parts[1])
|
||||
if not (isinstance(cls, type) and issubclass(cls, BaseModule)):
|
||||
raise TypeError(f"{module_path} is not a BaseModule subclass")
|
||||
self.register(cls, config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self, name: str) -> bool:
|
||||
"""Start a single module by name. Returns True on success."""
|
||||
with self._lock:
|
||||
entry = self._modules.get(name)
|
||||
if entry is None:
|
||||
logger.error("Module %s not registered", name)
|
||||
return False
|
||||
|
||||
if entry.process and entry.process.is_alive():
|
||||
logger.warning("Module %s already running", name)
|
||||
return True
|
||||
|
||||
# Phase gating
|
||||
if not self._phase_allows(entry.module_class):
|
||||
logger.warning("Module %s blocked by phase=%s", name, self._phase)
|
||||
return False
|
||||
|
||||
# Tier limits
|
||||
if not self._tier_allows(entry.module_class):
|
||||
logger.warning("Module %s blocked by tier=%s limits", name, self._tier)
|
||||
return False
|
||||
|
||||
# Dependency check
|
||||
for dep in entry.module_class.dependencies:
|
||||
dep_entry = self._modules.get(dep)
|
||||
if dep_entry is None or not (dep_entry.process and dep_entry.process.is_alive()):
|
||||
logger.error("Module %s requires %s which is not running", name, dep)
|
||||
return False
|
||||
|
||||
proc = multiprocessing.Process(
|
||||
target=_module_runner,
|
||||
args=(
|
||||
entry.module_class,
|
||||
self.bus.get_queue(),
|
||||
self.state._db_path,
|
||||
entry.config,
|
||||
None,
|
||||
),
|
||||
name=f"bb-{name}",
|
||||
daemon=True,
|
||||
)
|
||||
proc.start()
|
||||
entry.process = proc
|
||||
entry.started_at = time.time()
|
||||
|
||||
self.state.set_module_status(name, "running", pid=proc.pid)
|
||||
self.bus.emit("MODULE_STARTED", {"module": name, "pid": proc.pid},
|
||||
source_module="engine")
|
||||
logger.info("Started module %s (pid=%d)", name, proc.pid)
|
||||
return True
|
||||
|
||||
def stop(self, name: str, timeout: float = 5.0) -> bool:
|
||||
"""Stop a module. SIGTERM -> wait -> SIGKILL."""
|
||||
with self._lock:
|
||||
entry = self._modules.get(name)
|
||||
if entry is None or entry.process is None:
|
||||
return True
|
||||
|
||||
proc = entry.process
|
||||
if proc.is_alive():
|
||||
proc.terminate()
|
||||
proc.join(timeout=timeout)
|
||||
if proc.is_alive():
|
||||
logger.warning("Module %s did not stop, sending SIGKILL", name)
|
||||
proc.kill()
|
||||
proc.join(timeout=2.0)
|
||||
|
||||
entry.process = None
|
||||
entry.started_at = None
|
||||
self.state.set_module_status(name, "stopped")
|
||||
self.bus.emit("MODULE_STOPPED", {"module": name}, source_module="engine")
|
||||
logger.info("Stopped module %s", name)
|
||||
return True
|
||||
|
||||
def restart(self, name: str) -> bool:
|
||||
"""Stop then start a module."""
|
||||
self.stop(name)
|
||||
time.sleep(0.2)
|
||||
return self.start(name)
|
||||
|
||||
def start_all(self) -> dict[str, bool]:
|
||||
"""Start all registered modules in dependency order."""
|
||||
order = _topo_sort(self._modules)
|
||||
results = {}
|
||||
for name in order:
|
||||
results[name] = self.start(name)
|
||||
return results
|
||||
|
||||
def stop_all(self, timeout: float = 5.0) -> None:
|
||||
"""Stop all running modules in reverse dependency order."""
|
||||
order = _topo_sort(self._modules)
|
||||
for name in reversed(order):
|
||||
self.stop(name, timeout=timeout)
|
||||
|
||||
def get_status(self, name: str = None) -> dict:
|
||||
"""Get status of one module or all modules."""
|
||||
if name:
|
||||
entry = self._modules.get(name)
|
||||
if entry is None:
|
||||
return {"error": "not registered"}
|
||||
alive = entry.process.is_alive() if entry.process else False
|
||||
return {
|
||||
"name": name,
|
||||
"running": alive,
|
||||
"pid": entry.process.pid if entry.process else None,
|
||||
"uptime": time.time() - entry.started_at if entry.started_at and alive else 0,
|
||||
"type": entry.module_class.module_type,
|
||||
"priority": entry.module_class.priority,
|
||||
}
|
||||
|
||||
result = {}
|
||||
for n in self._modules:
|
||||
result[n] = self.get_status(n)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gating / enforcement
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _phase_allows(self, cls: Type[BaseModule]) -> bool:
|
||||
"""Check if current engagement phase allows this module type."""
|
||||
if self._phase == "passive_only":
|
||||
return cls.module_type in ("passive", "stealth", "intel", "connectivity")
|
||||
return True # active_allowed or unrestricted
|
||||
|
||||
def _tier_allows(self, cls: Type[BaseModule]) -> bool:
|
||||
"""Check if hardware tier limits allow this module."""
|
||||
limits = self._tier_limits
|
||||
running_by_type = self._count_running_by_type()
|
||||
|
||||
if cls.module_type == "passive":
|
||||
if running_by_type.get("passive", 0) >= limits["max_passive"]:
|
||||
return False
|
||||
elif cls.module_type == "active":
|
||||
if running_by_type.get("active", 0) >= limits["max_active"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _count_running_by_type(self) -> dict[str, int]:
|
||||
"""Count running modules by type."""
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for entry in self._modules.values():
|
||||
if entry.process and entry.process.is_alive():
|
||||
counts[entry.module_class.module_type] += 1
|
||||
return dict(counts)
|
||||
|
||||
def set_phase(self, phase: str) -> None:
|
||||
"""Switch engagement phase (passive_only | active_allowed)."""
|
||||
self._phase = phase
|
||||
logger.info("Engagement phase set to: %s", phase)
|
||||
|
||||
def check_scope(self, target: str) -> bool:
|
||||
"""Check if a target IP/hostname is within engagement scope."""
|
||||
if not self._scope.get("enabled", False):
|
||||
return True
|
||||
|
||||
import ipaddress
|
||||
|
||||
networks = self._scope.get("networks", [])
|
||||
hosts = self._scope.get("hosts", [])
|
||||
exclude = self._scope.get("exclude", [])
|
||||
|
||||
# Check exclusions first
|
||||
try:
|
||||
addr = ipaddress.ip_address(target)
|
||||
for exc in exclude:
|
||||
if addr in ipaddress.ip_network(exc, strict=False):
|
||||
return False
|
||||
for net in networks:
|
||||
if addr in ipaddress.ip_network(net, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
pass # Not an IP, check hostname
|
||||
|
||||
if target in hosts:
|
||||
return True
|
||||
|
||||
domains = self._scope.get("domains", [])
|
||||
for dom in domains:
|
||||
if target.endswith("." + dom) or target == dom:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def tier(self) -> str:
|
||||
return self._tier
|
||||
|
||||
@property
|
||||
def tier_limits(self) -> dict:
|
||||
return dict(self._tier_limits)
|
||||
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return self._phase
|
||||
Reference in New Issue
Block a user