#!/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