feat: wire Tor controller into server and API
Start/stop TorController in serve() lifecycle when tor: config is present. Adds GET /tor (status) and POST /tor/newnym (signal) endpoints to the control API. Logs control address at startup. Adds tor: section and api_listen to example config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ log_level: info
|
||||
# max_connections: 256 # max concurrent client connections (backpressure)
|
||||
# pool_size: 0 # pre-warmed TCP connections to first hop (0 = disabled)
|
||||
# pool_max_idle: 30 # max idle time (seconds) for pooled connections
|
||||
# api_listen: 127.0.0.1:1081 # control API (disabled by default)
|
||||
|
||||
# Proxy chain -- connections tunnel through each hop in order.
|
||||
# Supported protocols: socks5://, socks4://, http://
|
||||
@@ -37,6 +38,15 @@ chain:
|
||||
# state_file: "" # empty = ~/.cache/s5p/pool.json
|
||||
# report_url: "" # POST dead proxies here (optional)
|
||||
|
||||
# Tor control port -- enables NEWNYM signaling (new circuit on demand).
|
||||
# Requires Tor's ControlPort enabled (torrc: ControlPort 9051).
|
||||
# tor:
|
||||
# control_host: 127.0.0.1
|
||||
# control_port: 9051
|
||||
# password: "" # HashedControlPassword in torrc
|
||||
# cookie_file: "" # CookieAuthentication file path
|
||||
# newnym_interval: 0 # periodic NEWNYM (seconds, 0 = manual only)
|
||||
|
||||
# Legacy proxy source (still supported, auto-converts to proxy_pool):
|
||||
# proxy_source:
|
||||
# url: http://10.200.1.250:8081/proxies
|
||||
|
||||
@@ -167,6 +167,31 @@ async def _handle_pool_refresh(ctx: dict) -> tuple[int, dict]:
|
||||
return 200, {"ok": True}
|
||||
|
||||
|
||||
def _handle_tor(ctx: dict) -> tuple[int, dict]:
|
||||
"""GET /tor -- Tor controller status."""
|
||||
tor = ctx.get("tor")
|
||||
if not tor:
|
||||
return 200, {"enabled": False}
|
||||
last = tor.last_newnym
|
||||
return 200, {
|
||||
"enabled": True,
|
||||
"connected": tor.connected,
|
||||
"last_newnym": round(time.monotonic() - last, 1) if last else None,
|
||||
"newnym_interval": tor.newnym_interval,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_tor_newnym(ctx: dict) -> tuple[int, dict]:
|
||||
"""POST /tor/newnym -- trigger NEWNYM signal."""
|
||||
tor = ctx.get("tor")
|
||||
if not tor:
|
||||
return 400, {"error": "tor control not configured"}
|
||||
ok = await tor.newnym()
|
||||
if ok:
|
||||
return 200, {"ok": True}
|
||||
return 200, {"ok": False, "reason": "rate-limited or not connected"}
|
||||
|
||||
|
||||
# -- routing -----------------------------------------------------------------
|
||||
|
||||
_GET_ROUTES: dict[str, str] = {
|
||||
@@ -175,12 +200,14 @@ _GET_ROUTES: dict[str, str] = {
|
||||
"/pool": "pool",
|
||||
"/pool/alive": "pool_alive",
|
||||
"/config": "config",
|
||||
"/tor": "tor",
|
||||
}
|
||||
|
||||
_POST_ROUTES: dict[str, str] = {
|
||||
"/reload": "reload",
|
||||
"/pool/test": "pool_test",
|
||||
"/pool/refresh": "pool_refresh",
|
||||
"/tor/newnym": "tor_newnym",
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +225,8 @@ async def _route(method: str, path: str, ctx: dict) -> tuple[int, dict]:
|
||||
return _handle_pool(ctx, alive_only=True)
|
||||
if name == "config":
|
||||
return _handle_config(ctx)
|
||||
if name == "tor":
|
||||
return _handle_tor(ctx)
|
||||
|
||||
if method == "POST" and path in _POST_ROUTES:
|
||||
name = _POST_ROUTES[path]
|
||||
@@ -207,6 +236,8 @@ async def _route(method: str, path: str, ctx: dict) -> tuple[int, dict]:
|
||||
return await _handle_pool_test(ctx)
|
||||
if name == "pool_refresh":
|
||||
return await _handle_pool_refresh(ctx)
|
||||
if name == "tor_newnym":
|
||||
return await _handle_tor_newnym(ctx)
|
||||
|
||||
# wrong method on a known path
|
||||
if path in _GET_ROUTES or path in _POST_ROUTES:
|
||||
|
||||
@@ -14,6 +14,7 @@ from .connpool import FirstHopPool
|
||||
from .metrics import Metrics
|
||||
from .pool import ProxyPool
|
||||
from .proto import ProtoError, Socks5Reply, build_chain, read_socks5_address
|
||||
from .tor import TorController
|
||||
|
||||
logger = logging.getLogger("s5p")
|
||||
|
||||
@@ -236,6 +237,22 @@ async def serve(config: Config) -> None:
|
||||
)
|
||||
await hop_pool.start()
|
||||
|
||||
tor: TorController | None = None
|
||||
if config.tor:
|
||||
tc = config.tor
|
||||
tor = TorController(
|
||||
host=tc.control_host,
|
||||
port=tc.control_port,
|
||||
password=tc.password,
|
||||
cookie_file=tc.cookie_file,
|
||||
newnym_interval=tc.newnym_interval,
|
||||
)
|
||||
try:
|
||||
await tor.start()
|
||||
except (ConnectionError, OSError, TimeoutError) as e:
|
||||
logger.warning("tor: control port unavailable: %s", e)
|
||||
tor = None
|
||||
|
||||
sem = asyncio.Semaphore(config.max_connections)
|
||||
|
||||
async def on_client(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||
@@ -260,6 +277,10 @@ async def serve(config: Config) -> None:
|
||||
)
|
||||
logger.info(" retries: %d", config.retries)
|
||||
|
||||
if tor:
|
||||
interval = f", newnym every {tor.newnym_interval:.0f}s" if tor.newnym_interval else ""
|
||||
logger.info(" tor: control %s:%d%s", config.tor.control_host, config.tor.control_port, interval)
|
||||
|
||||
# -- control API ---------------------------------------------------------
|
||||
api_srv: asyncio.Server | None = None
|
||||
if config.api_port:
|
||||
@@ -268,6 +289,7 @@ async def serve(config: Config) -> None:
|
||||
"metrics": metrics,
|
||||
"pool": proxy_pool,
|
||||
"hop_pool": hop_pool,
|
||||
"tor": tor,
|
||||
}
|
||||
|
||||
# SIGHUP: hot-reload config (timeout, retries, log_level, pool settings)
|
||||
@@ -314,6 +336,8 @@ async def serve(config: Config) -> None:
|
||||
if api_srv:
|
||||
api_srv.close()
|
||||
await api_srv.wait_closed()
|
||||
if tor:
|
||||
await tor.stop()
|
||||
if hop_pool:
|
||||
await hop_pool.stop()
|
||||
if proxy_pool:
|
||||
|
||||
Reference in New Issue
Block a user