websurfer/crawler/tor.py
2026-11-07 16:58:29 +01:00

185 lines
7.1 KiB
Python

import atexit
import shutil
import time
from typing import Optional
import stem.process
from stem import Signal
from stem.control import Controller
DEFAULT_TOR_SOCKS_PORT = 9050
DEFAULT_TOR_CONTROL_PORT = 9051
class TorManager:
"""Manages the standalone headless Tor daemon process and circuit rotation."""
def __init__(
self,
socks_port: int = DEFAULT_TOR_SOCKS_PORT,
control_port: int = DEFAULT_TOR_CONTROL_PORT,
exit_nodes: Optional[str] = None,
log_callback: Optional[object] = None,
):
self.socks_port = socks_port
self.control_port = control_port
self.exit_nodes = exit_nodes
self.log_callback = log_callback
self.process: Optional[object] = None
self._is_external_service = False
def set_exit_nodes(self, exit_nodes: Optional[str]) -> None:
"""Dynamically updates exit node country filter on the active Tor controller."""
self.exit_nodes = exit_nodes
try:
with Controller.from_port(port=self.control_port) as controller:
controller.authenticate()
if self.exit_nodes:
controller.set_conf("ExitNodes", self.exit_nodes)
print(f"[Tor] Configured ExitNodes on Tor controller: {self.exit_nodes}")
else:
try:
controller.reset_conf("ExitNodes")
except Exception:
pass
print("[Tor] Reset ExitNodes filter (Global / Any)")
except Exception as e:
print(f" [!] Failed to set ExitNodes on ControlPort {self.control_port}: {e}")
def start(self):
"""
Starts a background Tor process with custom SocksPort and ControlPort using stem,
or attaches to an already running Tor service if available.
"""
# 1. Check if a Tor controller is already running on the control port
try:
with Controller.from_port(port=self.control_port) as controller:
controller.authenticate()
print(f"[Tor] Connected to existing Tor service on ControlPort {self.control_port}.")
if self.exit_nodes:
controller.set_conf("ExitNodes", self.exit_nodes)
print(f"[Tor] Applied ExitNodes: {self.exit_nodes}")
else:
try:
controller.reset_conf("ExitNodes")
except Exception:
pass
self._is_external_service = True
return None
except Exception:
pass
# 2. Check if 'tor' binary is installed
if not shutil.which("tor"):
raise RuntimeError(
"Tor executable ('tor') was not found on your system PATH.\n"
"Please install Tor:\n"
" - macOS: brew install tor\n"
" - Linux: sudo apt install tor\n"
" - Windows: download Tor expert bundle from torproject.org and add to PATH"
)
print(
f"[Tor] Launching headless Tor process "
f"(SocksPort {self.socks_port}, ControlPort {self.control_port})..."
)
# Always launch Tor with base config so it can bootstrap directory info and GeoIP cleanly without stalling
tor_config = {
"SocksPort": str(self.socks_port),
"ControlPort": str(self.control_port),
}
def _handle_tor_msg(msg: str):
if "Bootstrapped" in msg:
clean_msg = f"[Tor] {msg.strip()}"
print(f" {clean_msg}")
if self.log_callback and callable(self.log_callback):
try:
self.log_callback(clean_msg)
except Exception:
pass
self.process = stem.process.launch_tor_with_config(
config=tor_config,
take_ownership=False, # Managed explicitly via atexit/stop to prevent premature shutdowns
init_msg_handler=_handle_tor_msg,
)
self._is_external_service = False
# Apply ExitNodes once Tor is successfully bootstrapped
if self.exit_nodes:
try:
with Controller.from_port(port=self.control_port) as controller:
controller.authenticate()
controller.set_conf("ExitNodes", self.exit_nodes)
print(f"[Tor] Applied ExitNodes filter: {self.exit_nodes}")
except Exception as e:
print(f" [!] Failed to apply ExitNodes filter on ControlPort {self.control_port}: {e}")
# Ensure background process is killed when python script exits
atexit.register(self.stop)
print("[Tor] Standalone Tor process started successfully!")
return self.process
def renew_circuit(self) -> bool:
"""
Sends NEWNYM signal to Tor ControlPort to switch to a new circuit / IP.
Waits for the necessary cool-off period required by Tor.
Returns True if signal was sent successfully, False otherwise.
"""
try:
with Controller.from_port(port=self.control_port) as controller:
controller.authenticate()
if self.exit_nodes:
controller.set_conf("ExitNodes", self.exit_nodes)
controller.signal(Signal.NEWNYM)
wait_time = controller.get_newnym_wait()
print(f"[Tor] Requested new circuit (NEWNYM). Waiting {wait_time:.1f}s for new circuit to activate...")
if wait_time > 0:
time.sleep(wait_time)
print("[Tor] New circuit is active!")
return True
except Exception as e:
print(f" [!] Failed to renew Tor circuit via ControlPort {self.control_port}: {e}")
return False
def stop(self) -> None:
"""Stops the background Tor process if it was spawned by this manager."""
if self.process and not self._is_external_service:
try:
if self.process.poll() is None:
print("[Tor] Stopping headless Tor process...")
self.process.kill()
except Exception:
pass
finally:
self.process = None
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()
# Backward-compatible functional interfaces
def start_tor_service(
socks_port: int = DEFAULT_TOR_SOCKS_PORT,
control_port: int = DEFAULT_TOR_CONTROL_PORT,
):
manager = TorManager(socks_port=socks_port, control_port=control_port)
manager.start()
return manager
def renew_tor_circuit(control_port: int = DEFAULT_TOR_CONTROL_PORT) -> None:
TorManager(control_port=control_port).renew_circuit()
def stop_tor_service(tor_process) -> None:
if tor_process and hasattr(tor_process, "kill"):
try:
if tor_process.poll() is None:
tor_process.kill()
except Exception:
pass