ffd384f64b
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
360 lines
14 KiB
Python
360 lines
14 KiB
Python
#!/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(__name__)
|
|
|
|
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
|