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:
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Packet capture bus — single AF_PACKET socket, demuxed to per-module queues.
|
||||
|
||||
One privileged capture process reads raw frames. Modules subscribe with a
|
||||
BPF filter expression and receive matching packets via per-module queues.
|
||||
Backpressure: if a module's queue is full, oldest packets are dropped from
|
||||
that queue only.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import struct
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import multiprocessing
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger("bb.capture_bus")
|
||||
|
||||
# ETH_P_ALL for capturing all protocols
|
||||
ETH_P_ALL = 0x0003
|
||||
|
||||
|
||||
class SubscriberQueue:
|
||||
"""Per-module packet queue with configurable depth and drop-oldest backpressure."""
|
||||
|
||||
__slots__ = ("name", "queue", "bpf_filter", "dropped", "delivered")
|
||||
|
||||
def __init__(self, name: str, bpf_filter: str = "", maxsize: int = 5000):
|
||||
self.name = name
|
||||
self.queue: multiprocessing.Queue = multiprocessing.Queue(maxsize=maxsize)
|
||||
self.bpf_filter = bpf_filter
|
||||
self.dropped = 0
|
||||
self.delivered = 0
|
||||
|
||||
def put(self, packet: bytes, timestamp: float) -> None:
|
||||
"""Push a packet, dropping oldest if full."""
|
||||
msg = (timestamp, packet)
|
||||
try:
|
||||
self.queue.put_nowait(msg)
|
||||
self.delivered += 1
|
||||
except Exception:
|
||||
# Queue full — drop oldest and retry
|
||||
try:
|
||||
self.queue.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.queue.put_nowait(msg)
|
||||
self.delivered += 1
|
||||
except Exception:
|
||||
pass
|
||||
self.dropped += 1
|
||||
|
||||
def get(self, timeout: float = 1.0) -> Optional[tuple]:
|
||||
"""Get (timestamp, packet) from queue. Returns None on timeout."""
|
||||
try:
|
||||
return self.queue.get(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class CaptureBus:
|
||||
"""Packet demultiplexer.
|
||||
|
||||
Opens a single AF_PACKET/SOCK_RAW socket on the specified interface,
|
||||
reads raw Ethernet frames, and distributes them to subscriber queues.
|
||||
|
||||
BPF filter compilation uses the kernel's SO_ATTACH_FILTER. If a
|
||||
subscriber's filter cannot be compiled at the kernel level (requires
|
||||
ctypes/tcpdump helper), the filter is applied in userspace as a
|
||||
fallback (basic port/protocol matching).
|
||||
"""
|
||||
|
||||
def __init__(self, interface: str = "eth0"):
|
||||
self.interface = interface
|
||||
self._subscribers: list[SubscriberQueue] = []
|
||||
self._lock = threading.Lock()
|
||||
self._capture_thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._total_packets = 0
|
||||
self._compiled_filters: dict[str, Callable] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the capture thread (requires root/CAP_NET_RAW)."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._sock = self._open_socket()
|
||||
self._capture_thread = threading.Thread(
|
||||
target=self._capture_loop, daemon=True, name="bb-capture"
|
||||
)
|
||||
self._capture_thread.start()
|
||||
logger.info("CaptureBus started on %s", self.interface)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop capturing."""
|
||||
self._running = False
|
||||
if self._capture_thread and self._capture_thread.is_alive():
|
||||
self._capture_thread.join(timeout=3.0)
|
||||
if self._sock:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
logger.info("CaptureBus stopped (total_packets=%d)", self._total_packets)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscriptions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe(self, name: str, bpf_filter: str = "",
|
||||
queue_depth: int = 5000) -> SubscriberQueue:
|
||||
"""Register a module subscriber with a BPF filter expression.
|
||||
|
||||
Args:
|
||||
name: Subscriber/module name.
|
||||
bpf_filter: tcpdump-style BPF filter (e.g. "udp port 53").
|
||||
queue_depth: Max packets buffered for this subscriber.
|
||||
|
||||
Returns:
|
||||
SubscriberQueue that the module reads from.
|
||||
"""
|
||||
sq = SubscriberQueue(name=name, bpf_filter=bpf_filter, maxsize=queue_depth)
|
||||
if bpf_filter:
|
||||
self._compiled_filters[name] = self._compile_filter(bpf_filter)
|
||||
with self._lock:
|
||||
self._subscribers.append(sq)
|
||||
logger.info("Subscriber %s registered (filter=%r, depth=%d)",
|
||||
name, bpf_filter, queue_depth)
|
||||
return sq
|
||||
|
||||
def unsubscribe(self, name: str) -> None:
|
||||
"""Remove a subscriber by name."""
|
||||
with self._lock:
|
||||
self._subscribers = [s for s in self._subscribers if s.name != name]
|
||||
self._compiled_filters.pop(name, None)
|
||||
logger.info("Subscriber %s removed", name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Socket
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _open_socket(self) -> socket.socket:
|
||||
"""Open a raw AF_PACKET socket bound to the interface."""
|
||||
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW,
|
||||
socket.htons(ETH_P_ALL))
|
||||
sock.bind((self.interface, 0))
|
||||
sock.settimeout(1.0)
|
||||
# Increase receive buffer
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
|
||||
except OSError:
|
||||
pass
|
||||
return sock
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capture loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _capture_loop(self) -> None:
|
||||
"""Read frames and dispatch to subscribers."""
|
||||
while self._running:
|
||||
try:
|
||||
raw_packet = self._sock.recv(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError as e:
|
||||
if self._running:
|
||||
logger.error("Socket read error: %s", e)
|
||||
break
|
||||
|
||||
ts = time.time()
|
||||
self._total_packets += 1
|
||||
|
||||
with self._lock:
|
||||
subscribers = list(self._subscribers)
|
||||
|
||||
for sq in subscribers:
|
||||
if sq.bpf_filter:
|
||||
matcher = self._compiled_filters.get(sq.name)
|
||||
if matcher and not matcher(raw_packet):
|
||||
continue
|
||||
sq.put(raw_packet, ts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Filter compilation (userspace fallback)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _compile_filter(bpf_expr: str) -> Callable:
|
||||
"""Compile a BPF filter expression into a Python matcher function.
|
||||
|
||||
This is a lightweight userspace implementation for common patterns.
|
||||
For full BPF support, use tcpdump to compile the filter and attach
|
||||
via SO_ATTACH_FILTER (not implemented here to avoid ctypes dep).
|
||||
"""
|
||||
expr = bpf_expr.strip().lower()
|
||||
|
||||
# "udp port N" / "tcp port N"
|
||||
if "port" in expr:
|
||||
parts = expr.split()
|
||||
proto = None
|
||||
port = None
|
||||
for i, p in enumerate(parts):
|
||||
if p in ("tcp", "udp"):
|
||||
proto = p
|
||||
if p == "port" and i + 1 < len(parts):
|
||||
try:
|
||||
port = int(parts[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if port is not None:
|
||||
return _make_port_matcher(proto, port)
|
||||
|
||||
# "ether proto N" (e.g., ARP = 0x0806)
|
||||
if "ether proto" in expr:
|
||||
parts = expr.split()
|
||||
idx = parts.index("proto") + 1
|
||||
if idx < len(parts):
|
||||
try:
|
||||
etype = int(parts[idx], 0)
|
||||
except ValueError:
|
||||
etype = None
|
||||
if etype is not None:
|
||||
return lambda pkt, et=etype: (len(pkt) >= 14 and
|
||||
struct.unpack("!H", pkt[12:14])[0] == et)
|
||||
|
||||
# Fallback: match everything
|
||||
logger.warning("Could not compile BPF filter %r — matching all packets", bpf_expr)
|
||||
return lambda pkt: True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def total_packets(self) -> int:
|
||||
return self._total_packets
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return capture and per-subscriber statistics."""
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
return {
|
||||
"interface": self.interface,
|
||||
"total_packets": self._total_packets,
|
||||
"running": self._running,
|
||||
"subscribers": {
|
||||
s.name: {
|
||||
"delivered": s.delivered,
|
||||
"dropped": s.dropped,
|
||||
"filter": s.bpf_filter,
|
||||
}
|
||||
for s in subs
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _make_port_matcher(proto: Optional[str], port: int) -> Callable:
|
||||
"""Create a matcher that checks TCP/UDP src or dst port."""
|
||||
TCP_PROTO = 6
|
||||
UDP_PROTO = 17
|
||||
|
||||
def matcher(pkt: bytes) -> bool:
|
||||
if len(pkt) < 34: # 14 eth + 20 ip minimum
|
||||
return False
|
||||
# Check EtherType = IPv4 (0x0800)
|
||||
if struct.unpack("!H", pkt[12:14])[0] != 0x0800:
|
||||
return False
|
||||
ip_proto = pkt[23]
|
||||
if proto == "tcp" and ip_proto != TCP_PROTO:
|
||||
return False
|
||||
if proto == "udp" and ip_proto != UDP_PROTO:
|
||||
return False
|
||||
if proto is None and ip_proto not in (TCP_PROTO, UDP_PROTO):
|
||||
return False
|
||||
# IP header length
|
||||
ihl = (pkt[14] & 0x0F) * 4
|
||||
transport_offset = 14 + ihl
|
||||
if len(pkt) < transport_offset + 4:
|
||||
return False
|
||||
src_port, dst_port = struct.unpack("!HH", pkt[transport_offset:transport_offset + 4])
|
||||
return src_port == port or dst_port == port
|
||||
|
||||
return matcher
|
||||
Reference in New Issue
Block a user