Files
bigbrother/modules/connectivity/cellular_backup.py
T
Cobra ffd384f64b Strip OPSEC tool identity fingerprints
Replace all sensor.* logger namespaces with __name__ (generic module
identifiers instead of discoverable 'sensor.*' prefixes).

Change hardcoded 'bb' API user to 'admin' in config and code defaults.

Change hardcoded relay_user 'bb' to 'operator' — prevents network
profiling from exposing tool identity via SSH config.

Fixes #457, #458, #459
2026-04-08 22:18:35 -04:00

463 lines
16 KiB
Python

#!/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 subprocess
import time
from typing import Optional
try:
import serial
except ImportError:
serial = None
from modules.base import BaseModule
logger = logging.getLogger(__name__)
# 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
if serial is None:
logger.error("pyserial not installed — cellular backup unavailable")
self.state.set_module_status(self.name, "error",
extra={"reason": "pyserial_missing"})
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