#!/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("sensor.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="sensor-ble-gatt", ) self._gatt_thread.start() self._advertising = True return True def stop_gatt(self) -> None: """Stop GATT server and BLE advertising.""" self._advertising = False self._stop_advertising() if self._gatt_thread and self._gatt_thread.is_alive(): self._gatt_thread.join(timeout=3.0) self._authenticated_sessions.clear() def is_advertising(self) -> bool: """Check if BLE advertising is active.""" try: result = subprocess.run( ["hciconfig", self._hci_device], capture_output=True, text=True, timeout=5, ) return "UP RUNNING" in result.stdout except (subprocess.TimeoutExpired, FileNotFoundError): return False # ------------------------------------------------------------------ # BLE adapter management # ------------------------------------------------------------------ def _check_bluetooth_available(self) -> bool: """Check if Bluetooth hardware is present.""" try: result = subprocess.run( ["hciconfig", self._hci_device], capture_output=True, timeout=5, ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False def _enable_adapter(self) -> bool: """Power on the BLE adapter.""" try: subprocess.run( ["hciconfig", self._hci_device, "up"], check=True, capture_output=True, timeout=10, ) # Enable LE mode subprocess.run( ["hciconfig", self._hci_device, "leadv", "0"], capture_output=True, timeout=5, ) return True except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc: logger.error("Failed to enable BLE adapter: %s", exc) return False def _set_device_name(self) -> bool: """Set the BLE device name via bluetoothctl.""" try: proc = subprocess.run( ["bluetoothctl", "--", "system-alias", self._device_name], capture_output=True, timeout=5, ) return proc.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False def _start_advertising(self) -> bool: """Start BLE advertising.""" try: # Set advertising data subprocess.run( ["hciconfig", self._hci_device, "leadv", "0"], capture_output=True, timeout=5, ) # Also use btmgmt for more control if available subprocess.run( ["btmgmt", "--index", self._hci_device.replace("hci", ""), "power", "on"], capture_output=True, timeout=5, ) subprocess.run( ["btmgmt", "--index", self._hci_device.replace("hci", ""), "le", "on"], capture_output=True, timeout=5, ) subprocess.run( ["btmgmt", "--index", self._hci_device.replace("hci", ""), "connectable", "on"], capture_output=True, timeout=5, ) subprocess.run( ["btmgmt", "--index", self._hci_device.replace("hci", ""), "advertising", "on"], capture_output=True, timeout=5, ) return True except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): return False def _stop_advertising(self) -> None: """Stop BLE advertising.""" try: subprocess.run( ["hciconfig", self._hci_device, "noleadv"], capture_output=True, timeout=5, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): pass # ------------------------------------------------------------------ # GATT command processing # ------------------------------------------------------------------ def _gatt_listen_loop(self) -> None: """Listen for GATT characteristic writes via bluetoothctl/gatttool. This loop uses a simple file-based IPC approach: commands are written to a well-known path by the BLE GATT callback, and this loop processes them. In production, this would use dbus-python to register a proper GATT application. """ cmd_fifo = "/tmp/.bb_ble_cmd" resp_fifo = "/tmp/.bb_ble_resp" # Create FIFOs for IPC for fifo in (cmd_fifo, resp_fifo): try: if os.path.exists(fifo): os.unlink(fifo) os.mkfifo(fifo, 0o600) except OSError as exc: logger.error("Cannot create BLE FIFO %s: %s", fifo, exc) return logger.debug("BLE GATT listener ready on FIFOs") while self._advertising and self._running: try: # Non-blocking read from command FIFO fd = os.open(cmd_fifo, os.O_RDONLY | os.O_NONBLOCK) try: data = os.read(fd, 4096) finally: os.close(fd) if data: cmd_str = data.decode(errors='replace').strip() response = self._process_command(cmd_str) self._last_response = response # Write response to response FIFO try: fd = os.open(resp_fifo, os.O_WRONLY | os.O_NONBLOCK) try: os.write(fd, response.encode()[:BLE_MTU]) finally: os.close(fd) except OSError: pass except OSError: pass # No data ready time.sleep(0.5) # Cleanup FIFOs for fifo in (cmd_fifo, resp_fifo): try: os.unlink(fifo) except OSError: pass def _process_command(self, raw_cmd: str) -> str: """Process an authenticated BLE command. Command format: : HMAC is computed over: nonce + command using the PSK. """ if ":" not in raw_cmd: return '{"error": "invalid format"}' provided_hmac, command = raw_cmd.split(":", 1) command = command.strip().lower() # Verify HMAC if not self._verify_hmac(provided_hmac, command): logger.warning("BLE auth failed for command: %s", command) return '{"error": "auth_failed"}' # Rotate nonce after successful auth old_nonce = self._nonce self._nonce = secrets.token_hex(16) # Execute command if command == "status": return self._cmd_status() elif command == "kill": return self._cmd_kill() elif command == "reboot": return self._cmd_reboot() elif command == "get_ip": return self._cmd_get_ip() elif command == "get_nonce": # Return new nonce for next command (unauthenticated) return json.dumps({"nonce": self._nonce}) else: return json.dumps({"error": "unknown_command", "commands": [ "status", "kill", "reboot", "get_ip", "get_nonce" ]}) def _verify_hmac(self, provided_hmac: str, command: str) -> bool: """Verify HMAC authentication for a BLE command.""" if not self._psk or not self._nonce: return False message = f"{self._nonce}:{command}".encode() expected = hmac.new( self._psk.encode(), message, hashlib.sha256 ).hexdigest() return hmac.compare_digest(provided_hmac.lower(), expected.lower()) # ------------------------------------------------------------------ # BLE command handlers # ------------------------------------------------------------------ def _cmd_status(self) -> str: """Return implant status summary.""" status_data = { "ok": True, "uptime": time.time() - self._start_time if self._start_time else 0, "nonce": self._nonce, } # Get connectivity status from state all_status = self.state.get_all_module_status() connectivity_modules = {} for name, info in all_status.items(): if name in ("wireguard", "tailscale", "reverse_tunnel", "cellular_backup"): connectivity_modules[name] = info.get("status", "unknown") status_data["connectivity"] = connectivity_modules return json.dumps(status_data) def _cmd_kill(self) -> str: """Trigger kill switch via event bus.""" logger.warning("Kill switch triggered via BLE emergency") self.bus.emit("KILL_SWITCH", {"source": "ble_emergency", "reason": "operator_ble_command"}, source_module=self.name) return json.dumps({"ok": True, "action": "kill_switch_triggered"}) def _cmd_reboot(self) -> str: """Trigger system reboot.""" logger.warning("Reboot triggered via BLE emergency") def _delayed_reboot(): time.sleep(2.0) try: subprocess.run(["reboot"], timeout=5) except Exception: pass threading.Thread(target=_delayed_reboot, daemon=True).start() return json.dumps({"ok": True, "action": "reboot_in_2s"}) def _cmd_get_ip(self) -> str: """Return all IP addresses on the system.""" ips = {} try: result = subprocess.run( ["ip", "-4", "-o", "addr", "show"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: import re for line in result.stdout.strip().splitlines(): match = re.search(r'(\S+)\s+inet\s+(\d+\.\d+\.\d+\.\d+)', line) if match: iface = match.group(1) ip = match.group(2) if iface != "lo": ips[iface] = ip except (subprocess.TimeoutExpired, FileNotFoundError): pass return json.dumps({"ips": ips, "nonce": self._nonce})