feat: control API and Tor integration #1
@@ -8,6 +8,7 @@ log_level: info
|
|||||||
# max_connections: 256 # max concurrent client connections (backpressure)
|
# max_connections: 256 # max concurrent client connections (backpressure)
|
||||||
# pool_size: 0 # pre-warmed TCP connections to first hop (0 = disabled)
|
# pool_size: 0 # pre-warmed TCP connections to first hop (0 = disabled)
|
||||||
# pool_max_idle: 30 # max idle time (seconds) for pooled connections
|
# 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.
|
# Proxy chain -- connections tunnel through each hop in order.
|
||||||
# Supported protocols: socks5://, socks4://, http://
|
# Supported protocols: socks5://, socks4://, http://
|
||||||
@@ -37,6 +38,15 @@ chain:
|
|||||||
# state_file: "" # empty = ~/.cache/s5p/pool.json
|
# state_file: "" # empty = ~/.cache/s5p/pool.json
|
||||||
# report_url: "" # POST dead proxies here (optional)
|
# 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):
|
# Legacy proxy source (still supported, auto-converts to proxy_pool):
|
||||||
# proxy_source:
|
# proxy_source:
|
||||||
# url: http://10.200.1.250:8081/proxies
|
# 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}
|
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 -----------------------------------------------------------------
|
# -- routing -----------------------------------------------------------------
|
||||||
|
|
||||||
_GET_ROUTES: dict[str, str] = {
|
_GET_ROUTES: dict[str, str] = {
|
||||||
@@ -175,12 +200,14 @@ _GET_ROUTES: dict[str, str] = {
|
|||||||
"/pool": "pool",
|
"/pool": "pool",
|
||||||
"/pool/alive": "pool_alive",
|
"/pool/alive": "pool_alive",
|
||||||
"/config": "config",
|
"/config": "config",
|
||||||
|
"/tor": "tor",
|
||||||
}
|
}
|
||||||
|
|
||||||
_POST_ROUTES: dict[str, str] = {
|
_POST_ROUTES: dict[str, str] = {
|
||||||
"/reload": "reload",
|
"/reload": "reload",
|
||||||
"/pool/test": "pool_test",
|
"/pool/test": "pool_test",
|
||||||
"/pool/refresh": "pool_refresh",
|
"/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)
|
return _handle_pool(ctx, alive_only=True)
|
||||||
if name == "config":
|
if name == "config":
|
||||||
return _handle_config(ctx)
|
return _handle_config(ctx)
|
||||||
|
if name == "tor":
|
||||||
|
return _handle_tor(ctx)
|
||||||
|
|
||||||
if method == "POST" and path in _POST_ROUTES:
|
if method == "POST" and path in _POST_ROUTES:
|
||||||
name = _POST_ROUTES[path]
|
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)
|
return await _handle_pool_test(ctx)
|
||||||
if name == "pool_refresh":
|
if name == "pool_refresh":
|
||||||
return await _handle_pool_refresh(ctx)
|
return await _handle_pool_refresh(ctx)
|
||||||
|
if name == "tor_newnym":
|
||||||
|
return await _handle_tor_newnym(ctx)
|
||||||
|
|
||||||
# wrong method on a known path
|
# wrong method on a known path
|
||||||
if path in _GET_ROUTES or path in _POST_ROUTES:
|
if path in _GET_ROUTES or path in _POST_ROUTES:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .connpool import FirstHopPool
|
|||||||
from .metrics import Metrics
|
from .metrics import Metrics
|
||||||
from .pool import ProxyPool
|
from .pool import ProxyPool
|
||||||
from .proto import ProtoError, Socks5Reply, build_chain, read_socks5_address
|
from .proto import ProtoError, Socks5Reply, build_chain, read_socks5_address
|
||||||
|
from .tor import TorController
|
||||||
|
|
||||||
logger = logging.getLogger("s5p")
|
logger = logging.getLogger("s5p")
|
||||||
|
|
||||||
@@ -236,6 +237,22 @@ async def serve(config: Config) -> None:
|
|||||||
)
|
)
|
||||||
await hop_pool.start()
|
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)
|
sem = asyncio.Semaphore(config.max_connections)
|
||||||
|
|
||||||
async def on_client(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
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)
|
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 ---------------------------------------------------------
|
# -- control API ---------------------------------------------------------
|
||||||
api_srv: asyncio.Server | None = None
|
api_srv: asyncio.Server | None = None
|
||||||
if config.api_port:
|
if config.api_port:
|
||||||
@@ -268,6 +289,7 @@ async def serve(config: Config) -> None:
|
|||||||
"metrics": metrics,
|
"metrics": metrics,
|
||||||
"pool": proxy_pool,
|
"pool": proxy_pool,
|
||||||
"hop_pool": hop_pool,
|
"hop_pool": hop_pool,
|
||||||
|
"tor": tor,
|
||||||
}
|
}
|
||||||
|
|
||||||
# SIGHUP: hot-reload config (timeout, retries, log_level, pool settings)
|
# SIGHUP: hot-reload config (timeout, retries, log_level, pool settings)
|
||||||
@@ -314,6 +336,8 @@ async def serve(config: Config) -> None:
|
|||||||
if api_srv:
|
if api_srv:
|
||||||
api_srv.close()
|
api_srv.close()
|
||||||
await api_srv.wait_closed()
|
await api_srv.wait_closed()
|
||||||
|
if tor:
|
||||||
|
await tor.stop()
|
||||||
if hop_pool:
|
if hop_pool:
|
||||||
await hop_pool.stop()
|
await hop_pool.stop()
|
||||||
if proxy_pool:
|
if proxy_pool:
|
||||||
|
|||||||
Reference in New Issue
Block a user