From b72d083f56be3d714a0d833cd07ecbc1691f568c Mon Sep 17 00:00:00 2001 From: user Date: Mon, 16 Feb 2026 19:03:44 +0100 Subject: [PATCH] feat: wire control API into server and config Add api_host/api_port to Config dataclass, parse api_listen key in load_config(), add --api [HOST:]PORT CLI flag. Start/stop API server in serve() alongside the SOCKS5 listener. Co-Authored-By: Claude Opus 4.6 --- src/s5p/cli.py | 12 ++++++++++++ src/s5p/config.py | 11 +++++++++++ src/s5p/server.py | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/s5p/cli.py b/src/s5p/cli.py index 61df0be..718d705 100644 --- a/src/s5p/cli.py +++ b/src/s5p/cli.py @@ -54,6 +54,10 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: "-S", "--proxy-source", metavar="URL", help="proxy source API URL", ) + p.add_argument( + "--api", metavar="[HOST:]PORT", + help="enable control API on address (e.g. 127.0.0.1:1081)", + ) p.add_argument( "--cprofile", metavar="FILE", nargs="?", const="s5p.prof", help="enable cProfile, dump stats to FILE (default: s5p.prof)", @@ -89,6 +93,14 @@ def main(argv: list[str] | None = None) -> int: if args.max_connections is not None: config.max_connections = args.max_connections + if args.api: + if ":" in args.api: + host, port_str = args.api.rsplit(":", 1) + config.api_host = host + config.api_port = int(port_str) + else: + config.api_port = int(args.api) + if args.proxy_source: config.proxy_pool = ProxyPoolConfig( sources=[PoolSourceConfig(url=args.proxy_source)], diff --git a/src/s5p/config.py b/src/s5p/config.py index 867b029..157f865 100644 --- a/src/s5p/config.py +++ b/src/s5p/config.py @@ -66,6 +66,8 @@ class Config: max_connections: int = 256 pool_size: int = 0 pool_max_idle: float = 30.0 + api_host: str = "" + api_port: int = 0 proxy_pool: ProxyPoolConfig | None = None config_file: str = "" @@ -148,6 +150,15 @@ def load_config(path: str | Path) -> Config: if "pool_max_idle" in raw: config.pool_max_idle = float(raw["pool_max_idle"]) + if "api_listen" in raw: + api = raw["api_listen"] + if isinstance(api, str) and ":" in api: + host, port_str = api.rsplit(":", 1) + config.api_host = host + config.api_port = int(port_str) + elif isinstance(api, (str, int)): + config.api_port = int(api) + if "chain" in raw: for entry in raw["chain"]: if isinstance(entry, str): diff --git a/src/s5p/server.py b/src/s5p/server.py index 42df0cc..9eb98cd 100644 --- a/src/s5p/server.py +++ b/src/s5p/server.py @@ -8,6 +8,7 @@ import signal import struct import time +from .api import start_api from .config import Config, load_config from .connpool import FirstHopPool from .metrics import Metrics @@ -259,6 +260,16 @@ async def serve(config: Config) -> None: ) logger.info(" retries: %d", config.retries) + # -- control API --------------------------------------------------------- + api_srv: asyncio.Server | None = None + if config.api_port: + api_ctx: dict = { + "config": config, + "metrics": metrics, + "pool": proxy_pool, + "hop_pool": hop_pool, + } + # SIGHUP: hot-reload config (timeout, retries, log_level, pool settings) async def _reload() -> None: if not config.config_file: @@ -289,6 +300,10 @@ async def serve(config: Config) -> None: loop.add_signal_handler(signal.SIGHUP, _on_sighup) + if config.api_port: + api_ctx["reload_fn"] = _reload + api_srv = await start_api(config.api_host, config.api_port, api_ctx) + metrics_stop = asyncio.Event() pool_ref = proxy_pool metrics_task = asyncio.create_task(_metrics_logger(metrics, metrics_stop, pool_ref)) @@ -296,6 +311,9 @@ async def serve(config: Config) -> None: async with srv: sig = await stop logger.info("received %s, shutting down", signal.Signals(sig).name) + if api_srv: + api_srv.close() + await api_srv.wait_closed() if hop_pool: await hop_pool.stop() if proxy_pool: