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:
user
2026-02-16 20:07:18 +01:00
parent b07135ad44
commit ff217be9c8
3 changed files with 65 additions and 0 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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: