diff --git a/config/example.yaml b/config/example.yaml index 88cccf3..b440c15 100644 --- a/config/example.yaml +++ b/config/example.yaml @@ -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 diff --git a/src/s5p/api.py b/src/s5p/api.py index fb0d40e..b8b6c2d 100644 --- a/src/s5p/api.py +++ b/src/s5p/api.py @@ -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: diff --git a/src/s5p/server.py b/src/s5p/server.py index 9eb98cd..af56c6e 100644 --- a/src/s5p/server.py +++ b/src/s5p/server.py @@ -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: