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,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
|
||||
Reference in New Issue
Block a user