ffd384f64b
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
448 lines
16 KiB
Python
448 lines
16 KiB
Python
#!/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(__name__)
|
|
|
|
# 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_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", "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})
|