Files
bigbrother/core/bus.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

186 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Multiprocess-safe event bus with pub/sub pattern.
Uses multiprocessing.Queue for cross-process event delivery.
A dedicated dispatcher thread reads from the queue and fans out
to registered subscriber callbacks.
"""
import time
import threading
import logging
import multiprocessing
from dataclasses import dataclass, field, asdict
from typing import Callable, Optional
logger = logging.getLogger(__name__)
# Canonical event types
EVENT_TYPES = frozenset([
"CREDENTIAL_FOUND", "HOST_DISCOVERED", "PCAP_ROTATED",
"MODULE_STARTED", "MODULE_STOPPED", "MODULE_ERROR",
"RESOURCE_WARNING", "RESOURCE_CRITICAL", "KILL_SWITCH",
"CONNECTIVITY_CHANGED", "VLAN_DETECTED", "HANDSHAKE_CAPTURED",
"TICKET_HARVESTED", "CLOUD_TOKEN_FOUND", "BEACON_DETECTED",
"CHANGE_DETECTED", "THERMAL_WARNING", "SCOPE_VIOLATION",
"TOOL_CRASHED", "TOOL_RESTARTED",
])
@dataclass
class Event:
"""Single event on the bus."""
event_type: str
payload: dict
timestamp: float = field(default_factory=time.time)
source_module: str = ""
def to_dict(self) -> dict:
return asdict(self)
class EventBus:
"""Multiprocess-safe publish/subscribe event bus.
Subscribers register a callback with an optional event_type filter.
Publishing pushes an Event into a shared mp.Queue. A background
dispatcher thread reads from the queue and invokes matching callbacks.
"""
def __init__(self, maxsize: int = 10000):
self._queue: multiprocessing.Queue = multiprocessing.Queue(maxsize=maxsize)
self._subscribers: dict[str, list[Callable]] = {} # event_type -> [callbacks]
self._wildcard_subscribers: list[Callable] = [] # receive all events
self._lock = threading.Lock()
self._dispatcher_thread: Optional[threading.Thread] = None
self._running = False
self._event_count = 0
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Start the dispatcher thread."""
if self._running:
return
self._running = True
self._dispatcher_thread = threading.Thread(
target=self._dispatch_loop, daemon=True, name="sensor-bus-dispatch"
)
self._dispatcher_thread.start()
logger.info("EventBus dispatcher started")
def stop(self) -> None:
"""Stop the dispatcher thread. Drains remaining events first."""
if not self._running:
return
self._running = False
# Push sentinel to unblock queue.get()
try:
self._queue.put_nowait(None)
except Exception:
pass
if self._dispatcher_thread and self._dispatcher_thread.is_alive():
self._dispatcher_thread.join(timeout=5.0)
logger.info("EventBus dispatcher stopped (dispatched %d events)", self._event_count)
# ------------------------------------------------------------------
# Pub / Sub
# ------------------------------------------------------------------
def subscribe(self, callback: Callable, event_type: Optional[str] = None) -> None:
"""Register a callback for events.
Args:
callback: Callable that takes a single Event argument.
event_type: If set, only events of this type are delivered.
If None, the callback receives ALL events.
"""
with self._lock:
if event_type is None:
self._wildcard_subscribers.append(callback)
else:
self._subscribers.setdefault(event_type, []).append(callback)
def unsubscribe(self, callback: Callable, event_type: Optional[str] = None) -> None:
"""Remove a previously registered callback."""
with self._lock:
if event_type is None:
try:
self._wildcard_subscribers.remove(callback)
except ValueError:
pass
else:
subs = self._subscribers.get(event_type, [])
try:
subs.remove(callback)
except ValueError:
pass
def publish(self, event: Event) -> None:
"""Publish an event onto the bus (multiprocess-safe).
Non-blocking: if the queue is full the event is dropped and a
warning is logged.
"""
try:
self._queue.put_nowait(event)
except Exception:
logger.warning("EventBus queue full — dropped %s from %s",
event.event_type, event.source_module)
def emit(self, event_type: str, payload: dict, source_module: str = "") -> None:
"""Convenience wrapper: build an Event and publish it."""
self.publish(Event(
event_type=event_type,
payload=payload,
source_module=source_module,
))
# ------------------------------------------------------------------
# Internal dispatcher
# ------------------------------------------------------------------
def _dispatch_loop(self) -> None:
"""Background loop: pull events from the mp.Queue, fan out to callbacks."""
while self._running:
try:
event = self._queue.get(timeout=0.5)
except Exception:
continue
if event is None: # sentinel
continue
self._event_count += 1
with self._lock:
targets = list(self._wildcard_subscribers)
targets.extend(self._subscribers.get(event.event_type, []))
for cb in targets:
try:
cb(event)
except Exception:
logger.exception("Subscriber callback error for %s", event.event_type)
# ------------------------------------------------------------------
# Introspection
# ------------------------------------------------------------------
@property
def event_count(self) -> int:
return self._event_count
@property
def queue_size(self) -> int:
try:
return self._queue.qsize()
except NotImplementedError:
return -1
def get_queue(self) -> multiprocessing.Queue:
"""Return the underlying mp.Queue for sharing with child processes."""
return self._queue