#!/usr/bin/env python3 """Bridge module — transparent inline network bridge. Creates a transparent L2 bridge between the onboard ethernet adapter and a USB ethernet adapter. The bridge has NO IP address — it is invisible on the network and can only be detected by physical inspection. Provides 802.1X bypass via silent bridge (EAP passthrough) and suppresses STP/BPDU/CDP/LLDP frames via ebtables to avoid triggering network monitoring. A C watchdog thread monitors bridge health and restores direct connectivity within 15 seconds if the bridge fails. """ import logging import os import subprocess import threading import time from pathlib import Path from typing import Optional from modules.base import BaseModule logger = logging.getLogger("sensor.connectivity.bridge") BRIDGE_NAME = "br0" WATCHDOG_INTERVAL = 5.0 # seconds between health checks WATCHDOG_MAX_FAILURES = 3 # consecutive failures before teardown FAILSAFE_TIMEOUT = 15 # seconds — must restore connectivity within this class Bridge(BaseModule): """Transparent inline bridge — invisible on the network.""" name = "bridge" module_type = "connectivity" priority = -300 requires_root = True def __init__(self, bus, state, config, engine=None): super().__init__(bus, state, config, engine) self._if1: Optional[str] = None self._if2: Optional[str] = None self._bridge_up = False self._watchdog_thread: Optional[threading.Thread] = None self._consecutive_failures = 0 self._scripts_dir: Optional[str] = None # ------------------------------------------------------------------ # BaseModule interface # ------------------------------------------------------------------ def start(self) -> None: if self._running: return br_cfg = self.config.get("connectivity", {}).get("bridge", {}) if not br_cfg: logger.error("No bridge config in bigbrother.yaml") self.state.set_module_status(self.name, "error") return self._if1 = br_cfg.get("interface1") self._if2 = br_cfg.get("interface2") if not self._if1 or not self._if2: logger.error("Bridge requires interface1 and interface2 in config") self.state.set_module_status(self.name, "error") return # Locate scripts directory install_path = self.config.get("device", {}).get("install_path", "/opt/.cache/bb") self._scripts_dir = os.path.join(install_path, "scripts") if not self.setup_bridge(self._if1, self._if2): self.state.set_module_status(self.name, "error") return # Start watchdog thread self._running = True self._pid = os.getpid() self._start_time = time.time() self._bridge_up = True self._watchdog_thread = threading.Thread( target=self._watchdog_loop, daemon=True, name="sensor-bridge-watchdog", ) self._watchdog_thread.start() self.state.set_module_status(self.name, "running", pid=self._pid) self.bus.emit("CONNECTIVITY_CHANGED", {"module": self.name, "state": "up", "bridge": BRIDGE_NAME, "if1": self._if1, "if2": self._if2}, source_module=self.name) logger.info("Bridge %s active: %s <-> %s", BRIDGE_NAME, self._if1, self._if2) def stop(self) -> None: if not self._running: return self._running = False if self._watchdog_thread and self._watchdog_thread.is_alive(): self._watchdog_thread.join(timeout=5.0) self.teardown_bridge() self._bridge_up = False self.state.set_module_status(self.name, "stopped") self.bus.emit("CONNECTIVITY_CHANGED", {"module": self.name, "state": "down"}, source_module=self.name) logger.info("Bridge torn down") def status(self) -> dict: return { "running": self._running, "pid": self._pid, "uptime": time.time() - self._start_time if self._start_time else 0, "bridge_up": self._bridge_up, "bridge_name": BRIDGE_NAME, "interface1": self._if1, "interface2": self._if2, "healthy": self.is_healthy(), "consecutive_failures": self._consecutive_failures, } def configure(self, config: dict) -> None: self.config.update(config) def health_check(self) -> bool: if not self._running: return False return self.is_healthy() # ------------------------------------------------------------------ # Bridge operations # ------------------------------------------------------------------ def setup_bridge(self, if1: str, if2: str) -> bool: """Create transparent bridge between two interfaces. Tries the shell script first; falls back to inline commands if the script is missing. """ script = os.path.join(self._scripts_dir, "setup_bridge.sh") if self._scripts_dir else None if script and os.path.isfile(script): try: result = subprocess.run( ["bash", script, if1, if2], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: logger.info("Bridge setup via script succeeded") return True logger.warning("Bridge script failed: %s", result.stderr.strip()) except (subprocess.TimeoutExpired, OSError) as exc: logger.warning("Bridge script error: %s", exc) # Fallback: inline bridge setup return self._setup_bridge_inline(if1, if2) def teardown_bridge(self) -> bool: """Remove bridge and restore interfaces.""" script = os.path.join(self._scripts_dir, "teardown_bridge.sh") if self._scripts_dir else None if script and os.path.isfile(script): try: result = subprocess.run( ["bash", script], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: logger.info("Bridge teardown via script succeeded") return True logger.warning("Teardown script failed: %s", result.stderr.strip()) except (subprocess.TimeoutExpired, OSError) as exc: logger.warning("Teardown script error: %s", exc) # Fallback: inline teardown return self._teardown_bridge_inline() def is_healthy(self) -> bool: """Check if the bridge interface exists and both ports are attached.""" try: # Check bridge exists result = subprocess.run( ["ip", "link", "show", BRIDGE_NAME], capture_output=True, timeout=5, ) if result.returncode != 0: return False # Check both interfaces are in the bridge result = subprocess.run( ["bridge", "link", "show", "dev", self._if1], capture_output=True, text=True, timeout=5, ) if BRIDGE_NAME not in result.stdout: return False result = subprocess.run( ["bridge", "link", "show", "dev", self._if2], capture_output=True, text=True, timeout=5, ) if BRIDGE_NAME not in result.stdout: return False return True except (subprocess.TimeoutExpired, OSError): return False # ------------------------------------------------------------------ # Inline bridge commands (fallback if scripts missing) # ------------------------------------------------------------------ def _setup_bridge_inline(self, if1: str, if2: str) -> bool: """Create bridge using ip/bridge/ebtables commands directly.""" commands = [ # Create bridge, no IP ["ip", "link", "add", "name", BRIDGE_NAME, "type", "bridge"], # Disable STP ["ip", "link", "set", BRIDGE_NAME, "type", "bridge", "stp_state", "0"], # Disable forwarding delay ["ip", "link", "set", BRIDGE_NAME, "type", "bridge", "forward_delay", "0"], # Set promiscuous mode on interfaces ["ip", "link", "set", if1, "promisc", "on"], ["ip", "link", "set", if2, "promisc", "on"], # Flush any existing IP addresses ["ip", "addr", "flush", "dev", if1], ["ip", "addr", "flush", "dev", if2], # Add interfaces to bridge ["ip", "link", "set", if1, "master", BRIDGE_NAME], ["ip", "link", "set", if2, "master", BRIDGE_NAME], # Bring everything up ["ip", "link", "set", "up", "dev", if1], ["ip", "link", "set", "up", "dev", if2], ["ip", "link", "set", "up", "dev", BRIDGE_NAME], ] for cmd in commands: try: subprocess.run(cmd, check=True, capture_output=True, timeout=10) except subprocess.CalledProcessError as exc: logger.error("Bridge setup command failed: %s — %s", " ".join(cmd), exc.stderr.decode(errors='replace')) self._teardown_bridge_inline() return False except subprocess.TimeoutExpired: logger.error("Bridge setup command timed out: %s", " ".join(cmd)) self._teardown_bridge_inline() return False # Suppress STP/BPDU/CDP/LLDP via ebtables self._apply_ebtables_rules(if1, if2) logger.info("Inline bridge setup complete: %s <-> %s via %s", if1, if2, BRIDGE_NAME) return True def _teardown_bridge_inline(self) -> bool: """Remove bridge and restore interfaces.""" # Remove ebtables rules (best-effort) self._remove_ebtables_rules() commands = [ ["ip", "link", "set", "down", "dev", BRIDGE_NAME], ] # Remove interfaces from bridge if self._if1: commands.append(["ip", "link", "set", self._if1, "nomaster"]) commands.append(["ip", "link", "set", self._if1, "promisc", "off"]) if self._if2: commands.append(["ip", "link", "set", self._if2, "nomaster"]) commands.append(["ip", "link", "set", self._if2, "promisc", "off"]) commands.append(["ip", "link", "del", BRIDGE_NAME]) for cmd in commands: try: subprocess.run(cmd, capture_output=True, timeout=10) except (subprocess.CalledProcessError, subprocess.TimeoutExpired): pass # Best-effort teardown logger.info("Inline bridge teardown complete") return True def _apply_ebtables_rules(self, if1: str, if2: str) -> None: """Apply ebtables rules to suppress discovery protocols.""" # STP/BPDU destination: 01:80:C2:00:00:00 # CDP/VTP: 01:00:0C:CC:CC:CC # LLDP: 01:80:C2:00:00:0E suppress_macs = [ "01:80:C2:00:00:00", # STP/BPDU "01:00:0C:CC:CC:CC", # CDP/VTP "01:80:C2:00:00:0E", # LLDP "01:00:0C:CC:CC:CD", # Cisco PVST+ ] for mac in suppress_macs: for direction in ["FORWARD", "OUTPUT"]: cmd = ["ebtables", "-A", direction, "-d", mac, "-j", "DROP"] try: subprocess.run(cmd, capture_output=True, timeout=5) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): logger.debug("ebtables rule failed (may not be installed): %s", " ".join(cmd)) def _remove_ebtables_rules(self) -> None: """Flush ebtables rules.""" try: subprocess.run(["ebtables", "-F"], capture_output=True, timeout=5) subprocess.run(["ebtables", "-X"], capture_output=True, timeout=5) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): pass # ------------------------------------------------------------------ # Watchdog thread # ------------------------------------------------------------------ def _watchdog_loop(self) -> None: """Monitor bridge health; restore direct connectivity on failure.""" while self._running: time.sleep(WATCHDOG_INTERVAL) if not self._running: break if self.is_healthy(): self._consecutive_failures = 0 continue self._consecutive_failures += 1 logger.warning("Bridge health check failed (%d/%d)", self._consecutive_failures, WATCHDOG_MAX_FAILURES) if self._consecutive_failures >= WATCHDOG_MAX_FAILURES: logger.error("Bridge failsafe triggered — restoring direct connectivity") self.bus.emit("MODULE_ERROR", {"module": self.name, "error": "bridge_failsafe_triggered", "consecutive_failures": self._consecutive_failures}, source_module=self.name) # Teardown broken bridge and restore direct connection self._teardown_bridge_inline() self._bridge_up = False # Try to re-establish bridge if self._if1 and self._if2: time.sleep(2.0) if self._setup_bridge_inline(self._if1, self._if2): self._bridge_up = True self._consecutive_failures = 0 logger.info("Bridge recovered after failsafe") else: logger.error("Bridge recovery failed — operating without bridge") self._running = False