[
  {
    "path": ".dockerignore",
    "content": ".git\n.github\n.pytest_cache\n.ruff_cache\n.venv\n__pycache__\n*.pyc\nbuild\ndist\n*.egg-info\ndocs\ntests\nax-spec\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG]\"\nlabels: bug\nassignees: Amaindex\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1.\n2.\n3.\n4.\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the information):**\n- OS: [e.g. Win10]\n- Version: [e.g. 1.0.0]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[FEATURE]\"\nlabels: enhancement\nassignees: Amaindex\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is.\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  release:\n    types: [published]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\nenv:\n  IMAGE_NAME: amaindex/asyncio-socks-server\n  DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}\n  DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }}\n\njobs:\n  docker:\n    name: Build Docker image\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: docker/setup-buildx-action@v3\n\n      - uses: docker/metadata-action@v5\n        id: meta\n        with:\n          images: ${{ env.IMAGE_NAME }}\n          tags: |\n            type=ref,event=branch\n            type=ref,event=pr\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n\n      - uses: docker/login-action@v3\n        if: github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_PASSWORD != ''\n        with:\n          username: ${{ env.DOCKERHUB_USERNAME }}\n          password: ${{ env.DOCKERHUB_PASSWORD }}\n\n      - uses: docker/build-push-action@v6\n        with:\n          context: .\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_PASSWORD != '' }}\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  workflow_dispatch:\n  release:\n    types: [published]\n\npermissions:\n  contents: read\n\njobs:\n  publish:\n    name: Publish Python package\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: astral-sh/setup-uv@v4\n        with:\n          enable-cache: true\n          cache-dependency-glob: pyproject.toml\n\n      - name: Set up Python\n        run: uv python install 3.12\n\n      - name: Build\n        run: uv build --python 3.12\n\n      - name: Publish to PyPI\n        run: uv publish\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  quality:\n    name: Quality\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: astral-sh/setup-uv@v4\n        with:\n          enable-cache: true\n          cache-dependency-glob: pyproject.toml\n\n      - name: Set up Python\n        run: uv python install 3.12\n\n      - name: Install dependencies\n        run: uv sync --group dev --python 3.12\n\n      - name: Lint\n        run: uv run ruff check .\n\n      - name: Format check\n        run: uv run ruff format --check .\n\n      - name: Type check\n        run: uv run pyright\n\n  test:\n    name: Test Python ${{ matrix.python-version }}\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version: [\"3.12\", \"3.13\"]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: astral-sh/setup-uv@v4\n        with:\n          enable-cache: true\n          cache-dependency-glob: pyproject.toml\n\n      - name: Set up Python ${{ matrix.python-version }}\n        run: uv python install ${{ matrix.python-version }}\n\n      - name: Install dependencies\n        run: uv sync --group dev --python ${{ matrix.python-version }}\n\n      - name: Test\n        run: uv run pytest -q\n\n  build:\n    name: Build package\n    runs-on: ubuntu-latest\n    needs: [quality, test]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: astral-sh/setup-uv@v4\n        with:\n          enable-cache: true\n          cache-dependency-glob: pyproject.toml\n\n      - name: Set up Python\n        run: uv python install 3.12\n\n      - name: Build sdist and wheel\n        run: uv build --python 3.12\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: python-package\n          path: dist/*\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# Distribution / packaging\nbuild/\ndist/\n*.egg-info/\n\n# Environments\n.env\n.venv/\nvenv/\n\n# Testing\n.pytest_cache/\nhtmlcov/\n.coverage\n\n# Agent-local tooling\n.claude/\n.codex/\n\n# Development specs\nax-spec/\n\n# Type checkers\n.mypy_cache/\n.pyre/\n\n# uv\nuv.lock\n.uv-cache/\n\n# IDE\n.idea/\n.vscode/\n.DS_Store\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.12-slim\n\nWORKDIR /app\n\nCOPY pyproject.toml README.md LICENSE ./\nCOPY src ./src\n\nRUN pip install --no-cache-dir --root-user-action=ignore . \\\n    && useradd --create-home --shell /usr/sbin/nologin appuser\n\nUSER appuser\n\nEXPOSE 1080\n\nENTRYPOINT [\"asyncio_socks_server\"]\nCMD [\"--host\", \"0.0.0.0\", \"--port\", \"1080\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 Amaindex\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# asyncio-socks-server\n\n[![Tests](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/tests.yml/badge.svg)](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/tests.yml)\n[![Docker](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/docker.yml/badge.svg)](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/docker.yml)\n[![Python](https://img.shields.io/badge/python-3.12%2B-blue)](pyproject.toml)\n[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)\n\nSOCKS5 server with async Python addon hooks.\n\n[Docs](#docs) · [Architecture](docs/architecture.md) · [Addon recipes](docs/addon-recipes.md) · [Addon model](docs/addon-model.md) · [Public API](docs/public-api.md) · [简体中文](README.zh-CN.md)\n\n## Install\n\n```shell\npip install asyncio-socks-server\n```\n\nDocker images are versioned:\n\n```shell\ndocker run --rm -p 1080:1080 amaindex/asyncio-socks-server:1.3.1\n```\n\n## Run\n\n```shell\nasyncio_socks_server\nasyncio_socks_server --host 127.0.0.1 --port 9050\nasyncio_socks_server --auth user:pass\n```\n\nCLI flags:\n\n| Flag | Default | Meaning |\n|------|---------|---------|\n| `--host` | `::` | Bind address |\n| `--port` | `1080` | Bind port |\n| `--auth` | None | `username:password` |\n| `--log-level` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |\n\n## Use from Python\n\n```python\nfrom asyncio_socks_server import Server\n\nServer(host=\"::\", port=1080).run()\n```\n\nAddons are optional. Add only the behavior you need:\n\n| Goal | Addons |\n|------|--------|\n| Runtime counters and active flows | `FlowStats` + `StatsAPI` |\n| Closed-flow usage audit | `FlowAudit` + `StatsAPI` |\n| TCP chain proxying | `ChainRouter` |\n| UDP chain proxying | `UdpOverTcpEntry` + `UdpOverTcpExitServer` |\n| Auth, source policy, logs | `FileAuth`, `IPFilter`, `Logger` |\n\nRuntime counters and audit API:\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nAddon order is execution order. Built-in addons are opt-in; adding `StatsAPI`\nis what starts an HTTP listener.\n\n`FlowStats` has no network side effects. Use its `snapshot()` and `flows()`\nmethods directly, or pair it with `StatsAPI` for a small local HTTP API.\n`FlowAudit` records closed-flow usage in memory and can be exposed through\n`StatsAPI` for Kafra-like usage audit summaries.\n\nFor task-oriented examples, see [Addon recipes](docs/addon-recipes.md).\n\n## Model\n\nThe core handles SOCKS5 parsing, relay, and hook dispatch. Addons handle policy.\n\nHook dispatch has three models:\n\n| Model | Hooks | Contract |\n|-------|-------|----------|\n| Competitive | `on_auth`, `on_connect`, `on_udp_associate` | First non-`None` result wins |\n| Pipeline | `on_data` | Output from one addon becomes input to the next |\n| Observational | `on_start`, `on_stop`, `on_flow_close`, `on_error` | All applicable addons run |\n\nBuilt-ins:\n\n- `ChainRouter` for TCP chain proxying\n- `UdpOverTcpEntry` and `UdpOverTcpExitServer` for UDP chain proxying\n- `FlowStats` for in-memory flow statistics\n- `FlowAudit` for closed-flow usage audit summaries\n- `StatsAPI` as an opt-in HTTP API around `FlowStats`\n- `StatsServer` as a backward-compatible name for `StatsAPI`\n- `TrafficCounter`, `FileAuth`, `IPFilter`, `Logger`\n\n## Architecture sketch\n\n```text\nClient ── SOCKS5 ──▶ Server ──▶ Target\n                     │\n                     ├─ auth / route hooks\n                     ├─ data pipeline hooks\n                     └─ flow close hooks\n\nChainRouter:\nClient ──▶ A ──▶ B ──▶ C ──▶ Target\n```\n\n## Chain proxying\n\nEach node only knows its next hop:\n\n```python\n# A ─▶ B ─▶ C ─▶ target\nServer(addons=[ChainRouter(\"B:1080\")])  # A\nServer(addons=[ChainRouter(\"C:1080\")])  # B\nServer()                                # C\n```\n\nUDP chain proxying uses TCP between proxy nodes:\n\n```python\nfrom asyncio_socks_server import Server, UdpOverTcpEntry, UdpOverTcpExitServer\n\nentry = Server(addons=[UdpOverTcpEntry(\"exit-host:9020\")])\nexit_server = UdpOverTcpExitServer(host=\"::\", port=9020)\n```\n\n## Client\n\n```python\nfrom asyncio_socks_server import Address, connect\n\nconn = await connect(\n    proxy_addr=Address(\"127.0.0.1\", 1080),\n    target_addr=Address(\"93.184.216.34\", 443),\n)\nconn.writer.write(b\"hello\")\nawait conn.writer.drain()\ndata = await conn.reader.read(4096)\n```\n\n## API surface\n\nStable imports live at the package root:\n\n```python\nfrom asyncio_socks_server import (\n    Addon,\n    Address,\n    ChainRouter,\n    Flow,\n    FlowAudit,\n    FlowStats,\n    Server,\n    StatsAPI,\n    StatsServer,\n    UdpOverTcpEntry,\n    UdpOverTcpExitServer,\n    connect,\n)\n```\n\nRoot exports are the 1.x compatibility contract. Submodules remain importable.\n\n## Docs\n\n| Document | Scope |\n|----------|-------|\n| [Architecture](docs/architecture.md) | Core flow, relay design, UDP-over-TCP, Flow context |\n| [Addon recipes](docs/addon-recipes.md) | Goal-oriented addon combinations |\n| [Addon model](docs/addon-model.md) | Hook contracts, dispatch semantics, built-in addons |\n| [Public API](docs/public-api.md) | 1.x compatibility surface |\n\n## Development\n\n```shell\ngit clone https://github.com/Amaindex/asyncio-socks-server.git\ncd asyncio-socks-server\nuv sync\nuv run ruff check .\nuv run ruff format --check .\nuv run pyright\nuv run pytest\nuv build\n```\n\n## Release\n\nGitHub Actions tests Python 3.12 and 3.13, builds the Python package, and builds Docker images.\n\nCreate a GitHub Release from a tag such as `v1.3.1`. The release workflow publishes the Python package. The Docker workflow publishes semver image tags.\n\n## License\n\nMIT\n"
  },
  {
    "path": "README.zh-CN.md",
    "content": "# asyncio-socks-server\n\n[![Tests](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/tests.yml/badge.svg)](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/tests.yml)\n[![Docker](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/docker.yml/badge.svg)](https://github.com/Amaindex/asyncio-socks-server/actions/workflows/docker.yml)\n[![Python](https://img.shields.io/badge/python-3.12%2B-blue)](pyproject.toml)\n[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)\n\n带 async Python addon hooks 的 SOCKS5 server。\n\n[文档](#文档) · [架构](docs/architecture.zh-CN.md) · [Addon recipes](docs/addon-recipes.zh-CN.md) · [Addon 模型](docs/addon-model.zh-CN.md) · [公共 API](docs/public-api.zh-CN.md) · [English](README.md)\n\n## 安装\n\n```shell\npip install asyncio-socks-server\n```\n\nDocker image 使用明确版本：\n\n```shell\ndocker run --rm -p 1080:1080 amaindex/asyncio-socks-server:1.3.1\n```\n\n## 运行\n\n```shell\nasyncio_socks_server\nasyncio_socks_server --host 127.0.0.1 --port 9050\nasyncio_socks_server --auth user:pass\n```\n\nCLI 参数：\n\n| 参数 | 默认值 | 含义 |\n|------|--------|------|\n| `--host` | `::` | 监听地址 |\n| `--port` | `1080` | 监听端口 |\n| `--auth` | 无 | `username:password` |\n| `--log-level` | `INFO` | `DEBUG`、`INFO`、`WARNING`、`ERROR` |\n\n## Python API\n\n```python\nfrom asyncio_socks_server import Server\n\nServer(host=\"::\", port=1080).run()\n```\n\nAddon 是可选的。只添加你需要的行为：\n\n| 目标 | Addons |\n|------|--------|\n| 运行计数和活跃 flows | `FlowStats` + `StatsAPI` |\n| 已关闭 flow 的用量审计 | `FlowAudit` + `StatsAPI` |\n| TCP 链式代理 | `ChainRouter` |\n| UDP 链式代理 | `UdpOverTcpEntry` + `UdpOverTcpExitServer` |\n| 认证、来源策略、日志 | `FileAuth`、`IPFilter`、`Logger` |\n\n运行计数和审计 API：\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nAddon 顺序就是执行顺序。内置 addon 都是显式 opt-in；只有加入\n`StatsAPI` 才会启动 HTTP listener。\n\n`FlowStats` 没有网络副作用。使用它的 `snapshot()` 和 `flows()` 方法，\n可以自行搭建 HTTP API、metrics exporter 或日志管道，也可以搭配\n`StatsAPI` 使用一个小型本地 HTTP API。\n`FlowAudit` 在内存中记录已关闭 flow 的用量，可通过 `StatsAPI`\n暴露类似 Kafra 的用量审计摘要。\n\n按目标组合 addon 的例子见 [Addon recipes](docs/addon-recipes.zh-CN.md)。\n\n## 模型\n\n核心处理 SOCKS5 解析、中继和 hook 调度。策略由 addon 处理。\n\nHook 调度有三种模型：\n\n| 模型 | Hooks | 契约 |\n|------|-------|------|\n| 竞争型 | `on_auth`、`on_connect`、`on_udp_associate` | 第一个非 `None` 结果获胜 |\n| 管道型 | `on_data` | 前一个 addon 的输出成为下一个 addon 的输入 |\n| 观察型 | `on_start`、`on_stop`、`on_flow_close`、`on_error` | 所有适用 addon 都会执行 |\n\n内置 addon：\n\n- `ChainRouter`：TCP 链式代理\n- `UdpOverTcpEntry` 和 `UdpOverTcpExitServer`：UDP 链式代理\n- `FlowStats`：内存 flow 统计\n- `FlowAudit`：已关闭 flow 的用量审计摘要\n- `StatsAPI`：基于 `FlowStats` 的显式 opt-in HTTP API\n- `StatsServer`：`StatsAPI` 的向后兼容名称\n- `TrafficCounter`、`FileAuth`、`IPFilter`、`Logger`\n\n## 架构简图\n\n```text\nClient ── SOCKS5 ──▶ Server ──▶ Target\n                     │\n                     ├─ auth / route hooks\n                     ├─ data pipeline hooks\n                     └─ flow close hooks\n\nChainRouter:\nClient ──▶ A ──▶ B ──▶ C ──▶ Target\n```\n\n## 链式代理\n\n每个节点只知道自己的下一跳：\n\n```python\n# A ─▶ B ─▶ C ─▶ target\nServer(addons=[ChainRouter(\"B:1080\")])  # A\nServer(addons=[ChainRouter(\"C:1080\")])  # B\nServer()                                # C\n```\n\nUDP 链式代理在代理节点之间使用 TCP：\n\n```python\nfrom asyncio_socks_server import Server, UdpOverTcpEntry, UdpOverTcpExitServer\n\nentry = Server(addons=[UdpOverTcpEntry(\"exit-host:9020\")])\nexit_server = UdpOverTcpExitServer(host=\"::\", port=9020)\n```\n\n## Client\n\n```python\nfrom asyncio_socks_server import Address, connect\n\nconn = await connect(\n    proxy_addr=Address(\"127.0.0.1\", 1080),\n    target_addr=Address(\"93.184.216.34\", 443),\n)\nconn.writer.write(b\"hello\")\nawait conn.writer.drain()\ndata = await conn.reader.read(4096)\n```\n\n## API 面\n\n稳定导入面在包根：\n\n```python\nfrom asyncio_socks_server import (\n    Addon,\n    Address,\n    ChainRouter,\n    Flow,\n    FlowAudit,\n    FlowStats,\n    Server,\n    StatsAPI,\n    StatsServer,\n    UdpOverTcpEntry,\n    UdpOverTcpExitServer,\n    connect,\n)\n```\n\n包根导出是 1.x 兼容性契约。子模块仍可导入。\n\n## 文档\n\n| 文档 | 范围 |\n|------|------|\n| [架构](docs/architecture.zh-CN.md) | 核心流程、relay 设计、UDP-over-TCP、Flow context |\n| [Addon recipes](docs/addon-recipes.zh-CN.md) | 按目标组合 addon 的示例 |\n| [Addon 模型](docs/addon-model.zh-CN.md) | Hook 契约、调度语义、内置 addon |\n| [公共 API](docs/public-api.zh-CN.md) | 1.x 兼容面 |\n\n## 开发\n\n```shell\ngit clone https://github.com/Amaindex/asyncio-socks-server.git\ncd asyncio-socks-server\nuv sync\nuv run ruff check .\nuv run ruff format --check .\nuv run pyright\nuv run pytest\nuv build\n```\n\n## 发布\n\nGitHub Actions 测试 Python 3.12 和 3.13，构建 Python package，并构建 Docker images。\n\n从 `v1.3.1` 这样的 tag 创建 GitHub Release。Release workflow 发布 Python package。Docker workflow 发布 semver image tags。\n\n## License\n\nMIT\n"
  },
  {
    "path": "docs/addon-model.md",
    "content": "# Addon Model\n\n[README](../README.md) · [Architecture](architecture.md) · [Addon recipes](addon-recipes.md) · [Public API](public-api.md) · [简体中文](addon-model.zh-CN.md)\n\nAddons are Python classes with optional async methods. The server calls them at defined points in the SOCKS5 flow.\n\nThis document explains dispatch semantics. If you already know what you want to\nbuild, start with [Addon recipes](addon-recipes.md).\n\n## Execution Models\n\nA single dispatch rule is not enough:\n\n- Authentication and routing need first-match-wins.\n- Data processing needs output-to-input chaining.\n- Lifecycle events need all applicable addons to run.\n\nThe manager uses three models:\n\n| Model | Semantics | When to use | Hooks |\n|-------|-----------|-------------|-------|\n| Competitive | First non-`None` wins, rest skipped | Mutually exclusive decisions | `on_auth`, `on_connect`, `on_udp_associate` |\n| Pipeline | Sequential, output→input chaining | Data transformation chains | `on_data` |\n| Observational | All called where applicable; flow-close/error exceptions are caught | Logging, monitoring, cleanup | `on_start`, `on_stop`, `on_flow_close`, `on_error` |\n\n## Hook API\n\nAll methods are optional — unimplemented hooks have no effect.\n\n```python\nclass Addon:\n    # Lifecycle (observational)\n    async def on_start(self) -> None:\n        \"\"\"Server started.\"\"\"\n\n    async def on_stop(self) -> None:\n        \"\"\"Server stopped. Flush buffers, write stats.\"\"\"\n\n    # Authentication (competitive)\n    async def on_auth(self, username: str, password: str) -> bool | None:\n        \"\"\"True = allow, False = deny, None = abstain.\"\"\"\n\n    # Connection interception (competitive)\n    async def on_connect(self, flow: Flow) -> Connection | None:\n        \"\"\"Return Connection to intercept, None to abstain, raise to deny.\"\"\"\n\n    async def on_udp_associate(self, flow: Flow) -> UdpRelayBase | None:\n        \"\"\"Return UdpRelayBase to intercept, None to abstain.\"\"\"\n\n    # Data transformation (pipeline)\n    async def on_data(self, direction: Direction, data: bytes, flow: Flow) -> bytes | None:\n        \"\"\"Return bytes to write, None to drop this chunk, raise to abort.\"\"\"\n\n    # Teardown (observational)\n    async def on_flow_close(self, flow: Flow) -> None:\n        \"\"\"Connection closed. Final stats available in flow.\"\"\"\n\n    async def on_error(self, error: Exception) -> None:\n        \"\"\"Error occurred. For logging/monitoring only.\"\"\"\n```\n\n### Return Value Contract\n\nCompetitive and pipeline hooks use different `None` semantics:\n\n| Hook kind | Return | Meaning |\n|-----------|--------|---------|\n| Competitive | `None` | Abstain — let the next addon or default behavior decide |\n| Competitive | non-`None` | Win — use the returned value as the result |\n| Pipeline `on_data` | `bytes` | Write these bytes and pass them to the next addon |\n| Pipeline `on_data` | `None` | Drop this chunk and stop the pipeline |\n| Any | raise exception | Deny/reject/abort the current operation |\n\nAddons can share a list without coordinating if they use different hooks.\n\n## Competitive Dispatch\n\nFirst non-`None` wins. Remaining addons are skipped.\n\n```\non_auth(\"admin\", \"secret\"):\n  FileAuth  → True        ← wins, stops here\n  IPFilter  → (not called)\n  Logger    → (not called)\n```\n\n```\non_auth(\"unknown\", \"pass\"):\n  FileAuth  → False       ← explicit deny\n  IPFilter  → (not called)\n```\n\n```\non_auth(\"guest\", \"pass\"):\n  FileAuth  → None        ← abstain (user not in file)\n  IPFilter  → None        ← abstain (IP not relevant for auth)\n  → kernel uses default: no auth required → allow\n```\n\nRaising an exception rejects the operation. The client receives a SOCKS5 error reply.\n\n## Pipeline Dispatch\n\nSequential, output-chained. Returning `None` breaks the pipeline (data is dropped, subsequent addons are not called).\n\n```\non_data(up, b\"hello\", flow):\n  UpperAddon    → b\"HELLO\"     ← transform\n  TrafficLogger → b\"HELLO\"     ← pass through by returning input unchanged\n  AppendNull    → b\"HELLO\\x00\" ← transform\n  → write b\"HELLO\\x00\" to target\n```\n\n```\non_data(down, response, flow):\n  DropAddon     → None         ← drops data, pipeline breaks\n  UpperAddon    → (not called)\n  → nothing written to client\n```\n\nPipeline order is addon list order.\n\n## Observational Dispatch\n\nAll addons called. Exceptions caught and not propagated.\n\n```\non_flow_close(flow):\n  TrafficCounter  → aggregates bytes (may raise on write error)\n  Logger          → logs connection stats\n  → all called, any exceptions logged but suppressed\n```\n\nThis keeps teardown and monitoring isolated from individual addon failures.\n\n## Built-in Addons\n\n| Addon | Primary role | Starts network listeners |\n|-------|--------------|--------------------------|\n| `ChainRouter` | TCP next-hop routing | No |\n| `UdpOverTcpEntry` | UDP-over-TCP entry routing | No |\n| `UdpOverTcpExitServer` | UDP-over-TCP exit service | Yes, as a separate server |\n| `FlowStats` | Runtime counters and active flow snapshots | No |\n| `FlowAudit` | Closed-flow usage audit window | No |\n| `StatsAPI` | Optional HTTP presentation for stats and audit | Yes, only when added |\n| `StatsServer` | Backward-compatible name for `StatsAPI` | Yes, only when added |\n| `TrafficCounter` | Minimal closed-flow byte totals | No |\n| `FileAuth` | Username/password auth from JSON | No |\n| `IPFilter` | Source IP allow/block policy | No |\n| `Logger` | Connection and data logging | No |\n\nAll built-in addons are opt-in. CLI mode starts a direct SOCKS5 server; addon\ncomposition is configured from Python.\n\n### ChainRouter — TCP Chain Proxying\n\n```python\nclass ChainRouter(Addon):\n    def __init__(self, next_hop: str): ...\n\n    async def on_connect(self, flow):\n        conn = await client.connect(self.next_hop, flow.dst)\n        return conn\n```\n\n`ChainRouter` returns a `Connection` to the next-hop SOCKS5 server. The server relays through the returned connection.\n\nEach node only knows its next hop:\n\n```\nUser → [A: ChainRouter(\"B:1080\")] → [B: ChainRouter(\"C:1080\")] → [C: direct] → Target\n```\n\n### UdpOverTcpEntry — UDP Chain Proxying\n\nUDP chain proxying reuses the same competitive hook (`on_udp_associate`), but returns a bridge that encapsulates UDP datagrams as TCP frames instead of a `Connection`.\n\n```\nClient UDP → Entry addon (encapsulate) → TCP chain → Exit server (decapsulate) → UDP → Target\n```\n\nMiddle nodes see TCP bytes.\n\n### TrafficCounter — Stats Aggregation\n\n```python\nclass TrafficCounter(Addon):\n    async def on_connect(self, flow):\n        self.connections += 1\n\n    async def on_flow_close(self, flow):\n        self.bytes_up += flow.bytes_up\n        self.bytes_down += flow.bytes_down\n```\n\n`TrafficCounter` aggregates in `on_flow_close`. `Flow` already has cumulative byte counters, and UDP does not pass through `on_data`.\n\n### FlowStats — Flow Statistics Infrastructure\n\n```python\nfrom asyncio_socks_server import FlowStats, Server\n\nstats = FlowStats()\nserver = Server(addons=[stats])\n```\n\n`FlowStats` has no network side effects. It records flow lifecycle data through\naddon hooks and exposes Python methods for application-specific presentation:\n\n| Method | Content |\n|--------|---------|\n| `snapshot()` | Aggregate counters, rates, errors, and active flows |\n| `flows()` | Active flows and recent closed flow snapshots |\n| `errors()` | Error counters and recent errors |\n\nUse `FlowStats` as infrastructure for your own HTTP API, Prometheus exporter,\nfile audit stream, or control-plane integration.\n\n### FlowAudit — Usage Audit Infrastructure\n\n```python\nfrom asyncio_socks_server import FlowAudit, Server\n\naudit = FlowAudit()\nserver = Server(addons=[audit])\n```\n\n`FlowAudit` has no network side effects. It records closed flows in memory and\naggregates usage by source host and target host:\n\n| Method | Content |\n|--------|---------|\n| `snapshot()` | Kafra-like audit summary with period, records, totals, devices, and traffic |\n| `reset()` | Clear the in-memory audit window |\n\nThe audit window resets when the process restarts. Use an application-specific\nsink if you need durable long-term audit storage.\n\n### StatsAPI — Opt-in HTTP API\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\napi = StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900)\nserver = Server(addons=[audit, stats, api])\n```\n\n`StatsAPI` is a simple stdlib HTTP wrapper around `FlowStats` and optional\n`FlowAudit`. It starts a listener only when explicitly added to the addon list:\n\n| Endpoint | Content |\n|----------|---------|\n| `GET /health` | Liveness response |\n| `GET /stats` | `FlowStats.snapshot()` |\n| `GET /flows` | `FlowStats.flows()` |\n| `GET /errors` | `FlowStats.errors()` |\n| `GET /audit?top=25&device=` | `FlowAudit.snapshot()` |\n| `POST /audit/refresh?top=25&device=` | Current `FlowAudit.snapshot()` for Kafra-like refresh flows |\n\nWhen constructed without a `FlowStats` instance, `StatsAPI` creates and owns one:\n\n```python\nserver = Server(addons=[StatsAPI(host=\"127.0.0.1\", port=9900)])\n```\n\n`StatsServer` remains as a backward-compatible name for `StatsAPI`.\n\nPut `FlowStats` or owning `StatsAPI` early in the addon list. It observes flow starts through competitive hooks. An earlier winning addon can prevent it from seeing a start event. `on_flow_close` still receives the final Flow snapshot.\n\n### FileAuth — Multi-user Auth\n\nReads a JSON file mapping usernames to passwords. Caches after first load.\n`FileAuth` is consulted only when the server negotiates username/password auth,\nso configure `Server(auth=...)` when using it.\n\n### IPFilter — Source IP Access Control\n\n```python\nIPFilter(allowed=[\"10.0.0.0/24\"])\n# or\nIPFilter(blocked=[\"10.0.0.5\"])\n```\n\nReads `flow.src.host` in `on_connect`. Denied connections receive SOCKS5 `CONNECTION_NOT_ALLOWED` reply.\n\n### Logger — Connection Logging\n\nLogs connection details and flow stats. It does not change proxy behavior.\n\n## Custom Addon Patterns\n\n### Selective Content Inspection\n\n```python\nclass ContentFilter(Addon):\n    async def on_connect(self, flow):\n        if flow.dst.port != 80:\n            return  # only inspect HTTP\n\n    async def on_data(self, direction, data, flow):\n        if direction == Direction.UP and b\"forbidden-keyword\" in data:\n            raise Exception(\"blocked content\")\n        return data  # pass through\n```\n\n### Per-connection Rate Limiting\n\n```python\nclass RateLimiter(Addon):\n    def __init__(self, max_bytes=1024 * 1024):  # 1MB per connection\n        self.max_bytes = max_bytes\n\n    async def on_data(self, direction, data, flow):\n        if flow.bytes_up + flow.bytes_down > self.max_bytes:\n            raise Exception(\"rate limit exceeded\")\n        return data\n```\n\n### Dynamic Next-hop Routing\n\n```python\nclass DynamicRouter(Addon):\n    def __init__(self):\n        self.routes = {}  # domain pattern → next hop\n\n    async def on_connect(self, flow):\n        for pattern, hop in self.routes.items():\n            if pattern in flow.dst.host:\n                return await client.connect(hop, flow.dst)\n```\n\n## Dispatch Internals\n\n`AddonManager` skips unimplemented hooks by checking `type(addon).method is not Addon.method`. This avoids creating coroutines for base-class methods that do nothing — significant when processing thousands of chunks through `on_data`.\n\nAddon list order is execution order. There is no priority system or dependency resolution — if order matters, arrange the list accordingly.\n\nFor hook signature and Flow compatibility, see\n[`public-api.md`](public-api.md).\n"
  },
  {
    "path": "docs/addon-model.zh-CN.md",
    "content": "# Addon 模型\n\n[README](../README.zh-CN.md) · [架构](architecture.zh-CN.md) · [Addon recipes](addon-recipes.zh-CN.md) · [公共 API](public-api.zh-CN.md) · [English](addon-model.md)\n\nAddon 是包含可选 async 方法的 Python 类。Server 在 SOCKS5 流程的固定位置调用它们。\n\n本文解释派发语义。如果你已经知道自己想搭建什么，先看\n[Addon recipes](addon-recipes.zh-CN.md)。\n\n## 执行模型\n\n单一派发规则不够：\n\n- 认证和路由需要第一个结果胜出。\n- 数据处理需要输出到输入的链式传递。\n- 生命周期事件需要调用所有适用 addon。\n\nManager 使用三种模型：\n\n| 模型 | 语义 | 何时使用 | Hook |\n|------|------|----------|------|\n| 竞争型 | 第一个非 `None` 胜出，后续跳过 | 互斥决策 | `on_auth`、`on_connect`、`on_udp_associate` |\n| 管道型 | 顺序执行，输出→输入链式传递 | 数据转换链 | `on_data` |\n| 观察型 | 按场景全部调用；flow-close/error 异常被捕获 | 日志、监控、清理 | `on_start`、`on_stop`、`on_flow_close`、`on_error` |\n\n## Hook API\n\n所有方法可选——未实现的 hook 不影响流程。\n\n```python\nclass Addon:\n    # 生命周期（观察型）\n    async def on_start(self) -> None:\n        \"\"\"服务器启动。\"\"\"\n\n    async def on_stop(self) -> None:\n        \"\"\"服务器停止。刷新缓冲、写入统计。\"\"\"\n\n    # 认证（竞争型）\n    async def on_auth(self, username: str, password: str) -> bool | None:\n        \"\"\"True = 放行，False = 拒绝，None = 不干预。\"\"\"\n\n    # 连接拦截（竞争型）\n    async def on_connect(self, flow: Flow) -> Connection | None:\n        \"\"\"返回 Connection 拦截，None 不干预，抛异常拒绝。\"\"\"\n\n    async def on_udp_associate(self, flow: Flow) -> UdpRelayBase | None:\n        \"\"\"返回 UdpRelayBase 拦截，None 不干预。\"\"\"\n\n    # 数据转换（管道型）\n    async def on_data(self, direction: Direction, data: bytes, flow: Flow) -> bytes | None:\n        \"\"\"返回 bytes 写出，None 丢弃当前 chunk，抛异常中止。\"\"\"\n\n    # 拆解（观察型）\n    async def on_flow_close(self, flow: Flow) -> None:\n        \"\"\"连接关闭。最终统计在 flow 中。\"\"\"\n\n    async def on_error(self, error: Exception) -> None:\n        \"\"\"发生异常。仅用于日志/监控。\"\"\"\n```\n\n### 返回值契约\n\n竞争型和管道型 hook 的 `None` 语义不同：\n\n| Hook 类型 | 返回 | 含义 |\n|----------|------|------|\n| 竞争型 | `None` | 弃权——让下一个 addon 或默认行为决定 |\n| 竞争型 | 非 `None` | 胜出——将返回值作为结果 |\n| 管道型 `on_data` | `bytes` | 写出这些字节，并继续传给下一个 addon |\n| 管道型 `on_data` | `None` | 丢弃当前 chunk，并停止管道 |\n| 任意 | 抛异常 | 拒绝/中止当前操作 |\n\n如果 addon 使用不同 hook，可以共存而不需要互相协调。\n\n## 竞争型派发\n\n第一个非 `None` 胜出。剩余 addon 跳过。\n\n```\non_auth(\"admin\", \"secret\"):\n  FileAuth  → True        ← 胜出，在此停止\n  IPFilter  → （不调用）\n  Logger    → （不调用）\n```\n\n```\non_auth(\"unknown\", \"pass\"):\n  FileAuth  → False       ← 显式拒绝\n  IPFilter  → （不调用）\n```\n\n```\non_auth(\"guest\", \"pass\"):\n  FileAuth  → None        ← 不干预（用户不在文件中）\n  IPFilter  → None        ← 不干预（IP 与认证无关）\n  → 内核使用默认行为：无需认证 → 放行\n```\n\n抛异常会拒绝当前操作。客户端收到 SOCKS5 错误回复。\n\n## 管道型派发\n\n顺序执行，输出链式传递。返回 `None` 中断管道（数据丢弃，后续 addon 不调用）。\n\n```\non_data(up, b\"hello\", flow):\n  UpperAddon    → b\"HELLO\"     ← 转换\n  TrafficLogger → b\"HELLO\"     ← 通过返回原输入来放行\n  AppendNull    → b\"HELLO\\x00\" ← 转换\n  → 写入目标: b\"HELLO\\x00\"\n```\n\n```\non_data(down, response, flow):\n  DropAddon     → None         ← 丢弃数据，管道中断\n  UpperAddon    → （不调用）\n  → 不向客户端写入任何内容\n```\n\n管道顺序即 addon 列表顺序。\n\n## 观察型派发\n\n所有 addon 调用。异常被捕获不传播。\n\n```\non_flow_close(flow):\n  TrafficCounter  → 聚合字节（写入时可能抛异常）\n  Logger          → 记录连接统计\n  → 全部调用，任何异常被记录但被抑制\n```\n\n这把 teardown 和监控从单个 addon 的失败中隔离出来。\n\n## 内置 Addon\n\n| Addon | 主要角色 | 是否启动网络 listener |\n|-------|----------|-----------------------|\n| `ChainRouter` | TCP 下一跳路由 | 否 |\n| `UdpOverTcpEntry` | UDP-over-TCP 入口路由 | 否 |\n| `UdpOverTcpExitServer` | UDP-over-TCP 出口服务 | 是，作为独立 server |\n| `FlowStats` | 运行计数和活跃 flow 快照 | 否 |\n| `FlowAudit` | 已关闭 flow 的用量审计窗口 | 否 |\n| `StatsAPI` | stats/audit 的可选 HTTP 展示层 | 是，只有加入 addon 列表才启动 |\n| `StatsServer` | `StatsAPI` 的向后兼容名称 | 是，只有加入 addon 列表才启动 |\n| `TrafficCounter` | 最小的已关闭 flow 字节汇总 | 否 |\n| `FileAuth` | JSON 用户名/密码认证 | 否 |\n| `IPFilter` | 来源 IP allow/block 策略 | 否 |\n| `Logger` | 连接和数据日志 | 否 |\n\n所有内置 addon 都是显式 opt-in。CLI 模式启动直连 SOCKS5 server；addon\n组合通过 Python 配置。\n\n### ChainRouter — TCP 链式代理\n\n```python\nclass ChainRouter(Addon):\n    def __init__(self, next_hop: str): ...\n\n    async def on_connect(self, flow):\n        conn = await client.connect(self.next_hop, flow.dst)\n        return conn\n```\n\n`ChainRouter` 返回到下一跳 SOCKS5 server 的 `Connection`。Server 通过返回的连接中继。\n\n每个节点只知道自己下一跳：\n\n```\n用户 → [A: ChainRouter(\"B:1080\")] → [B: ChainRouter(\"C:1080\")] → [C: 直连] → 目标\n```\n\n### UdpOverTcpEntry — UDP 链式代理\n\nUDP 链式代理复用同一个竞争型 hook（`on_udp_associate`），但返回一个将 UDP 数据报封装为 TCP 帧的 bridge，而非 `Connection`。\n\n```\n客户端 UDP → 入口 addon（封装）→ TCP 链式 → 出口服务（拆封）→ UDP → 目标\n```\n\n中间节点只看到 TCP bytes。\n\n### TrafficCounter — 统计聚合\n\n```python\nclass TrafficCounter(Addon):\n    async def on_connect(self, flow):\n        self.connections += 1\n\n    async def on_flow_close(self, flow):\n        self.bytes_up += flow.bytes_up\n        self.bytes_down += flow.bytes_down\n```\n\n`TrafficCounter` 在 `on_flow_close` 中聚合。`Flow` 已经有累计字节计数，且 UDP 不经过 `on_data`。\n\n### FlowStats — Flow 统计基础设施\n\n```python\nfrom asyncio_socks_server import FlowStats, Server\n\nstats = FlowStats()\nserver = Server(addons=[stats])\n```\n\n`FlowStats` 没有网络副作用。它通过 addon hooks 记录 flow 生命周期数据，\n并暴露 Python 方法供应用自行决定展示方式：\n\n| 方法 | 内容 |\n|------|------|\n| `snapshot()` | 聚合计数、速率、错误和活跃 flow |\n| `flows()` | 活跃 flow 和最近关闭 flow 快照 |\n| `errors()` | 错误计数和最近错误 |\n\n用 `FlowStats` 搭建自己的 HTTP API、Prometheus exporter、文件审计流或控制面集成。\n\n### FlowAudit — 用量审计基础设施\n\n```python\nfrom asyncio_socks_server import FlowAudit, Server\n\naudit = FlowAudit()\nserver = Server(addons=[audit])\n```\n\n`FlowAudit` 没有网络副作用。它在内存中记录已关闭 flow，并按 source host\n和 target host 聚合用量：\n\n| 方法 | 内容 |\n|------|------|\n| `snapshot()` | 类似 Kafra audit 的摘要，包含 period、records、total、devices 和 traffic |\n| `reset()` | 清空当前内存审计窗口 |\n\n进程重启后审计窗口会重置。如果需要长期留痕，应在应用层接入持久化 sink。\n\n### StatsAPI — 显式 opt-in HTTP API\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\napi = StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900)\nserver = Server(addons=[audit, stats, api])\n```\n\n`StatsAPI` 是基于 `FlowStats` 和可选 `FlowAudit` 的简单标准库 HTTP\nwrapper。只有显式加入 addon 列表时才会启动 listener：\n\n| Endpoint | 内容 |\n|----------|------|\n| `GET /health` | 存活响应 |\n| `GET /stats` | `FlowStats.snapshot()` |\n| `GET /flows` | `FlowStats.flows()` |\n| `GET /errors` | `FlowStats.errors()` |\n| `GET /audit?top=25&device=` | `FlowAudit.snapshot()` |\n| `POST /audit/refresh?top=25&device=` | 返回当前 `FlowAudit.snapshot()`，用于类似 Kafra 的刷新流程 |\n\n如果不传入 `FlowStats`，`StatsAPI` 会自己创建并托管一个：\n\n```python\nserver = Server(addons=[StatsAPI(host=\"127.0.0.1\", port=9900)])\n```\n\n`StatsServer` 作为 `StatsAPI` 的向后兼容名称保留。\n\n建议把 `FlowStats` 或托管自身 stats 的 `StatsAPI` 放在 addon 列表靠前位置。它通过竞争型 hook 观察 flow start。更早胜出的 addon 会让它看不到 start 事件。`on_flow_close` 仍会收到最终 Flow 快照。\n\n### FileAuth — 多用户认证\n\n从 JSON 文件读取用户名/密码映射。首次加载后缓存。只有 server 协商\nusername/password auth 时才会调用 `FileAuth`，因此使用它时需要配置\n`Server(auth=...)`。\n\n### IPFilter — 源 IP 访问控制\n\n```python\nIPFilter(allowed=[\"10.0.0.0/24\"])\n# 或\nIPFilter(blocked=[\"10.0.0.5\"])\n```\n\n在 `on_connect` 中读取 `flow.src.host`。被拒绝的连接收到 SOCKS5 `CONNECTION_NOT_ALLOWED` 回复。\n\n### Logger — 连接日志\n\n记录连接详情和流量统计。不改变代理行为。\n\n## 自定义 Addon 模式\n\n### 选择性内容检查\n\n```python\nclass ContentFilter(Addon):\n    async def on_connect(self, flow):\n        if flow.dst.port != 80:\n            return  # 只检查 HTTP\n\n    async def on_data(self, direction, data, flow):\n        if direction == Direction.UP and b\"forbidden-keyword\" in data:\n            raise Exception(\"blocked content\")\n        return data  # 放行\n```\n\n### 每连接速率限制\n\n```python\nclass RateLimiter(Addon):\n    def __init__(self, max_bytes=1024 * 1024):  # 每连接 1MB\n        self.max_bytes = max_bytes\n\n    async def on_data(self, direction, data, flow):\n        if flow.bytes_up + flow.bytes_down > self.max_bytes:\n            raise Exception(\"rate limit exceeded\")\n        return data\n```\n\n### 动态下一跳路由\n\n```python\nclass DynamicRouter(Addon):\n    def __init__(self):\n        self.routes = {}  # 域名模式 → 下一跳\n\n    async def on_connect(self, flow):\n        for pattern, hop in self.routes.items():\n            if pattern in flow.dst.host:\n                return await client.connect(hop, flow.dst)\n```\n\n## 派发内部机制\n\n`AddonManager` 通过 `type(addon).method is not Addon.method` 检测子类是否重写了方法，跳过未重写的。这避免为基类的空方法创建协程——在处理数千个 chunk 经过 `on_data` 时影响显著。\n\nAddon 列表顺序即执行顺序。没有优先级系统或依赖解析——如果顺序重要，自行安排列表。\n\nHook 签名和 Flow 语义的兼容性承诺见\n[`public-api.zh-CN.md`](public-api.zh-CN.md)。\n"
  },
  {
    "path": "docs/addon-recipes.md",
    "content": "# Addon Recipes\n\n[README](../README.md) · [Architecture](architecture.md) · [Addon model](addon-model.md) · [Public API](public-api.md) · [简体中文](addon-recipes.zh-CN.md)\n\nUse this page when choosing which addons to combine. Addons are opt-in and run\nin the order listed in `Server(addons=[...])`.\n\n## Direct SOCKS5 Server\n\nNo addons are required for a plain SOCKS5 server:\n\n```python\nfrom asyncio_socks_server import Server\n\nServer(host=\"::\", port=1080).run()\n```\n\nCLI mode is equivalent to this direct shape plus optional single-user auth.\n\n## Runtime Counters\n\nUse `FlowStats` for counters and `StatsAPI` only if you want an HTTP endpoint:\n\n```python\nfrom asyncio_socks_server import FlowStats, Server, StatsAPI\n\nstats = FlowStats()\nserver = Server(\n    addons=[\n        stats,\n        StatsAPI(stats=stats, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nEndpoints:\n\n| Endpoint | Use |\n|----------|-----|\n| `GET /health` | Liveness |\n| `GET /stats` | Totals, rates, errors, active flows |\n| `GET /flows` | Active and recent closed flows |\n| `GET /errors` | Error counters |\n\n`FlowStats` should appear before competitive routing addons if you need flow\nstart visibility.\n\n## Usage Audit\n\nUse `FlowAudit` for closed-flow usage grouped by source host and target host:\n\n```python\nfrom asyncio_socks_server import FlowAudit, Server, StatsAPI\n\naudit = FlowAudit()\nserver = Server(\n    addons=[\n        audit,\n        StatsAPI(audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nEndpoints:\n\n| Endpoint | Use |\n|----------|-----|\n| `GET /audit?top=25&device=` | Current in-memory audit window |\n| `POST /audit/refresh?top=25&device=` | Same snapshot, useful for control-plane refresh flows |\n\nThe audit window is in-memory and resets when the process restarts. Add a\ncustom sink if you need durable records.\n\n## Runtime Counters Plus Audit\n\nThis is the normal observability stack:\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\n`StatsAPI` is a presentation layer. It does not collect stats or audit data by\nitself unless it owns an internal `FlowStats`; pass explicit `FlowStats` and\n`FlowAudit` instances when other code also needs direct Python access.\n\n## TCP Chain Proxy\n\nUse `ChainRouter` when this server should forward TCP CONNECT traffic through a\ndownstream SOCKS5 server:\n\n```python\nfrom asyncio_socks_server import ChainRouter, Server\n\nServer(addons=[ChainRouter(\"10.0.0.5:1080\")]).run()\n```\n\nEach node only knows its next hop:\n\n```python\nServer(addons=[ChainRouter(\"B:1080\")])  # A\nServer(addons=[ChainRouter(\"C:1080\")])  # B\nServer()                                # C\n```\n\n## UDP Over TCP Chain\n\nUse `UdpOverTcpEntry` at the SOCKS-facing node and\n`UdpOverTcpExitServer` at the exit:\n\n```python\nfrom asyncio_socks_server import Server, UdpOverTcpEntry, UdpOverTcpExitServer\n\nentry = Server(addons=[UdpOverTcpEntry(\"exit-host:9020\")])\nexit_server = UdpOverTcpExitServer(host=\"::\", port=9020)\n```\n\nMiddle chain nodes see TCP bytes.\n\n## Auth, Source Policy, And Logs\n\nUse these independently or together:\n\n```python\nfrom asyncio_socks_server import FileAuth, IPFilter, Logger, Server\n\nserver = Server(\n    auth=(\"_fallback_disabled_\", \"_fallback_disabled_\"),\n    addons=[\n        FileAuth(\"/etc/asyncio-socks-users.json\"),\n        IPFilter(allowed=[\"10.0.0.0/24\"]),\n        Logger(),\n    ],\n)\nserver.run()\n```\n\n`FileAuth` is consulted only when server auth is enabled; the `auth` tuple\nforces username/password negotiation and remains a valid fallback credential, so\nset it deliberately. `IPFilter` accepts either `allowed` or `blocked`, not both.\n`Logger` observes traffic without changing routing.\n\n## Compatibility Names\n\n`StatsServer` is a backward-compatible name for `StatsAPI`. New code should use\n`StatsAPI` because it describes the role more precisely.\n"
  },
  {
    "path": "docs/addon-recipes.zh-CN.md",
    "content": "# Addon Recipes\n\n[README](../README.zh-CN.md) · [架构](architecture.zh-CN.md) · [Addon 模型](addon-model.zh-CN.md) · [公共 API](public-api.zh-CN.md) · [English](addon-recipes.md)\n\n当你需要选择 addon 组合时，从这里开始。Addon 都是显式 opt-in，并按\n`Server(addons=[...])` 中的顺序执行。\n\n## 直连 SOCKS5 Server\n\n普通 SOCKS5 server 不需要任何 addon：\n\n```python\nfrom asyncio_socks_server import Server\n\nServer(host=\"::\", port=1080).run()\n```\n\nCLI 模式等价于这个直连形态，加上可选的单用户认证。\n\n## 运行计数\n\n用 `FlowStats` 收集计数；只有需要 HTTP endpoint 时才加 `StatsAPI`：\n\n```python\nfrom asyncio_socks_server import FlowStats, Server, StatsAPI\n\nstats = FlowStats()\nserver = Server(\n    addons=[\n        stats,\n        StatsAPI(stats=stats, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nEndpoints：\n\n| Endpoint | 用途 |\n|----------|------|\n| `GET /health` | 存活检查 |\n| `GET /stats` | 总量、速率、错误、活跃 flows |\n| `GET /flows` | 活跃和最近关闭 flows |\n| `GET /errors` | 错误计数 |\n\n如果需要看到 flow start，`FlowStats` 应放在竞争型路由 addon 之前。\n\n## 用量审计\n\n用 `FlowAudit` 按 source host 和 target host 聚合已关闭 flow 的用量：\n\n```python\nfrom asyncio_socks_server import FlowAudit, Server, StatsAPI\n\naudit = FlowAudit()\nserver = Server(\n    addons=[\n        audit,\n        StatsAPI(audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\nEndpoints：\n\n| Endpoint | 用途 |\n|----------|------|\n| `GET /audit?top=25&device=` | 当前内存审计窗口 |\n| `POST /audit/refresh?top=25&device=` | 同一份 snapshot，便于控制面做刷新流程 |\n\n审计窗口在内存中，进程重启后会清空。如果需要长期留痕，应增加自定义 sink。\n\n## 运行计数加审计\n\n这是常见的观测组合：\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\nserver.run()\n```\n\n`StatsAPI` 是展示层。除非它自己托管内部 `FlowStats`，否则它不直接收集\nstats 或 audit 数据。当其他代码也需要 Python API 时，显式传入\n`FlowStats` 和 `FlowAudit` 实例。\n\n## TCP 链式代理\n\n当这个 server 需要把 TCP CONNECT 流量转发到下游 SOCKS5 server 时，使用\n`ChainRouter`：\n\n```python\nfrom asyncio_socks_server import ChainRouter, Server\n\nServer(addons=[ChainRouter(\"10.0.0.5:1080\")]).run()\n```\n\n每个节点只知道自己的下一跳：\n\n```python\nServer(addons=[ChainRouter(\"B:1080\")])  # A\nServer(addons=[ChainRouter(\"C:1080\")])  # B\nServer()                                # C\n```\n\n## UDP Over TCP 链式代理\n\n在面向 SOCKS 的入口节点使用 `UdpOverTcpEntry`，在出口节点使用\n`UdpOverTcpExitServer`：\n\n```python\nfrom asyncio_socks_server import Server, UdpOverTcpEntry, UdpOverTcpExitServer\n\nentry = Server(addons=[UdpOverTcpEntry(\"exit-host:9020\")])\nexit_server = UdpOverTcpExitServer(host=\"::\", port=9020)\n```\n\n中间链路节点只看到 TCP bytes。\n\n## 认证、来源策略和日志\n\n这些 addon 可以独立使用，也可以组合：\n\n```python\nfrom asyncio_socks_server import FileAuth, IPFilter, Logger, Server\n\nserver = Server(\n    auth=(\"_fallback_disabled_\", \"_fallback_disabled_\"),\n    addons=[\n        FileAuth(\"/etc/asyncio-socks-users.json\"),\n        IPFilter(allowed=[\"10.0.0.0/24\"]),\n        Logger(),\n    ],\n)\nserver.run()\n```\n\n只有启用 server auth 时，`FileAuth` 才会被调用；`auth` tuple 用于强制\nusername/password 协商，且它本身仍是有效的 fallback 凭证，因此需要明确设置。\n`IPFilter` 接受 `allowed` 或 `blocked`，不要同时传入。`Logger` 只观察流量，\n不改变路由。\n\n## 兼容名称\n\n`StatsServer` 是 `StatsAPI` 的向后兼容名称。新代码建议使用 `StatsAPI`，\n因为这个名字更准确地表达它是展示层。\n"
  },
  {
    "path": "docs/architecture.md",
    "content": "# Architecture\n\n[README](../README.md) · [Addon recipes](addon-recipes.md) · [Addon model](addon-model.md) · [Public API](public-api.md) · [简体中文](architecture.zh-CN.md)\n\nThe core handles protocol parsing, relay, and hook dispatch. Addons handle policy and routing. Chain proxying, traffic counting, and access control are addon behavior.\n\n## System Overview\n\n```text\nSOCKS5 Client          Server                          Remote\n─────────────          ──────                          ──────\n\nauth negotiation ────▶ parse_method_selection\n                       parse_username_password\n                       dispatch_auth (competitive)\n                       │\nCONNECT/UDP request ─▶ parse_request → create Flow\n                       dispatch_connect / dispatch_udp_associate\n                       ├─ no addon ─────────────────▶ direct connect\n                       └─ ChainRouter ──────────────▶ client.connect(next_hop)\n                       │\nbidirectional relay ─▶ dispatch_data (pipeline) per chunk\n                       flow.bytes_up/down per chunk or datagram\n                       │\nconnection close ────▶ dispatch_flow_close (observational)\n                       log stats from Flow\n```\n\nChain proxying uses the same path. `dispatch_connect` returns a `Connection` to the next-hop SOCKS5 server instead of a direct TCP connection.\n\n## Request Lifecycle\n\nEvery request has three stages:\n\n| Stage | Entry | Core Action | Output |\n|-------|-------|-------------|--------|\n| Handshake | SOCKS5 client | Parse method selection + auth + request, create Flow | Flow with src/dst/protocol |\n| Relay | Flow + addon decision | Bidirectional data pump with addon pipeline, Flow tracks bytes | Data forwarded, bytes counted |\n| Teardown | Connection close | Log stats, dispatch `on_flow_close` | Addons get final stats |\n\nTCP and UDP share the hook lifecycle. TCP uses paired `_copy()` coroutines. UDP uses a shared socket and routing table.\n\n### TCP Relay Data Flow\n\n```text\nClient          Server                                  Target\n──────          ──────                                  ──────\n\n  │               │                                       │\n  │── handshake ─▶│                                       │\n  │               │  parse + auth + Flow                  │\n  │               │                                       │\n  │               │  dispatch_connect(flow)               │\n  │               │  ├─ no addon ──▶ direct               │\n  │               │  └─ ChainRouter ──▶ next hop          │\n  │               │                                       │\n  │── data ──────▶│── _copy(client→target)───────────────▶│\n  │               │  dispatch_data(up, data, flow)        │\n  │               │  flow.bytes_up += len(data)           │\n  │               │                                       │\n  │◀─ response ───│◀── _copy(target→client)               │\n  │               │  dispatch_data(down, data)            │\n  │               │  flow.bytes_down += len(data)         │\n  │               │                                       │\n  │── close ─────▶│── dispatch_flow_close(flow)──────────▶│\n  │               │  log: ↑1.2KB ↓45.6KB                  │\n  │               │                                       │\n```\n\n### UDP Relay Architecture\n\nSome SOCKS5 implementations create one outbound UDP socket per client source port. Long-running servers can accumulate sockets.\n\nThis implementation uses one outbound socket and a bidirectional routing table:\n\n```text\nOutbound:\nClient datagram ──▶ shared_socket.sendto(payload, target)\n                      route_map[(\"93.184.216.34\", 443)] = (\"10.0.0.1\", 54321)\n                      flow.bytes_up += len(payload)\n\nInbound:\nshared_socket.recvfrom() ──▶ lookup route_map ──▶ sendto(client, response)\n                               flow.bytes_down += len(response)\n```\n\nRoutes expire by TTL.\n\n## UDP-over-TCP Chaining\n\nUDP chain proxying does not use UDP between proxy nodes. Inter-node transport is TCP.\n\nEntry nodes encapsulate UDP datagrams as TCP frames. Exit nodes decapsulate them back to UDP.\n\n```text\nRequest:\nClient UDP ──▶ UdpOverTcpEntry ──▶ middle nodes ──▶ Exit server ──▶ raw UDP ──▶ Target\n                encapsulate          (TCP bytes)      decapsulate\n                UDP → TCP                             TCP → UDP\n\nResponse:\nTarget ──▶ raw UDP ──▶ Exit server ──▶ middle nodes ──▶ UdpOverTcpEntry ──▶ Client UDP\n                         encapsulate       (TCP bytes)      decapsulate\n                         UDP → TCP                         TCP → UDP\n```\n\nFrame format (4-byte length prefix + SOCKS5 encoded address + payload):\n\n```text\n┌──────────┬──────────────────┬─────────┐\n│ Length   │ Encoded Address  │ Payload │\n│ 4 bytes  │ variable         │ N bytes │\n└──────────┴──────────────────┴─────────┘\n```\n\nProperties:\n\n- Middle nodes only forward TCP CONNECT traffic.\n- `on_data` sees TCP bytes in both TCP and UDP-over-TCP cases.\n- UDP semantics remain at the client-entry and exit-target edges.\n- No per-hop UDP ASSOCIATE state is needed.\n\n## Flow Context\n\n`Flow` is the per-connection context passed through hooks.\n\n```python\n@dataclass\nclass Flow:\n    id: int               # Monotonically increasing\n    src: Address          # Client address\n    dst: Address          # Target address\n    protocol: Literal[\"tcp\", \"udp\"]\n    started_at: float     # time.monotonic()\n    bytes_up: int = 0     # Client → target (TCP: post-addon; UDP: raw payload)\n    bytes_down: int = 0   # Target → client\n```\n\nWithout `Flow`, data hooks have no connection identity. Byte counters also become easy to duplicate across relay and addon code.\n\nWith `Flow`:\n\n- Bytes are counted once in relay code.\n- Hooks receive the same object for the connection lifecycle.\n- `on_flow_close` receives the final counters.\n\nLifecycle:\n\n```\non_connect / on_udp_associate(flow)     → addon registers connection, gets identity\non_data(direction, data, flow)          → addon knows whose data, can read running stats\n  └─ relay updates flow.bytes_* directly\non_flow_close(flow)                     → addon gets final snapshot, can log/aggregate\n```\n\n## IPv6 Dual-Stack\n\nServer listens on `::` with one `AF_INET6` socket (`IPV6_V6ONLY=0`), handling IPv4 and IPv6.\n\nClient connection uses Happy Eyeballs-style fallback. It resolves IPv6 and IPv4 candidates, starts one candidate, then staggers subsequent candidates every 250ms.\n\nUDP relay normalizes IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) in routing tables.\n\n## Async Hooks\n\nThe data path uses `StreamReader` and `StreamWriter`. It is already async:\n`await reader.read()` -> process -> `await writer.drain()`.\n\nAsync hooks allow:\n\n- `ChainRouter.on_connect` to `await client.connect()` directly.\n- `on_auth` to use async I/O.\n- No sync-to-async bridge in the relay path.\n\nThe extra `await` is outside the main cost path.\n\n## Design Decisions\n\n| Decision | Choice | Rationale |\n|----------|--------|-----------|\n| SOCKS version | SOCKS5 | Covers CONNECT and UDP ASSOCIATE |\n| Runtime deps | Zero | Stdlib only |\n| Addon model | Class-based, async | One class with multiple hooks gives natural state management; async matches the data path |\n| Config method | Python scripts | Addons are regular Python objects |\n| Hot reload | Not in kernel | Use an external watcher if needed |\n| Resource limits | Not in kernel | Use system-level limits |\n\n## Topic Docs\n\n| Doc | Content |\n|-----|---------|\n| [`addon-model.md`](addon-model.md) | Hook API, execution models, built-in addons, chain proxying |\n| [`public-api.md`](public-api.md) | 1.x compatibility surface, root exports, hook contracts |\n"
  },
  {
    "path": "docs/architecture.zh-CN.md",
    "content": "# 架构与数据流\n\n[README](../README.zh-CN.md) · [Addon recipes](addon-recipes.zh-CN.md) · [Addon 模型](addon-model.zh-CN.md) · [公共 API](public-api.zh-CN.md) · [English](architecture.md)\n\n核心处理协议解析、中继和 hook 调度。Addon 处理策略和路由。链式代理、流量统计、访问控制都是 addon 行为。\n\n## 系统总览\n\n```text\nSOCKS5 Client          Server                          Remote\n─────────────          ──────                          ──────\n\nauth negotiation ────▶ parse_method_selection\n                       parse_username_password\n                       dispatch_auth (competitive)\n                       │\nCONNECT/UDP request ─▶ parse_request → create Flow\n                       dispatch_connect / dispatch_udp_associate\n                       ├─ no addon ─────────────────▶ direct connect\n                       └─ ChainRouter ──────────────▶ client.connect(next_hop)\n                       │\nbidirectional relay ─▶ dispatch_data (pipeline) per chunk\n                       flow.bytes_up/down per chunk or datagram\n                       │\nconnection close ────▶ dispatch_flow_close (observational)\n                       log stats from Flow\n```\n\n链式代理使用同一条路径。区别是 `dispatch_connect` 返回到下一跳 SOCKS5 server 的 `Connection`，而不是直连 TCP 连接。\n\n## 请求生命周期\n\n每个请求经过三个阶段：\n\n| 阶段 | 入口 | 核心动作 | 输出 |\n|------|------|----------|------|\n| 握手 | SOCKS5 客户端 | 解析方法选择 + 认证 + 请求，创建 Flow | 含 src/dst/protocol 的 Flow |\n| 中继 | Flow + addon 决策 | 双向数据泵 + addon 管道，Flow 追踪字节 | 数据转发，字节计数 |\n| 拆解 | 连接关闭 | 记录统计，派发 `on_flow_close` | Addon 获得最终统计 |\n\nTCP 和 UDP 共享 hook 生命周期。TCP 使用配对的 `_copy()` 协程。UDP 使用共享 socket 和路由表。\n\n### TCP 中继数据流\n\n```text\nClient          Server                                  Target\n──────          ──────                                  ──────\n\n  │               │                                       │\n  │── handshake ─▶│                                       │\n  │               │  parse + auth + Flow                  │\n  │               │                                       │\n  │               │  dispatch_connect(flow)               │\n  │               │  ├─ no addon ──▶ direct               │\n  │               │  └─ ChainRouter ──▶ next hop          │\n  │               │                                       │\n  │── data ──────▶│── _copy(client→target)───────────────▶│\n  │               │  dispatch_data(up, data, flow)        │\n  │               │  flow.bytes_up += len(data)           │\n  │               │                                       │\n  │◀─ response ───│◀── _copy(target→client)               │\n  │               │  dispatch_data(down, data)            │\n  │               │  flow.bytes_down += len(data)         │\n  │               │                                       │\n  │── close ─────▶│── dispatch_flow_close(flow)──────────▶│\n  │               │  log: ↑1.2KB ↓45.6KB                  │\n  │               │                                       │\n```\n\n### UDP Relay 架构\n\n一些 SOCKS5 实现为每个客户端源端口创建独立的出向 UDP socket。长时间运行时容易积累 socket。\n\n本实现使用一个出向 socket 和双向路由表：\n\n```text\nOutbound:\nClient datagram ──▶ shared_socket.sendto(payload, target)\n                      route_map[(\"93.184.216.34\", 443)] = (\"10.0.0.1\", 54321)\n                      flow.bytes_up += len(payload)\n\nInbound:\nshared_socket.recvfrom() ──▶ lookup route_map ──▶ sendto(client, response)\n                               flow.bytes_down += len(response)\n```\n\n路由通过 TTL 过期淘汰。\n\n## UDP-over-TCP 链式\n\nUDP 链式代理不在代理节点之间使用 UDP。节点间传输走 TCP。\n\n入口节点把 UDP datagram 封装为 TCP frame。出口节点拆封后发出 UDP。\n\n```text\nRequest:\nClient UDP ──▶ UdpOverTcpEntry ──▶ middle nodes ──▶ Exit server ──▶ raw UDP ──▶ Target\n                encapsulate          (TCP bytes)      decapsulate\n                UDP → TCP                             TCP → UDP\n\nResponse:\nTarget ──▶ raw UDP ──▶ Exit server ──▶ middle nodes ──▶ UdpOverTcpEntry ──▶ Client UDP\n                         encapsulate       (TCP bytes)      decapsulate\n                         UDP → TCP                         TCP → UDP\n```\n\n性质：\n\n- 中间节点只转发 TCP CONNECT 流量。\n- `on_data` 在 TCP 和 UDP-over-TCP 场景下都只看到 TCP bytes。\n- UDP 语义只存在于 client-entry 和 exit-target 两段。\n- 不需要逐跳维护 UDP ASSOCIATE 状态。\n\n## Flow Context\n\n`Flow` 是贯穿 hooks 的每连接上下文。\n\n```python\n@dataclass\nclass Flow:\n    id: int               # 全局递增 ID\n    src: Address          # 客户端地址\n    dst: Address          # 目标地址\n    protocol: Literal[\"tcp\", \"udp\"]\n    started_at: float     # time.monotonic()\n    bytes_up: int = 0     # 客户端→目标（TCP: post-addon; UDP: 原始载荷）\n    bytes_down: int = 0   # 目标→客户端\n```\n\n没有 `Flow` 时，data hook 没有连接身份。字节计数也容易在 relay 和 addon 中重复。\n\n有了 `Flow`：\n\n- 字节只在 relay 中计数一次。\n- hook 在连接生命周期内收到同一个对象。\n- `on_flow_close` 收到最终计数。\n\n生命周期：\n\n```text\non_connect / on_udp_associate(flow)     → addon registers connection, gets identity\non_data(direction, data, flow)          → addon knows whose data, can read live stats\n  └─ relay updates flow.bytes_* directly\non_flow_close(flow)                     → addon gets final snapshot, can log/aggregate\n```\n\n## IPv6 双栈\n\n服务端用一个 `AF_INET6` socket（`IPV6_V6ONLY=0`）监听 `::`，同时处理 IPv4 和 IPv6。\n\n客户端连接使用 Happy Eyeballs 风格 fallback。解析 IPv6 和 IPv4 候选，启动一个候选，然后每 250ms 启动后续候选。快速失败不会终止后续候选。\n\nUDP relay 在路由表中归一化 IPv4-mapped IPv6 地址（`::ffff:x.x.x.x`）。\n\n## Async Hooks\n\n数据路径使用 `StreamReader` 和 `StreamWriter`。它本来就是 async：`await reader.read()` -> 处理 -> `await writer.drain()`。\n\nAsync hooks 允许：\n\n- `ChainRouter.on_connect` 直接 `await client.connect()`。\n- `on_auth` 使用 async I/O。\n- relay 路径不需要 sync-to-async 桥接。\n\n额外一次 `await` 相比网络 I/O 不构成主要成本。\n\n## 设计决策\n\n| 决策 | 选择 | 理由 |\n|------|------|------|\n| SOCKS 版本 | SOCKS5 | 覆盖 CONNECT 和 UDP ASSOCIATE |\n| 运行时依赖 | 零 | 仅标准库 |\n| Addon 模型 | 类式 + async | 一个类实现多个 hook，状态管理自然；async 匹配数据路径 |\n| 配置方式 | Python 脚本 | Addon 是普通 Python 对象 |\n| 热加载 | 内核不支持 | 需要时使用外部 watcher |\n| 资源限制 | 内核不处理 | 使用系统级限制 |\n\n## 专题文档\n\n| 文档 | 内容 |\n|------|------|\n| [`addon-model.md`](addon-model.md) | Hook API、执行模型、内置 addon、链式代理 |\n| [`public-api.zh-CN.md`](public-api.zh-CN.md) | 1.x 兼容面、包根导出、hook 契约 |\n"
  },
  {
    "path": "docs/public-api.md",
    "content": "# Public API\n\n[README](../README.md) · [Architecture](architecture.md) · [Addon recipes](addon-recipes.md) · [Addon model](addon-model.md) · [简体中文](public-api.zh-CN.md)\n\nThis document defines the asyncio-socks-server 1.x compatibility surface.\nStable imports live at the package root. Submodules remain importable.\n\n## Compatibility Policy\n\nThe package root is stable:\n\n```python\nfrom asyncio_socks_server import Server, Addon, Address, connect\n```\n\nWithin the 1.x series:\n\n- Root exports keep their names and broad behavior.\n- Addon hook signatures remain compatible.\n- `Flow` byte counters and address fields keep their meaning.\n- CLI flags remain backward-compatible.\n\nModules under `asyncio_socks_server.core`,\n`asyncio_socks_server.server`, `asyncio_socks_server.client`, and\n`asyncio_socks_server.addons` are importable. Root exports are the compatibility contract.\n\n## Root Exports\n\n| Name | Category | Purpose |\n|------|----------|---------|\n| `Server` | Server | SOCKS5 server entry point |\n| `connect` | Client | Open a TCP connection through a SOCKS5 proxy |\n| `Addon` | Addon base | Base class for optional async hooks |\n| `ChainRouter` | Addon | Route TCP CONNECT through a downstream SOCKS5 proxy |\n| `UdpOverTcpEntry` | Addon | Tunnel UDP ASSOCIATE traffic through a TCP exit service |\n| `UdpOverTcpExitServer` | Server | Exit service for UDP-over-TCP chaining |\n| `FlowAudit` | Addon | In-memory closed-flow usage audit collector |\n| `FlowStats` | Addon | In-memory flow statistics collector |\n| `StatsAPI` | Addon | Opt-in HTTP API backed by FlowStats |\n| `StatsServer` | Addon | Backward-compatible name for StatsAPI |\n| `TrafficCounter` | Addon | Aggregate closed-flow byte counters |\n| `FileAuth` | Addon | Username/password auth from JSON |\n| `IPFilter` | Addon | Source IP allow/block rules |\n| `Logger` | Addon | Connection and data logging |\n| `Address` | Type | Host/port pair |\n| `Flow` | Type | Per-connection context and byte counters |\n| `Direction` | Type | Data direction enum |\n| `Connection` | Type | Reader/writer pair returned by connection hooks |\n| `UdpRelayBase` | Type | Base interface for custom UDP relay addons |\n\n## Server Contract\n\n```python\nserver = Server(\n    host=\"::\",\n    port=1080,\n    addons=[],\n    auth=None,\n    log_level=\"INFO\",\n    shutdown_timeout=30.0,\n)\nserver.run()\n```\n\n`Server.run()` owns the event loop and installs SIGINT/SIGTERM handlers. Internal coroutines are not part of the stable public API.\n\nShutdown stops accepting new clients, waits for active client tasks, then calls\naddon `on_stop`. If `shutdown_timeout` is `None`, shutdown waits indefinitely\nfor active clients. Otherwise unfinished tasks are cancelled after the timeout.\n\n## Addon Contract\n\nAll addon hooks are optional async methods. The hook models are:\n\n| Model | Hooks | Return contract |\n|-------|-------|-----------------|\n| Competitive | `on_auth`, `on_connect`, `on_udp_associate` | `None` abstains; non-`None` wins |\n| Pipeline | `on_data` | `bytes` continues; `None` drops the chunk |\n| Observational | `on_start`, `on_stop`, `on_flow_close`, `on_error` | Return value ignored |\n\nExceptions in competitive hooks reject the current SOCKS operation. Exceptions in `on_flow_close` and `on_error` are suppressed.\n\n## Flow Semantics\n\n`Flow` is shared across hooks for one TCP CONNECT or UDP ASSOCIATE lifecycle.\n\n```python\nFlow(\n    id=1,\n    src=Address(\"127.0.0.1\", 54321),\n    dst=Address(\"example.com\", 443),\n    protocol=\"tcp\",\n    started_at=...,\n    bytes_up=0,\n    bytes_down=0,\n)\n```\n\nByte counters are maintained by the relay path, not by addons:\n\n- `bytes_up`: client to target, after TCP data pipeline processing\n- `bytes_down`: target to client\n- UDP counters count SOCKS5 UDP payload bytes, not UDP header bytes\n\nAddons should treat `Flow` as readable context. Mutating byte counters or\naddresses is unsupported.\n\n## Stats API\n\n`FlowStats` is the stats infrastructure. It has no network side effects and\nexposes plain Python methods:\n\n| Method | Meaning |\n|--------|---------|\n| `snapshot()` | Aggregate counters and active flow snapshots |\n| `flows()` | Active flows and recent closed flow snapshots |\n| `active_flows()` | Active flow snapshots |\n| `recent_closed_flows()` | Retained closed flow snapshots |\n| `errors()` | Error counters observed through `on_error` |\n\nUse `FlowStats` to build an application-specific HTTP API, metrics exporter, or\nlogging pipeline. Put it early in the addon list so it can observe flow starts\nbefore another competitive addon wins.\n\n`FlowAudit` is the usage audit infrastructure. It has no network side effects\nand aggregates closed-flow usage by source host and target host:\n\n| Method | Meaning |\n|--------|---------|\n| `snapshot()` | Kafra-like audit summary with period, records, totals, devices, traffic, and recent records |\n| `reset()` | Clear the in-memory audit window |\n\nThe audit window is in-memory and process-local. Use a custom addon or sink if\nyou need durable long-term storage.\n\n`StatsAPI` is the built-in opt-in HTTP presentation addon. It can either own its\nown `FlowStats` instance, or expose `FlowStats` and `FlowAudit` instances\nsupplied by the application:\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\n```\n\n| Endpoint | Meaning |\n|----------|---------|\n| `GET /health` | Liveness response |\n| `GET /stats` | `FlowStats.snapshot()` |\n| `GET /flows` | `FlowStats.flows()` |\n| `GET /errors` | `FlowStats.errors()` |\n| `GET /audit?top=25&device=` | `FlowAudit.snapshot()` |\n| `POST /audit/refresh?top=25&device=` | Current `FlowAudit.snapshot()` for Kafra-like refresh flows |\n\n`StatsServer` remains available as a backward-compatible name for `StatsAPI`.\n\n## CLI Contract\n\n```shell\nasyncio_socks_server --host :: --port 1080 --auth user:pass --log-level INFO\n```\n\nCLI mode starts a direct SOCKS5 server with optional single-user auth. Addons and advanced routing are configured from Python.\n"
  },
  {
    "path": "docs/public-api.zh-CN.md",
    "content": "# 公共 API\n\n[README](../README.zh-CN.md) · [架构](architecture.zh-CN.md) · [Addon recipes](addon-recipes.zh-CN.md) · [Addon 模型](addon-model.zh-CN.md) · [English](public-api.md)\n\n本文定义 asyncio-socks-server 1.x 的兼容性边界。稳定导入面是包根。子模块仍可导入。\n\n## 兼容性策略\n\n包根稳定：\n\n```python\nfrom asyncio_socks_server import Server, Addon, Address, connect\n```\n\n在 1.x 系列内：\n\n- 包根导出的名称和主要行为保持兼容。\n- Addon hook 签名保持兼容。\n- `Flow` 的字节计数和地址字段语义保持稳定。\n- CLI 参数保持向后兼容。\n\n`asyncio_socks_server.core`、`asyncio_socks_server.server`、\n`asyncio_socks_server.client`、`asyncio_socks_server.addons` 下的模块可以导入。兼容性契约以包根导出为准。\n\n## 包根导出\n\n| 名称 | 类别 | 用途 |\n|------|------|------|\n| `Server` | 服务端 | SOCKS5 server 入口 |\n| `connect` | 客户端 | 通过 SOCKS5 proxy 打开 TCP 连接 |\n| `Addon` | Addon 基类 | 可选 async hooks 的基类 |\n| `ChainRouter` | Addon | 将 TCP CONNECT 路由到下游 SOCKS5 proxy |\n| `UdpOverTcpEntry` | Addon | 将 UDP ASSOCIATE 流量封装到 TCP exit service |\n| `UdpOverTcpExitServer` | 服务端 | UDP-over-TCP 链式代理的出口服务 |\n| `FlowAudit` | Addon | 内存中的已关闭 flow 用量审计 collector |\n| `FlowStats` | Addon | 内存 flow 统计 collector |\n| `StatsAPI` | Addon | 基于 FlowStats 的显式 opt-in HTTP API |\n| `StatsServer` | Addon | `StatsAPI` 的向后兼容名称 |\n| `TrafficCounter` | Addon | 聚合已关闭 flow 的字节计数 |\n| `FileAuth` | Addon | 从 JSON 文件读取用户名/密码 |\n| `IPFilter` | Addon | 源 IP allow/block 规则 |\n| `Logger` | Addon | 连接和数据日志 |\n| `Address` | 类型 | host/port 二元组 |\n| `Flow` | 类型 | 每连接上下文和字节计数 |\n| `Direction` | 类型 | 数据方向枚举 |\n| `Connection` | 类型 | connection hook 返回的 reader/writer |\n| `UdpRelayBase` | 类型 | 自定义 UDP relay addon 的基础接口 |\n\n## Server 契约\n\n```python\nserver = Server(\n    host=\"::\",\n    port=1080,\n    addons=[],\n    auth=None,\n    log_level=\"INFO\",\n    shutdown_timeout=30.0,\n)\nserver.run()\n```\n\n`Server.run()` 接管 event loop，并安装 SIGINT/SIGTERM handler。内部 coroutine 不属于稳定公共 API。\n\nShutdown 会先停止接收新客户端，等待活跃 client task，再调用 addon\n`on_stop`。如果 `shutdown_timeout` 为 `None`，会无限等待活跃客户端；否则超时后\n取消未完成 task。\n\n## Addon 契约\n\n所有 addon hook 都是可选 async 方法。Hook 模型如下：\n\n| 模型 | Hooks | 返回值契约 |\n|------|-------|------------|\n| 竞争型 | `on_auth`, `on_connect`, `on_udp_associate` | `None` 表示弃权；非 `None` 获胜 |\n| 管道型 | `on_data` | `bytes` 继续；`None` 丢弃当前 chunk |\n| 观察型 | `on_start`, `on_stop`, `on_flow_close`, `on_error` | 返回值忽略 |\n\n竞争型 hook 抛异常会拒绝当前 SOCKS 操作。`on_flow_close` 和 `on_error` 中的异常会被抑制。\n\n## Flow 语义\n\n`Flow` 在一次 TCP CONNECT 或 UDP ASSOCIATE 生命周期内被所有 hook 共享。\n\n```python\nFlow(\n    id=1,\n    src=Address(\"127.0.0.1\", 54321),\n    dst=Address(\"example.com\", 443),\n    protocol=\"tcp\",\n    started_at=...,\n    bytes_up=0,\n    bytes_down=0,\n)\n```\n\n字节计数由 relay 路径维护，而不是由 addon 维护：\n\n- `bytes_up`：client 到 target，TCP 场景下为经过 data pipeline 后的字节\n- `bytes_down`：target 到 client\n- UDP 计数统计 SOCKS5 UDP payload，不包含 UDP header\n\nAddon 应把 `Flow` 视为可读上下文。修改字节计数或地址字段不受支持。\n\n## Stats API\n\n`FlowStats` 是统计基础设施。它没有网络副作用，只暴露 Python 方法：\n\n| 方法 | 含义 |\n|------|------|\n| `snapshot()` | 聚合计数和活跃 flow 快照 |\n| `flows()` | 活跃 flow 和最近关闭 flow 快照 |\n| `active_flows()` | 活跃 flow 快照 |\n| `recent_closed_flows()` | 保留的关闭 flow 快照 |\n| `errors()` | 通过 `on_error` 观察到的错误计数 |\n\n用 `FlowStats` 自行搭建应用需要的 HTTP API、metrics exporter 或日志管道。\n建议把它放在 addon 列表靠前位置，这样它能在其他竞争型 addon 获胜前观察 flow start。\n\n`FlowAudit` 是用量审计基础设施。它没有网络副作用，按 source host 和\ntarget host 聚合已关闭 flow 的用量：\n\n| 方法 | 含义 |\n|------|------|\n| `snapshot()` | 类似 Kafra audit 的摘要，包含 period、records、total、devices、traffic 和 recent records |\n| `reset()` | 清空当前内存审计窗口 |\n\n审计窗口是内存级、进程级的。如果需要长期留存，应使用自定义 addon 或 sink\n做持久化。\n\n`StatsAPI` 是内置的显式 opt-in HTTP 展示 addon。它可以自己托管\n`FlowStats`，也可以暴露应用传入的 `FlowStats` 和 `FlowAudit`：\n\n```python\nfrom asyncio_socks_server import FlowAudit, FlowStats, Server, StatsAPI\n\naudit = FlowAudit()\nstats = FlowStats()\nserver = Server(\n    addons=[\n        audit,\n        stats,\n        StatsAPI(stats=stats, audit=audit, host=\"127.0.0.1\", port=9900),\n    ],\n)\n```\n\n| Endpoint | 含义 |\n|----------|------|\n| `GET /health` | 存活检查 |\n| `GET /stats` | `FlowStats.snapshot()` |\n| `GET /flows` | `FlowStats.flows()` |\n| `GET /errors` | `FlowStats.errors()` |\n| `GET /audit?top=25&device=` | `FlowAudit.snapshot()` |\n| `POST /audit/refresh?top=25&device=` | 返回当前 `FlowAudit.snapshot()`，用于类似 Kafra 的刷新流程 |\n\n`StatsServer` 作为 `StatsAPI` 的向后兼容名称保留。\n\n## CLI 契约\n\n```shell\nasyncio_socks_server --host :: --port 1080 --auth user:pass --log-level INFO\n```\n\nCLI 模式启动一个直连 SOCKS5 server，可选单用户认证。Addon 和高级路由通过 Python 配置。\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"asyncio-socks-server\"\nversion = \"1.3.1\"\ndescription = \"A SOCKS5 toolchain/framework with programmable addons\"\nreadme = \"README.md\"\nrequires-python = \">=3.12\"\nlicense = \"MIT\"\nauthors = [\n    { name = \"Amaindex\", email = \"amaindex@outlook.com\" },\n]\nkeywords = [\"asyncio\", \"socks5\", \"proxy\", \"addon\"]\nclassifiers = [\n    \"Development Status :: 5 - Production/Stable\",\n    \"Framework :: AsyncIO\",\n    \"Intended Audience :: System Administrators\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Topic :: Internet :: Proxy Servers\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/Amaindex/asyncio-socks-server\"\nRepository = \"https://github.com/Amaindex/asyncio-socks-server\"\nIssues = \"https://github.com/Amaindex/asyncio-socks-server/issues\"\n\n[project.scripts]\nasyncio_socks_server = \"asyncio_socks_server.cli:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/asyncio_socks_server\"]\n\n[tool.ruff]\ntarget-version = \"py312\"\nline-length = 88\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\", \"W\"]\n\n[tool.pyright]\npythonVersion = \"3.12\"\ntypeCheckingMode = \"basic\"\ninclude = [\"src\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\ntestpaths = [\"tests\"]\n\n[dependency-groups]\ndev = [\"ruff>=0.11\", \"pytest>=8\", \"pytest-asyncio>=0.26\", \"pyright>=1.1\"]\n"
  },
  {
    "path": "src/asyncio_socks_server/__init__.py",
    "content": "\"\"\"asyncio-socks-server: A SOCKS5 toolchain/framework with programmable addons.\"\"\"\n\nfrom asyncio_socks_server.addons import (\n    Addon,\n    ChainRouter,\n    FileAuth,\n    FlowAudit,\n    FlowStats,\n    IPFilter,\n    Logger,\n    StatsAPI,\n    StatsServer,\n    TrafficCounter,\n    UdpOverTcpEntry,\n)\nfrom asyncio_socks_server.client.client import connect\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\nfrom asyncio_socks_server.server.connection import Connection\nfrom asyncio_socks_server.server.server import Server\nfrom asyncio_socks_server.server.udp_over_tcp_exit import UdpOverTcpExitServer\nfrom asyncio_socks_server.server.udp_relay import UdpRelayBase\n\n__all__ = [\n    \"Addon\",\n    \"Address\",\n    \"ChainRouter\",\n    \"Connection\",\n    \"Direction\",\n    \"FileAuth\",\n    \"Flow\",\n    \"FlowAudit\",\n    \"FlowStats\",\n    \"IPFilter\",\n    \"Logger\",\n    \"Server\",\n    \"StatsAPI\",\n    \"StatsServer\",\n    \"TrafficCounter\",\n    \"UdpOverTcpEntry\",\n    \"UdpOverTcpExitServer\",\n    \"UdpRelayBase\",\n    \"connect\",\n]\n"
  },
  {
    "path": "src/asyncio_socks_server/__main__.py",
    "content": "from asyncio_socks_server.cli import main\n\nmain()\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/__init__.py",
    "content": "from asyncio_socks_server.addons.auth import FileAuth\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.chain import ChainRouter\nfrom asyncio_socks_server.addons.ip_filter import IPFilter\nfrom asyncio_socks_server.addons.logger import Logger\nfrom asyncio_socks_server.addons.stats import (\n    FlowAudit,\n    FlowStats,\n    StatsAPI,\n    StatsServer,\n)\nfrom asyncio_socks_server.addons.traffic import TrafficCounter\nfrom asyncio_socks_server.addons.udp_over_tcp_entry import UdpOverTcpEntry\n\n__all__ = [\n    \"Addon\",\n    \"ChainRouter\",\n    \"FileAuth\",\n    \"FlowAudit\",\n    \"FlowStats\",\n    \"IPFilter\",\n    \"Logger\",\n    \"StatsAPI\",\n    \"StatsServer\",\n    \"TrafficCounter\",\n    \"UdpOverTcpEntry\",\n]\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/auth.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom pathlib import Path\n\nfrom asyncio_socks_server.addons.base import Addon\n\n\nclass FileAuth(Addon):\n    \"\"\"File-based username/password authentication.\n\n    Reads a JSON file mapping usernames to passwords:\n    {\"user1\": \"pass1\", \"user2\": \"pass2\"}\n    \"\"\"\n\n    def __init__(self, path: str | Path):\n        self._path = Path(path)\n        self._credentials: dict[str, str] = {}\n\n    def _load(self) -> dict[str, str]:\n        try:\n            text = self._path.read_text(encoding=\"utf-8\")\n            return json.loads(text)\n        except (OSError, json.JSONDecodeError):\n            return {}\n\n    async def on_auth(self, username: str, password: str) -> bool | None:\n        if not self._credentials:\n            self._credentials = self._load()\n        if username in self._credentials:\n            return self._credentials[username] == password\n        return None\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/base.py",
    "content": "from __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from asyncio_socks_server.core.types import Direction, Flow\n    from asyncio_socks_server.server.connection import Connection\n    from asyncio_socks_server.server.udp_relay import UdpRelayBase\n\n\nclass Addon:\n    \"\"\"Base addon class. All hook methods are optional async methods.\n\n    Competitive hooks use None to abstain and non-None values to take over.\n    The on_data pipeline uses returned bytes as the outgoing payload and None\n    to drop the current chunk. Exceptions reject or abort the current operation.\n    \"\"\"\n\n    async def on_start(self) -> None:\n        \"\"\"Called when the server starts.\"\"\"\n\n    async def on_stop(self) -> None:\n        \"\"\"Called when the server stops.\"\"\"\n\n    async def on_auth(self, username: str, password: str) -> bool | None:\n        \"\"\"Competitive: True=allow, False=deny, None=don't interfere.\"\"\"\n\n    async def on_connect(self, flow: Flow) -> Connection | None:\n        \"\"\"Competitive: return Connection to intercept, None=don't interfere.\"\"\"\n\n    async def on_udp_associate(self, flow: Flow) -> UdpRelayBase | None:\n        \"\"\"Competitive: return UdpRelayBase to intercept, None=don't interfere.\"\"\"\n\n    async def on_data(\n        self, direction: Direction, data: bytes, flow: Flow\n    ) -> bytes | None:\n        \"\"\"Pipeline: return bytes to write, None=drop this chunk.\"\"\"\n\n    async def on_flow_close(self, flow: Flow) -> None:\n        \"\"\"Observational: called when a flow (TCP or UDP) closes.\"\"\"\n\n    async def on_error(self, error: Exception) -> None:\n        \"\"\"Observational: just notify, doesn't affect flow.\"\"\"\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/chain.py",
    "content": "from __future__ import annotations\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.client import client\nfrom asyncio_socks_server.core.types import Address, Flow\nfrom asyncio_socks_server.server.connection import Connection\n\n\nclass ChainRouter(Addon):\n    \"\"\"Route connections through a SOCKS5 proxy chain.\n\n    Each instance represents one hop. The addon connects to next_hop\n    via SOCKS5 and tunnels the connection through it.\n    \"\"\"\n\n    def __init__(\n        self,\n        next_hop: str,\n        username: str | None = None,\n        password: str | None = None,\n    ):\n        host, _, port_str = next_hop.rpartition(\":\")\n        self._proxy_addr = Address(host, int(port_str))\n        self._username = username\n        self._password = password\n\n    async def on_connect(self, flow: Flow) -> Connection | None:\n        conn = await client.connect(\n            proxy_addr=self._proxy_addr,\n            target_addr=flow.dst,\n            username=self._username,\n            password=self._password,\n        )\n        return conn\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/ip_filter.py",
    "content": "from __future__ import annotations\n\nimport ipaddress\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.types import Flow\n\n\nclass IPFilter(Addon):\n    \"\"\"Allow or deny connections based on source IP ranges.\n\n    Either `allowed` or `blocked` can be provided (not both).\n    If `allowed` is set, only listed IPs/ranges can connect.\n    If `blocked` is set, listed IPs/ranges are denied.\n    \"\"\"\n\n    def __init__(\n        self,\n        allowed: list[str] | None = None,\n        blocked: list[str] | None = None,\n    ):\n        self._allowed = [ipaddress.ip_network(n) for n in (allowed or [])]\n        self._blocked = [ipaddress.ip_network(n) for n in (blocked or [])]\n\n    def _is_allowed(self, host: str) -> bool:\n        try:\n            addr = ipaddress.ip_address(host)\n        except ValueError:\n            return False\n\n        if self._blocked:\n            return not any(addr in net for net in self._blocked)\n        if self._allowed:\n            return any(addr in net for net in self._allowed)\n        return True\n\n    async def on_connect(self, flow: Flow) -> None:\n        if not self._is_allowed(flow.src.host):\n            raise ConnectionRefusedError(f\"IP blocked: {flow.src.host}\")\n        return None\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/logger.py",
    "content": "from __future__ import annotations\n\nimport logging\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.logging import fmt_connection\nfrom asyncio_socks_server.core.types import Direction, Flow\n\n\nclass Logger(Addon):\n    \"\"\"Detailed connection logging addon.\"\"\"\n\n    def __init__(self):\n        self._logger = logging.getLogger(\"asyncio_socks_server.addon.logger\")\n\n    async def on_connect(self, flow: Flow) -> None:\n        self._logger.info(f\"{fmt_connection(flow.src, flow.dst)} | on_connect\")\n\n    async def on_data(\n        self, direction: Direction, data: bytes, flow: Flow\n    ) -> bytes | None:\n        self._logger.debug(f\"{direction} | {len(data)} bytes\")\n        return data\n\n    async def on_error(self, error: Exception) -> None:\n        self._logger.warning(f\"error: {error}\")\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/manager.py",
    "content": "from __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom .base import Addon\n\nif TYPE_CHECKING:\n    from asyncio_socks_server.core.types import Direction, Flow\n    from asyncio_socks_server.server.connection import Connection\n    from asyncio_socks_server.server.udp_relay import UdpRelayBase\n\n\ndef _is_overridden(addon: Addon, method_name: str) -> bool:\n    base_method = getattr(Addon, method_name, None)\n    return getattr(type(addon), method_name, None) is not base_method\n\n\nclass AddonManager:\n    def __init__(self, addons: list[Addon] | None = None):\n        self._addons: list[Addon] = addons or []\n\n    # lifecycle\n\n    async def dispatch_start(self) -> None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_start\"):\n                await addon.on_start()\n\n    async def dispatch_stop(self) -> None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_stop\"):\n                await addon.on_stop()\n\n    # competitive: first non-None wins\n\n    async def dispatch_auth(self, username: str, password: str) -> bool | None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_auth\"):\n                result = await addon.on_auth(username, password)\n                if result is not None:\n                    return result\n        return None\n\n    async def dispatch_connect(self, flow: Flow) -> Connection | None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_connect\"):\n                result = await addon.on_connect(flow)\n                if result is not None:\n                    return result\n        return None\n\n    async def dispatch_udp_associate(self, flow: Flow) -> UdpRelayBase | None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_udp_associate\"):\n                result = await addon.on_udp_associate(flow)\n                if result is not None:\n                    return result\n        return None\n\n    # pipeline: chain outputs\n\n    async def dispatch_data(\n        self, direction: Direction, data: bytes, flow: Flow\n    ) -> bytes | None:\n        current: bytes | None = data\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_data\"):\n                if current is None:\n                    break\n                current = await addon.on_data(direction, current, flow)\n        return current\n\n    # observational: call all\n\n    async def dispatch_flow_close(self, flow: Flow) -> None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_flow_close\"):\n                try:\n                    await addon.on_flow_close(flow)\n                except Exception:\n                    pass\n\n    async def dispatch_error(self, error: Exception) -> None:\n        for addon in self._addons:\n            if _is_overridden(addon, \"on_error\"):\n                try:\n                    await addon.on_error(error)\n                except Exception:\n                    pass  # observational hooks must not disrupt\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/stats.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport json\nimport time\nfrom collections import deque\nfrom dataclasses import asdict\nfrom datetime import UTC, datetime\nfrom typing import Any\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.types import Flow\n\n\nclass FlowStats(Addon):\n    \"\"\"Flow statistics collector with no network side effects.\n\n    FlowStats is the reusable stats infrastructure. It implements addon hooks,\n    keeps in-memory flow counters, and exposes plain Python snapshot methods.\n    Applications can attach their own HTTP API, metrics exporter, file writer,\n    or any other presentation layer around it.\n    \"\"\"\n\n    def __init__(\n        self,\n        max_closed_flows: int = 100,\n        max_recent_errors: int = 50,\n    ) -> None:\n        self.max_closed_flows = max_closed_flows\n        self.max_recent_errors = max_recent_errors\n        self._started_at = time.monotonic()\n        self._started_wall_at = time.time()\n        self._active: dict[int, Flow] = {}\n        self._seen_flow_ids: set[int] = set()\n        self._closed: deque[dict[str, Any]] = deque(maxlen=max_closed_flows)\n        self._recent_errors: deque[dict[str, Any]] = deque(maxlen=max_recent_errors)\n        self.total_flows = 0\n        self.total_tcp_flows = 0\n        self.total_udp_flows = 0\n        self.total_closed_flows = 0\n        self.closed_bytes_up = 0\n        self.closed_bytes_down = 0\n        self.total_errors = 0\n        self.errors_by_type: dict[str, int] = {}\n        self._last_total_sample_at = self._started_at\n        self._last_total_bytes_up = 0\n        self._last_total_bytes_down = 0\n        self._upload_rate = 0.0\n        self._download_rate = 0.0\n        self._flow_rates: dict[int, dict[str, float]] = {}\n\n    async def on_connect(self, flow: Flow) -> None:\n        self._track_flow(flow)\n\n    async def on_udp_associate(self, flow: Flow) -> None:\n        self._track_flow(flow)\n\n    async def on_flow_close(self, flow: Flow) -> None:\n        if flow.id not in self._seen_flow_ids:\n            self._track_flow(flow)\n        self._sample_flow_rate(flow)\n        self._active.pop(flow.id, None)\n        self._closed.append(self._flow_snapshot(flow, state=\"closed\"))\n        self.total_closed_flows += 1\n        self.closed_bytes_up += flow.bytes_up\n        self.closed_bytes_down += flow.bytes_down\n        self._flow_rates.pop(flow.id, None)\n\n    async def on_error(self, error: Exception) -> None:\n        name = type(error).__name__\n        self.total_errors += 1\n        self.errors_by_type[name] = self.errors_by_type.get(name, 0) + 1\n        self._recent_errors.append(\n            {\n                \"type\": name,\n                \"message\": str(error),\n                \"at\": self._format_wall_time(time.time()),\n            }\n        )\n\n    def snapshot(self) -> dict[str, Any]:\n        \"\"\"Return aggregate counters plus active flow snapshots.\"\"\"\n        self._sample_rates()\n        active_bytes_up = sum(flow.bytes_up for flow in self._active.values())\n        active_bytes_down = sum(flow.bytes_down for flow in self._active.values())\n        return {\n            \"started_at\": self._format_wall_time(self._started_wall_at),\n            \"uptime_seconds\": self._duration(self._started_at),\n            \"active_flows\": len(self._active),\n            \"closed_flows\": len(self._closed),\n            \"recent_closed_flows\": len(self._closed),\n            \"total_closed_flows\": self.total_closed_flows,\n            \"total_flows\": self.total_flows,\n            \"total_tcp_flows\": self.total_tcp_flows,\n            \"total_udp_flows\": self.total_udp_flows,\n            \"active_bytes_up\": active_bytes_up,\n            \"active_bytes_down\": active_bytes_down,\n            \"closed_bytes_up\": self.closed_bytes_up,\n            \"closed_bytes_down\": self.closed_bytes_down,\n            \"total_bytes_up\": self.closed_bytes_up + active_bytes_up,\n            \"total_bytes_down\": self.closed_bytes_down + active_bytes_down,\n            \"upload_rate\": self._upload_rate,\n            \"download_rate\": self._download_rate,\n            \"errors\": self.errors(),\n            \"active\": self._active_flow_snapshots(),\n        }\n\n    def active_flows(self) -> list[dict[str, Any]]:\n        \"\"\"Return active flow snapshots.\"\"\"\n        self._sample_rates()\n        return self._active_flow_snapshots()\n\n    def _active_flow_snapshots(self) -> list[dict[str, Any]]:\n        return [\n            self._flow_snapshot(flow, state=\"active\") for flow in self._active.values()\n        ]\n\n    def recent_closed_flows(self) -> list[dict[str, Any]]:\n        \"\"\"Return retained closed flow snapshots.\"\"\"\n        return list(self._closed)\n\n    def flows(self) -> dict[str, Any]:\n        \"\"\"Return active and retained closed flow snapshots.\"\"\"\n        return {\n            \"active\": self.active_flows(),\n            \"recent_closed\": self.recent_closed_flows(),\n        }\n\n    def errors(self) -> dict[str, Any]:\n        \"\"\"Return error counters observed through on_error.\"\"\"\n        return {\n            \"total\": self.total_errors,\n            \"by_type\": dict(sorted(self.errors_by_type.items())),\n            \"recent\": list(self._recent_errors),\n        }\n\n    def _track_flow(self, flow: Flow) -> None:\n        self._active[flow.id] = flow\n        if flow.id in self._seen_flow_ids:\n            return\n        self._seen_flow_ids.add(flow.id)\n        self._flow_rates[flow.id] = {\n            \"sample_at\": time.monotonic(),\n            \"bytes_up\": float(flow.bytes_up),\n            \"bytes_down\": float(flow.bytes_down),\n            \"upload_rate\": 0.0,\n            \"download_rate\": 0.0,\n        }\n        self.total_flows += 1\n        if flow.protocol == \"tcp\":\n            self.total_tcp_flows += 1\n        else:\n            self.total_udp_flows += 1\n\n    def _flow_snapshot(self, flow: Flow, state: str) -> dict[str, Any]:\n        rates = self._flow_rates.get(flow.id, {})\n        return {\n            \"id\": flow.id,\n            \"state\": state,\n            \"src\": asdict(flow.src),\n            \"dst\": asdict(flow.dst),\n            \"protocol\": flow.protocol,\n            \"started_at\": self._format_wall_time(flow.started_wall_at),\n            \"age_seconds\": self._duration(flow.started_at),\n            \"bytes_up\": flow.bytes_up,\n            \"bytes_down\": flow.bytes_down,\n            \"upload_rate\": rates.get(\"upload_rate\", 0.0),\n            \"download_rate\": rates.get(\"download_rate\", 0.0),\n        }\n\n    def _sample_rates(self) -> None:\n        for flow in self._active.values():\n            self._sample_flow_rate(flow)\n\n        now = time.monotonic()\n        active_bytes_up = sum(flow.bytes_up for flow in self._active.values())\n        active_bytes_down = sum(flow.bytes_down for flow in self._active.values())\n        total_bytes_up = self.closed_bytes_up + active_bytes_up\n        total_bytes_down = self.closed_bytes_down + active_bytes_down\n        elapsed = now - self._last_total_sample_at\n        if elapsed > 0:\n            self._upload_rate = (total_bytes_up - self._last_total_bytes_up) / elapsed\n            self._download_rate = (\n                total_bytes_down - self._last_total_bytes_down\n            ) / elapsed\n        self._last_total_sample_at = now\n        self._last_total_bytes_up = total_bytes_up\n        self._last_total_bytes_down = total_bytes_down\n\n    def _sample_flow_rate(self, flow: Flow) -> None:\n        now = time.monotonic()\n        sample = self._flow_rates.setdefault(\n            flow.id,\n            {\n                \"sample_at\": now,\n                \"bytes_up\": float(flow.bytes_up),\n                \"bytes_down\": float(flow.bytes_down),\n                \"upload_rate\": 0.0,\n                \"download_rate\": 0.0,\n            },\n        )\n        elapsed = now - sample[\"sample_at\"]\n        if elapsed > 0:\n            sample[\"upload_rate\"] = (flow.bytes_up - sample[\"bytes_up\"]) / elapsed\n            sample[\"download_rate\"] = (flow.bytes_down - sample[\"bytes_down\"]) / elapsed\n        sample[\"sample_at\"] = now\n        sample[\"bytes_up\"] = float(flow.bytes_up)\n        sample[\"bytes_down\"] = float(flow.bytes_down)\n\n    @staticmethod\n    def _format_wall_time(timestamp: float) -> str:\n        return datetime.fromtimestamp(timestamp, UTC).isoformat().replace(\"+00:00\", \"Z\")\n\n    @staticmethod\n    def _duration(started_at: float) -> float:\n        return round(time.monotonic() - started_at, 6)\n\n\nclass FlowAudit(Addon):\n    \"\"\"Closed-flow traffic audit collector with no network side effects.\"\"\"\n\n    def __init__(self, max_recent_records: int = 100) -> None:\n        self.max_recent_records = max_recent_records\n        self._recent: deque[dict[str, Any]] = deque(maxlen=max_recent_records)\n        self._devices: dict[str, dict[str, Any]] = {}\n        self._traffic: dict[str, dict[str, Any]] = {}\n        self._period_start: float | None = None\n        self._period_end: float | None = None\n        self.records = 0\n        self.skipped = 0\n        self.total_upload = 0\n        self.total_download = 0\n\n    async def on_flow_close(self, flow: Flow) -> None:\n        self._record(flow)\n\n    def snapshot(\n        self,\n        top: int = 25,\n        device: str | None = None,\n    ) -> dict[str, Any]:\n        \"\"\"Return a Kafra-like traffic audit summary.\"\"\"\n        top = max(1, min(top, 100))\n        devices = self._sorted_totals(self._devices.values(), top, device)\n        traffic = self._sorted_totals(self._traffic.values(), top)\n        generated_at = self._format_wall_time(time.time())\n        return {\n            \"status\": \"ready\" if self.records else \"empty\",\n            \"generated_at\": generated_at,\n            \"period_start\": self._format_optional_time(self._period_start),\n            \"period_end\": self._format_optional_time(self._period_end),\n            \"duration_ms\": 0,\n            \"records\": self.records,\n            \"skipped\": self.skipped,\n            \"total\": {\n                \"upload\": self.total_upload,\n                \"download\": self.total_download,\n                \"total\": self.total_upload + self.total_download,\n            },\n            \"devices\": devices,\n            \"traffic\": traffic,\n            \"recent\": list(self._recent),\n        }\n\n    def reset(self) -> None:\n        \"\"\"Clear in-memory audit state.\"\"\"\n        self._recent.clear()\n        self._devices.clear()\n        self._traffic.clear()\n        self._period_start = None\n        self._period_end = None\n        self.records = 0\n        self.skipped = 0\n        self.total_upload = 0\n        self.total_download = 0\n\n    def _record(self, flow: Flow) -> None:\n        upload = flow.bytes_up\n        download = flow.bytes_down\n        total = upload + download\n        started_at = flow.started_wall_at\n        ended_at = time.time()\n        self._period_start = (\n            started_at\n            if self._period_start is None\n            else min(self._period_start, started_at)\n        )\n        self._period_end = ended_at\n        self.records += 1\n        self.total_upload += upload\n        self.total_download += download\n\n        device = flow.src.host\n        destination = flow.dst.host\n        self._add_total(self._devices, device, \"device\", upload, download)\n        self._add_total(self._traffic, destination, \"domain\", upload, download)\n        self._recent.append(\n            {\n                \"id\": flow.id,\n                \"src\": asdict(flow.src),\n                \"dst\": asdict(flow.dst),\n                \"protocol\": flow.protocol,\n                \"started_at\": self._format_wall_time(started_at),\n                \"ended_at\": self._format_wall_time(ended_at),\n                \"upload\": upload,\n                \"download\": download,\n                \"total\": total,\n            }\n        )\n\n    @staticmethod\n    def _add_total(\n        totals: dict[str, dict[str, Any]],\n        key: str,\n        label: str,\n        upload: int,\n        download: int,\n    ) -> None:\n        item = totals.setdefault(\n            key,\n            {\n                label: key,\n                \"upload\": 0,\n                \"download\": 0,\n                \"total\": 0,\n            },\n        )\n        item[\"upload\"] += upload\n        item[\"download\"] += download\n        item[\"total\"] += upload + download\n\n    @staticmethod\n    def _sorted_totals(\n        items: Any,\n        top: int,\n        device: str | None = None,\n    ) -> list[dict[str, Any]]:\n        out = [dict(item) for item in items]\n        if device:\n            out = [item for item in out if item.get(\"device\") == device]\n        return sorted(out, key=lambda item: item[\"total\"], reverse=True)[:top]\n\n    @classmethod\n    def _format_optional_time(cls, timestamp: float | None) -> str:\n        if timestamp is None:\n            return \"\"\n        return cls._format_wall_time(timestamp)\n\n    @staticmethod\n    def _format_wall_time(timestamp: float) -> str:\n        return datetime.fromtimestamp(timestamp, UTC).isoformat().replace(\"+00:00\", \"Z\")\n\n\nclass StatsAPI(Addon):\n    \"\"\"Opt-in HTTP API backed by FlowStats.\n\n    StatsAPI starts an HTTP listener only when explicitly added to a Server.\n    When constructed without a FlowStats instance, it owns one and forwards flow\n    hooks into it. When constructed with an existing FlowStats instance, it acts\n    only as a presentation layer so applications can compose both addons without\n    double-counting flows.\n    \"\"\"\n\n    def __init__(\n        self,\n        host: str = \"127.0.0.1\",\n        port: int = 0,\n        max_closed_flows: int = 100,\n        stats: FlowStats | None = None,\n        audit: FlowAudit | None = None,\n    ) -> None:\n        self.host = host\n        self.port = port\n        self.max_closed_flows = max_closed_flows\n        self.stats = stats or FlowStats(max_closed_flows=max_closed_flows)\n        self._owns_stats = stats is None\n        self.audit = audit\n        self._server: asyncio.AbstractServer | None = None\n\n    async def on_start(self) -> None:\n        self._server = await asyncio.start_server(\n            self._handle_http,\n            self.host,\n            self.port,\n        )\n        sock = self._server.sockets[0] if self._server.sockets else None\n        if sock is not None:\n            self.port = sock.getsockname()[1]\n\n    async def on_stop(self) -> None:\n        if self._server is None:\n            return\n        self._server.close()\n        await self._server.wait_closed()\n        self._server = None\n\n    async def on_connect(self, flow: Flow) -> None:\n        if self._owns_stats:\n            await self.stats.on_connect(flow)\n\n    async def on_udp_associate(self, flow: Flow) -> None:\n        if self._owns_stats:\n            await self.stats.on_udp_associate(flow)\n\n    async def on_flow_close(self, flow: Flow) -> None:\n        if self._owns_stats:\n            await self.stats.on_flow_close(flow)\n\n    async def on_error(self, error: Exception) -> None:\n        if self._owns_stats:\n            await self.stats.on_error(error)\n\n    def snapshot(self) -> dict[str, Any]:\n        return self.stats.snapshot()\n\n    async def _handle_http(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n    ) -> None:\n        try:\n            line = await reader.readline()\n            method, target, _ = line.decode(\"ascii\", errors=\"replace\").split(\" \", 2)\n            parsed = urlsplit(target)\n            path = parsed.path\n            query = parse_qs(parsed.query)\n            while True:\n                header = await reader.readline()\n                if header in (b\"\\r\\n\", b\"\\n\", b\"\"):\n                    break\n\n            if method == \"POST\" and path == \"/audit/refresh\":\n                await self._write_audit(writer, query)\n            elif method != \"GET\":\n                await self._write_json(writer, 405, {\"error\": \"method not allowed\"})\n            elif path == \"/health\":\n                await self._write_json(writer, 200, {\"ok\": True})\n            elif path == \"/stats\":\n                await self._write_json(writer, 200, self.stats.snapshot())\n            elif path == \"/flows\":\n                await self._write_json(writer, 200, self.stats.flows())\n            elif path == \"/errors\":\n                await self._write_json(writer, 200, self.stats.errors())\n            elif path == \"/audit\":\n                await self._write_audit(writer, query)\n            else:\n                await self._write_json(writer, 404, {\"error\": \"not found\"})\n        except (ConnectionError, OSError, ValueError):\n            pass\n        finally:\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except (ConnectionError, OSError):\n                pass\n\n    async def _write_json(\n        self,\n        writer: asyncio.StreamWriter,\n        status: int,\n        payload: dict[str, Any],\n    ) -> None:\n        reason = {\n            200: \"OK\",\n            404: \"Not Found\",\n            405: \"Method Not Allowed\",\n        }.get(status, \"Error\")\n        body = json.dumps(payload, separators=(\",\", \":\")).encode(\"utf-8\")\n        writer.write(\n            f\"HTTP/1.1 {status} {reason}\\r\\n\"\n            \"Content-Type: application/json\\r\\n\"\n            f\"Content-Length: {len(body)}\\r\\n\"\n            \"Connection: close\\r\\n\"\n            \"\\r\\n\".encode(\"ascii\")\n            + body\n        )\n        await writer.drain()\n\n    async def _write_audit(\n        self,\n        writer: asyncio.StreamWriter,\n        query: dict[str, list[str]],\n    ) -> None:\n        if self.audit is None:\n            await self._write_json(writer, 404, {\"error\": \"audit disabled\"})\n            return\n        await self._write_json(\n            writer,\n            200,\n            self.audit.snapshot(\n                top=self._int_query(query, \"top\", 25),\n                device=self._str_query(query, \"device\"),\n            ),\n        )\n\n    @staticmethod\n    def _int_query(query: dict[str, list[str]], name: str, default: int) -> int:\n        values = query.get(name)\n        if not values:\n            return default\n        try:\n            return int(values[0])\n        except ValueError:\n            return default\n\n    @staticmethod\n    def _str_query(query: dict[str, list[str]], name: str) -> str | None:\n        values = query.get(name)\n        if not values or not values[0]:\n            return None\n        return values[0]\n\n\nclass StatsServer(StatsAPI):\n    \"\"\"Backward-compatible name for StatsAPI.\"\"\"\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/traffic.py",
    "content": "from __future__ import annotations\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.types import Flow\n\n\nclass TrafficCounter(Addon):\n    \"\"\"Count bytes flowing through the proxy (TCP and UDP).\"\"\"\n\n    def __init__(self):\n        self.bytes_up: int = 0\n        self.bytes_down: int = 0\n        self.connections: int = 0\n\n    async def on_connect(self, flow: Flow) -> None:\n        self.connections += 1\n\n    async def on_flow_close(self, flow: Flow) -> None:\n        self.bytes_up += flow.bytes_up\n        self.bytes_down += flow.bytes_down\n"
  },
  {
    "path": "src/asyncio_socks_server/addons/udp_over_tcp_entry.py",
    "content": "from __future__ import annotations\n\nimport asyncio\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.protocol import build_udp_header, parse_udp_header\nfrom asyncio_socks_server.core.types import Address, Flow\nfrom asyncio_socks_server.server.udp_over_tcp import encode_udp_frame, read_udp_frame\nfrom asyncio_socks_server.server.udp_relay import UdpRelayBase\n\n\nclass UdpOverTcpEntry(Addon):\n    \"\"\"Route UDP ASSOCIATE through a downstream SOCKS5 proxy via UDP-over-TCP.\n\n    The bridge connects to next_hop via SOCKS5 TCP CONNECT, then tunnels\n    SOCKS5 UDP datagrams as length-prefixed TCP frames.\n    \"\"\"\n\n    def __init__(\n        self,\n        next_hop: str,\n        username: str | None = None,\n        password: str | None = None,\n    ):\n        host, _, port_str = next_hop.rpartition(\":\")\n        self._proxy_addr = Address(host, int(port_str))\n        self._username = username\n        self._password = password\n\n    async def on_udp_associate(self, flow: Flow) -> UdpRelayBase | None:\n        return _Bridge(self._proxy_addr, self._username, self._password, flow)\n\n\nclass _Bridge(UdpRelayBase):\n    \"\"\"UDP-over-TCP bridge: client-side UDP ↔ TCP frames ↔ downstream proxy.\"\"\"\n\n    def __init__(\n        self,\n        proxy_addr,\n        username: str | None,\n        password: str | None,\n        flow: Flow,\n    ):\n        self._proxy_addr = proxy_addr\n        self._username = username\n        self._password = password\n        self._tcp_reader: asyncio.StreamReader | None = None\n        self._tcp_writer: asyncio.StreamWriter | None = None\n        self._client_transport: asyncio.DatagramTransport | None = None\n        self._pump_task: asyncio.Task | None = None\n        self._route_map: dict[tuple[str, int], tuple[str, int]] = {}\n        self._flow = flow\n\n    async def start(self) -> Address:\n        # Open a plain TCP connection to the downstream proxy.\n        # We don't use SOCKS5 handshake here — this is just a raw TCP\n        # connection that the downstream UdpOverTcpExit server accepts.\n        self._tcp_reader, self._tcp_writer = await asyncio.open_connection(\n            self._proxy_addr.host, self._proxy_addr.port\n        )\n        sock = self._tcp_writer.get_extra_info(\"socket\")\n        sockname = sock.getsockname() if sock else (\"::\", 0)\n\n        # Start the TCP→client pump\n        self._pump_task = asyncio.create_task(self._tcp_to_client())\n\n        return Address(sockname[0], sockname[1])\n\n    def set_client_transport(self, transport: asyncio.DatagramTransport) -> None:\n        self._client_transport = transport\n\n    async def stop(self) -> None:\n        if self._pump_task:\n            self._pump_task.cancel()\n            try:\n                await self._pump_task\n            except asyncio.CancelledError:\n                pass\n        if self._tcp_writer:\n            try:\n                self._tcp_writer.close()\n                await self._tcp_writer.wait_closed()\n            except (ConnectionError, OSError):\n                pass\n\n    def handle_client_datagram(self, data: bytes, client_addr: tuple[str, int]) -> None:\n        if not self._tcp_writer:\n            return\n        try:\n            dst, _, payload = parse_udp_header(data)\n        except Exception:\n            return\n        if not payload:\n            return\n\n        # Record route: remote → client\n        remote_key = (dst.host, dst.port)\n        self._route_map[remote_key] = client_addr\n        self._flow.bytes_up += len(payload)\n\n        # Send as TCP frame (async but fire-and-forget via task)\n        async def _send():\n            try:\n                frame = await encode_udp_frame(dst, payload)\n                self._tcp_writer.write(frame)  # type: ignore[union-attr]\n                await self._tcp_writer.drain()  # type: ignore[union-attr]\n            except (ConnectionError, OSError):\n                pass\n\n        asyncio.create_task(_send())\n\n    async def _tcp_to_client(self) -> None:\n        try:\n            while True:\n                src_addr, payload = await read_udp_frame(self._tcp_reader)  # type: ignore[arg-type]\n                self._flow.bytes_down += len(payload)\n                # Find the client that sent to this remote\n                remote_key = (src_addr.host, src_addr.port)\n                client_addr = self._route_map.get(remote_key)\n                if client_addr is None:\n                    continue\n                header = build_udp_header(src_addr)\n                packet = header + payload\n                if self._client_transport:\n                    self._client_transport.sendto(packet, client_addr)\n        except (asyncio.IncompleteReadError, ConnectionError, OSError):\n            pass\n"
  },
  {
    "path": "src/asyncio_socks_server/cli.py",
    "content": "from __future__ import annotations\n\nimport argparse\n\nfrom asyncio_socks_server.server.server import Server\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(\n        prog=\"asyncio_socks_server\",\n        description=\"A SOCKS5 proxy server with programmable addons\",\n    )\n    parser.add_argument(\"--host\", default=\"::\", help=\"bind address\")\n    parser.add_argument(\"--port\", type=int, default=1080, help=\"bind port\")\n    parser.add_argument(\n        \"--auth\",\n        default=None,\n        help=\"username:password for authentication\",\n    )\n    parser.add_argument(\n        \"--log-level\",\n        default=\"INFO\",\n        choices=[\"DEBUG\", \"INFO\", \"WARNING\", \"ERROR\"],\n        help=\"logging level\",\n    )\n    args = parser.parse_args()\n\n    auth = None\n    if args.auth:\n        user, _, passwd = args.auth.partition(\":\")\n        auth = (user, passwd)\n\n    server = Server(\n        host=args.host,\n        port=args.port,\n        auth=auth,\n        log_level=args.log_level,\n    )\n    server.run()\n"
  },
  {
    "path": "src/asyncio_socks_server/client/__init__.py",
    "content": ""
  },
  {
    "path": "src/asyncio_socks_server/client/client.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport socket\nfrom itertools import zip_longest\n\nfrom asyncio_socks_server.core.address import decode_address, encode_address\nfrom asyncio_socks_server.core.protocol import ProtocolError\nfrom asyncio_socks_server.core.types import Address, AuthMethod, Rep\nfrom asyncio_socks_server.server.connection import Connection\n\nHAPPY_EYEBALLS_DELAY = 0.25\n\n\nasync def connect(\n    proxy_addr: Address,\n    target_addr: Address,\n    username: str | None = None,\n    password: str | None = None,\n) -> Connection:\n    \"\"\"Connect to target through a SOCKS5 proxy using Happy Eyeballs.\"\"\"\n    reader, writer = await _happy_eyeballs_connect(proxy_addr)\n\n    try:\n        await _negotiate(reader, writer, username, password)\n        await _request_connect(reader, writer, target_addr)\n\n        sock = writer.get_extra_info(\"socket\")\n        sockname = sock.getsockname() if sock else (\"0.0.0.0\", 0)\n        return Connection(\n            reader=reader,\n            writer=writer,\n            address=Address(sockname[0], sockname[1]),\n        )\n    except Exception:\n        writer.close()\n        raise\n\n\nasync def _happy_eyeballs_connect(\n    addr: Address,\n) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:\n    \"\"\"Happy Eyeballs-style fallback with staggered IPv6/IPv4 candidates.\"\"\"\n    loop = asyncio.get_running_loop()\n    ipv4_hosts: list[str] = []\n    ipv6_hosts: list[str] = []\n\n    try:\n        results = await loop.getaddrinfo(addr.host, addr.port, type=socket.SOCK_STREAM)\n        for family, _, _, _, sockaddr in results:\n            if family == socket.AF_INET6:\n                ipv6_hosts.append(sockaddr[0])\n            elif family == socket.AF_INET:\n                ipv4_hosts.append(sockaddr[0])\n    except socket.gaierror:\n        ipv4_hosts = [addr.host]\n\n    candidates: list[tuple[str, int]] = []\n    for ipv6_host, ipv4_host in zip_longest(ipv6_hosts, ipv4_hosts):\n        if ipv6_host is not None:\n            candidates.append((ipv6_host, addr.port))\n        if ipv4_host is not None:\n            candidates.append((ipv4_host, addr.port))\n\n    if not candidates:\n        raise ConnectionError(f\"cannot resolve {addr.host}\")\n\n    if len(candidates) == 1:\n        return await asyncio.open_connection(candidates[0][0], candidates[0][1])\n\n    pending: set[asyncio.Task[tuple[asyncio.StreamReader, asyncio.StreamWriter]]] = (\n        set()\n    )\n    errors: list[BaseException] = []\n    next_candidate = 0\n\n    def start_next_candidate() -> None:\n        nonlocal next_candidate\n        if next_candidate >= len(candidates):\n            return\n        host, port = candidates[next_candidate]\n        next_candidate += 1\n        pending.add(loop.create_task(asyncio.open_connection(host, port)))\n\n    async def cancel_pending() -> None:\n        for task in pending:\n            task.cancel()\n        for task in pending:\n            try:\n                await task\n            except (asyncio.CancelledError, Exception):\n                pass\n\n    start_next_candidate()\n\n    while pending:\n        timeout = HAPPY_EYEBALLS_DELAY if next_candidate < len(candidates) else None\n        done_tasks, pending_tasks = await asyncio.wait(\n            pending, timeout=timeout, return_when=asyncio.FIRST_COMPLETED\n        )\n        pending = set(pending_tasks)\n\n        if not done_tasks:\n            start_next_candidate()\n            continue\n\n        for task in done_tasks:\n            try:\n                result = task.result()\n            except Exception as exc:\n                errors.append(exc)\n            else:\n                await cancel_pending()\n                return result\n\n        if not pending:\n            start_next_candidate()\n\n    message = f\"all connection attempts failed to {addr.host}:{addr.port}\"\n    if errors:\n        raise ConnectionError(message) from errors[0]\n    raise ConnectionError(message)\n\n\nasync def _negotiate(\n    reader: asyncio.StreamReader,\n    writer: asyncio.StreamWriter,\n    username: str | None,\n    password: str | None,\n) -> None:\n    if username and password:\n        writer.write(b\"\\x05\\x01\\x02\")\n    else:\n        writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n\n    resp = await reader.readexactly(2)\n    if resp[0] != 0x05:\n        raise ProtocolError(f\"unsupported SOCKS version: {resp[0]}\")\n\n    if resp[1] == AuthMethod.NO_AUTH:\n        return\n    if resp[1] == AuthMethod.USERNAME_PASSWORD and username and password:\n        uname = username.encode(\"utf-8\")\n        passwd = password.encode(\"utf-8\")\n        writer.write(\n            b\"\\x01\"\n            + len(uname).to_bytes(1, \"big\")\n            + uname\n            + len(passwd).to_bytes(1, \"big\")\n            + passwd\n        )\n        await writer.drain()\n        auth_resp = await reader.readexactly(2)\n        if auth_resp[1] != 0x00:\n            raise ProtocolError(\"authentication failed\")\n    elif resp[1] == AuthMethod.NO_ACCEPTABLE:\n        raise ProtocolError(\"no acceptable auth method\")\n    else:\n        raise ProtocolError(f\"unsupported auth method: {resp[1]}\")\n\n\nasync def _request_connect(\n    reader: asyncio.StreamReader,\n    writer: asyncio.StreamWriter,\n    target: Address,\n) -> None:\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target.host, target.port))\n    await writer.drain()\n\n    reply = await reader.readexactly(3)\n    if reply[0] != 0x05:\n        raise ProtocolError(f\"unsupported SOCKS version: {reply[0]}\")\n    if reply[1] != Rep.SUCCEEDED:\n        raise ProtocolError(f\"connect failed with rep={reply[1]:#04x}\")\n\n    # Read bound address\n    await decode_address(reader)\n"
  },
  {
    "path": "src/asyncio_socks_server/core/__init__.py",
    "content": ""
  },
  {
    "path": "src/asyncio_socks_server/core/address.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport ipaddress\nimport struct\nfrom ipaddress import IPv4Address, IPv6Address\n\nfrom .types import Address, Atyp, Rep\n\n\ndef detect_atyp(host: str) -> Atyp:\n    try:\n        IPv4Address(host)\n        return Atyp.IPV4\n    except ValueError:\n        pass\n    try:\n        IPv6Address(host)\n        return Atyp.IPV6\n    except ValueError:\n        pass\n    return Atyp.DOMAIN\n\n\ndef encode_address(host: str, port: int) -> bytes:\n    atyp = detect_atyp(host)\n    if atyp == Atyp.IPV4:\n        ADDR = ipaddress.IPv4Address(host).packed\n    elif atyp == Atyp.IPV6:\n        ADDR = ipaddress.IPv6Address(host).packed\n    else:\n        encoded = host.encode(\"ascii\")\n        ADDR = bytes([len(encoded)]) + encoded\n    ATYP = atyp.to_bytes(1, \"big\")\n    PORT = struct.pack(\"!H\", port)\n    return ATYP + ADDR + PORT\n\n\nasync def decode_address(reader: asyncio.StreamReader) -> Address:\n    ATYP = Atyp((await reader.readexactly(1))[0])\n    if ATYP == Atyp.IPV4:\n        DST_ADDR = ipaddress.IPv4Address(await reader.readexactly(4)).compressed\n    elif ATYP == Atyp.IPV6:\n        DST_ADDR = ipaddress.IPv6Address(await reader.readexactly(16)).compressed\n    elif ATYP == Atyp.DOMAIN:\n        length = (await reader.readexactly(1))[0]\n        DST_ADDR = (await reader.readexactly(length)).decode(\"ascii\")\n    else:\n        raise ValueError(f\"unsupported ATYP: {ATYP}\")\n    DST_PORT = struct.unpack(\"!H\", await reader.readexactly(2))[0]\n    return Address(DST_ADDR, DST_PORT)\n\n\ndef encode_reply(\n    rep: Rep,\n    bind_host: str = \"0.0.0.0\",\n    bind_port: int = 0,\n) -> bytes:\n    VER = b\"\\x05\"\n    REP = rep.to_bytes(1, \"big\")\n    RSV = b\"\\x00\"\n    return VER + REP + RSV + encode_address(bind_host, bind_port)\n"
  },
  {
    "path": "src/asyncio_socks_server/core/logging.py",
    "content": "from __future__ import annotations\n\nimport logging\n\nfrom .types import Address\n\nFORMAT = \"%(asctime)s | %(levelname)-8s | %(message)s\"\n\n\ndef setup_logging(level: str = \"INFO\") -> None:\n    logging.basicConfig(\n        format=FORMAT,\n        level=getattr(logging, level.upper()),\n        force=True,\n    )\n\n\ndef get_logger() -> logging.Logger:\n    return logging.getLogger(\"asyncio_socks_server\")\n\n\ndef fmt_addr(addr: Address) -> str:\n    return str(addr)\n\n\ndef fmt_connection(src: Address, dst: Address) -> str:\n    return f\"{src} → {dst}\"\n\n\ndef fmt_bytes(n: int) -> str:\n    if n < 1024:\n        return f\"{n}B\"\n    if n < 1024 * 1024:\n        return f\"{n / 1024:.1f}KB\"\n    return f\"{n / (1024 * 1024):.1f}MB\"\n"
  },
  {
    "path": "src/asyncio_socks_server/core/protocol.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport ipaddress\nimport struct\n\nfrom .types import Address, Cmd\n\n\nclass ProtocolError(Exception):\n    pass\n\n\ndef parse_method_selection(data: bytes) -> tuple[int, set[int]]:\n    if len(data) < 2:\n        raise ProtocolError(\"method selection too short\")\n    VER = data[0]\n    NMETHODS = data[1]\n    if VER != 0x05:\n        raise ProtocolError(f\"unsupported SOCKS version: {VER}\")\n    METHODS = set(data[2 : 2 + NMETHODS])\n    return VER, METHODS\n\n\ndef build_method_reply(method: int) -> bytes:\n    VER = b\"\\x05\"\n    METHOD = method.to_bytes(1, \"big\")\n    return VER + METHOD\n\n\nasync def parse_username_password(\n    reader: asyncio.StreamReader,\n) -> tuple[str, str]:\n    VER = (await reader.readexactly(1))[0]\n    if VER != 0x01:\n        raise ProtocolError(f\"unsupported auth version: {VER}\")\n    ULEN = (await reader.readexactly(1))[0]\n    UNAME = (await reader.readexactly(ULEN)).decode(\"utf-8\")\n    PLEN = (await reader.readexactly(1))[0]\n    PASSWD = (await reader.readexactly(PLEN)).decode(\"utf-8\")\n    return UNAME, PASSWD\n\n\ndef build_auth_reply(success: bool) -> bytes:\n    VER = b\"\\x01\"\n    STATUS = b\"\\x00\" if success else b\"\\x01\"\n    return VER + STATUS\n\n\nasync def parse_request(reader: asyncio.StreamReader) -> tuple[Cmd, Address]:\n    VER, CMD, RSV, ATYP_BYTE = await reader.readexactly(4)\n    if VER != 0x05:\n        raise ProtocolError(f\"unsupported SOCKS version: {VER}\")\n    try:\n        cmd = Cmd(CMD)\n    except ValueError:\n        raise ProtocolError(f\"unsupported command: {CMD}\") from None\n\n    if ATYP_BYTE == 0x01:  # IPv4\n        host = ipaddress.IPv4Address(await reader.readexactly(4)).compressed\n    elif ATYP_BYTE == 0x04:  # IPv6\n        host = ipaddress.IPv6Address(await reader.readexactly(16)).compressed\n    elif ATYP_BYTE == 0x03:  # Domain\n        length = (await reader.readexactly(1))[0]\n        host = (await reader.readexactly(length)).decode(\"ascii\")\n    else:\n        raise ProtocolError(f\"unsupported ATYP: {ATYP_BYTE}\")\n\n    DST_PORT = struct.unpack(\"!H\", await reader.readexactly(2))[0]\n    return cmd, Address(host, DST_PORT)\n\n\ndef parse_udp_header(data: bytes) -> tuple[Address, int, bytes]:\n    \"\"\"Parse SOCKS5 UDP request header.\n\n    Returns (dst_address, header_length, payload).\n    \"\"\"\n    if len(data) < 4:\n        raise ProtocolError(\"UDP header too short\")\n    # RSV(2) + FRAG(1) skipped — we don't support fragmentation\n    ATYP_BYTE = data[3]\n\n    if ATYP_BYTE == 0x01:\n        if len(data) < 10:\n            raise ProtocolError(\"UDP header truncated (IPv4)\")\n        host = ipaddress.IPv4Address(data[4:8]).compressed\n        DST_PORT = struct.unpack(\"!H\", data[8:10])[0]\n        header_length = 10\n    elif ATYP_BYTE == 0x04:\n        if len(data) < 22:\n            raise ProtocolError(\"UDP header truncated (IPv6)\")\n        host = ipaddress.IPv6Address(data[4:20]).compressed\n        DST_PORT = struct.unpack(\"!H\", data[20:22])[0]\n        header_length = 22\n    elif ATYP_BYTE == 0x03:\n        length = data[4]\n        if len(data) < 5 + length + 2:\n            raise ProtocolError(\"UDP header truncated (domain)\")\n        host = data[5 : 5 + length].decode(\"ascii\")\n        DST_PORT = struct.unpack(\"!H\", data[5 + length : 5 + length + 2])[0]\n        header_length = 5 + length + 2\n    else:\n        raise ProtocolError(f\"unsupported ATYP: {ATYP_BYTE}\")\n\n    return Address(host, DST_PORT), header_length, data[header_length:]\n\n\ndef build_udp_header(address: Address) -> bytes:\n    RSV = b\"\\x00\\x00\"\n    FRAG = b\"\\x00\"\n    from .address import encode_address\n\n    return RSV + FRAG + encode_address(address.host, address.port)\n"
  },
  {
    "path": "src/asyncio_socks_server/core/socket.py",
    "content": "from __future__ import annotations\n\nimport ipaddress\nimport socket\n\n\ndef _is_ipv6(host: str) -> bool:\n    try:\n        ipaddress.IPv6Address(host)\n        return True\n    except ValueError:\n        return False\n\n\ndef create_dualstack_tcp_socket(host: str, port: int) -> socket.socket:\n    \"\"\"Create a TCP server socket with dual-stack (IPv4+IPv6) support.\"\"\"\n    if host in (\"\", \"::\"):\n        return socket.create_server(\n            (\"::\", port), family=socket.AF_INET6, dualstack_ipv6=True\n        )\n    if host == \"0.0.0.0\":\n        return socket.create_server((host, port), family=socket.AF_INET)\n    if _is_ipv6(host):\n        return socket.create_server((host, port), family=socket.AF_INET6)\n    return socket.create_server((host, port))\n\n\ndef create_dualstack_udp_socket(host: str, port: int = 0) -> socket.socket:\n    \"\"\"Create a UDP socket with dual-stack support.\"\"\"\n    if host in (\"0.0.0.0\", \"\", \"::\"):\n        sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)\n        sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)\n        sock.bind((\"::\", port))\n    elif _is_ipv6(host):\n        sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)\n        sock.bind((host, port))\n    else:\n        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        sock.bind((host, port))\n    return sock\n"
  },
  {
    "path": "src/asyncio_socks_server/core/types.py",
    "content": "from __future__ import annotations\n\nimport time\nfrom dataclasses import dataclass, field\nfrom enum import IntEnum, StrEnum\nfrom typing import Literal\n\n\nclass Rep(IntEnum):\n    \"\"\"RFC 1928 reply codes.\"\"\"\n\n    SUCCEEDED = 0x00\n    GENERAL_FAILURE = 0x01\n    CONNECTION_NOT_ALLOWED = 0x02\n    NETWORK_UNREACHABLE = 0x03\n    HOST_UNREACHABLE = 0x04\n    CONNECTION_REFUSED = 0x05\n    TTL_EXPIRED = 0x06\n    COMMAND_NOT_SUPPORTED = 0x07\n    ADDRESS_TYPE_NOT_SUPPORTED = 0x08\n\n\nclass AuthMethod(IntEnum):\n    \"\"\"SOCKS5 authentication methods.\"\"\"\n\n    NO_AUTH = 0x00\n    USERNAME_PASSWORD = 0x02\n    NO_ACCEPTABLE = 0xFF\n\n\nclass Cmd(IntEnum):\n    \"\"\"SOCKS5 commands.\"\"\"\n\n    CONNECT = 0x01\n    UDP_ASSOCIATE = 0x03\n\n\nclass Atyp(IntEnum):\n    \"\"\"SOCKS5 address types.\"\"\"\n\n    IPV4 = 0x01\n    DOMAIN = 0x03\n    IPV6 = 0x04\n\n\nclass Direction(StrEnum):\n    \"\"\"Data flow direction.\"\"\"\n\n    UPSTREAM = \"upstream\"\n    DOWNSTREAM = \"downstream\"\n\n\n@dataclass(frozen=True)\nclass Address:\n    host: str\n    port: int\n\n    def __str__(self) -> str:\n        return f\"{self.host}:{self.port}\"\n\n\n@dataclass\nclass Flow:\n    \"\"\"Per-connection context carried through the hook lifecycle.\"\"\"\n\n    id: int\n    src: Address\n    dst: Address\n    protocol: Literal[\"tcp\", \"udp\"]\n    started_at: float  # time.monotonic()\n    started_wall_at: float = field(default_factory=time.time)\n    bytes_up: int = 0  # TCP: post-addon; UDP: raw payload (no addon pipeline)\n    bytes_down: int = 0\n"
  },
  {
    "path": "src/asyncio_socks_server/py.typed",
    "content": ""
  },
  {
    "path": "src/asyncio_socks_server/server/__init__.py",
    "content": ""
  },
  {
    "path": "src/asyncio_socks_server/server/connection.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nfrom dataclasses import dataclass\n\nfrom asyncio_socks_server.core.types import Address\n\n\n@dataclass\nclass Connection:\n    reader: asyncio.StreamReader\n    writer: asyncio.StreamWriter\n    address: Address\n"
  },
  {
    "path": "src/asyncio_socks_server/server/server.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport ipaddress\nimport itertools\nimport socket\nimport time\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.address import encode_reply\nfrom asyncio_socks_server.core.logging import fmt_bytes, fmt_connection, get_logger\nfrom asyncio_socks_server.core.protocol import (\n    build_auth_reply,\n    build_method_reply,\n    parse_method_selection,\n    parse_request,\n    parse_username_password,\n)\nfrom asyncio_socks_server.core.socket import (\n    create_dualstack_tcp_socket,\n    create_dualstack_udp_socket,\n)\nfrom asyncio_socks_server.core.types import Address, AuthMethod, Cmd, Flow, Rep\nfrom asyncio_socks_server.server.connection import Connection\nfrom asyncio_socks_server.server.tcp_relay import handle_tcp_relay\nfrom asyncio_socks_server.server.udp_relay import UdpRelay, UdpRelayBase\n\n\nclass Server:\n    def __init__(\n        self,\n        host: str = \"::\",\n        port: int = 1080,\n        addons: list[Addon] | None = None,\n        auth: tuple[str, str] | None = None,\n        log_level: str = \"INFO\",\n        shutdown_timeout: float | None = 30.0,\n    ):\n        self.host = host\n        self.port = port\n        self.auth = auth\n        self.log_level = log_level\n        self.shutdown_timeout = shutdown_timeout\n        self._addon_manager = AddonManager(addons)\n        self._shutdown_event = asyncio.Event()\n        self._flow_seq = itertools.count(1)\n        self._client_tasks: set[asyncio.Task] = set()\n\n    def run(self) -> None:\n        asyncio.run(self._run())\n\n    def _install_signal_handlers(self) -> None:\n        import signal\n\n        loop = asyncio.get_running_loop()\n\n        def _signal_handler():\n            self.request_shutdown()\n\n        for sig in (signal.SIGTERM, signal.SIGINT):\n            loop.add_signal_handler(sig, _signal_handler)\n\n    async def _run(self) -> None:\n        from asyncio_socks_server.core.logging import setup_logging\n\n        setup_logging(self.log_level)\n        logger = get_logger()\n\n        await self._addon_manager.dispatch_start()\n        self._install_signal_handlers()\n\n        sock = create_dualstack_tcp_socket(self.host, self.port)\n        sock.setblocking(False)\n        srv = await asyncio.start_server(\n            self._handle_client,\n            sock=sock,\n        )\n        addr = srv.sockets[0].getsockname()\n        self.port = addr[1]\n        logger.info(f\"server started on {self.host}:{self.port}\")\n\n        try:\n            await self._shutdown_event.wait()\n        finally:\n            srv.close()\n            await srv.wait_closed()\n            await self._wait_for_client_tasks()\n            await self._addon_manager.dispatch_stop()\n            logger.info(\"server stopped\")\n\n    async def _wait_for_client_tasks(self) -> None:\n        if not self._client_tasks:\n            return\n\n        tasks = set(self._client_tasks)\n        try:\n            if self.shutdown_timeout is None:\n                await asyncio.gather(*tasks, return_exceptions=True)\n            else:\n                await asyncio.wait_for(\n                    asyncio.gather(*tasks, return_exceptions=True),\n                    timeout=self.shutdown_timeout,\n                )\n        except TimeoutError:\n            for task in tasks:\n                if not task.done():\n                    task.cancel()\n            await asyncio.gather(*tasks, return_exceptions=True)\n\n    async def _handle_client(\n        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter\n    ) -> None:\n        task = asyncio.current_task()\n        if task is not None:\n            self._client_tasks.add(task)\n        try:\n            await self._do_handshake_and_relay(reader, writer)\n        except Exception as e:\n            await self._addon_manager.dispatch_error(e)\n        finally:\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except (ConnectionError, OSError):\n                pass\n            if task is not None:\n                self._client_tasks.discard(task)\n\n    async def _do_handshake_and_relay(\n        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter\n    ) -> None:\n        header = await reader.readexactly(2)\n        version, method_count = header[0], header[1]\n        method_data = await reader.readexactly(method_count)\n        _, methods = parse_method_selection(\n            bytes([version, method_count]) + method_data\n        )\n\n        if self.auth:\n            if AuthMethod.USERNAME_PASSWORD not in methods:\n                writer.write(build_method_reply(AuthMethod.NO_ACCEPTABLE))\n                await writer.drain()\n                return\n            writer.write(build_method_reply(AuthMethod.USERNAME_PASSWORD))\n            await writer.drain()\n\n            username, password = await parse_username_password(reader)\n\n            auth_result = await self._addon_manager.dispatch_auth(username, password)\n            if auth_result is not None:\n                success = auth_result\n            else:\n                success = username == self.auth[0] and password == self.auth[1]\n\n            writer.write(build_auth_reply(success))\n            await writer.drain()\n            if not success:\n                return\n        else:\n            if AuthMethod.NO_AUTH not in methods:\n                writer.write(build_method_reply(AuthMethod.NO_ACCEPTABLE))\n                await writer.drain()\n                return\n            writer.write(build_method_reply(AuthMethod.NO_AUTH))\n            await writer.drain()\n\n        cmd, dst = await parse_request(reader)\n\n        peername = writer.get_extra_info(\"peername\")\n        src = Address(peername[0], peername[1]) if peername else Address(\"::\", 0)\n\n        if cmd == Cmd.CONNECT:\n            await self._handle_connect(reader, writer, src, dst)\n        elif cmd == Cmd.UDP_ASSOCIATE:\n            await self._handle_udp_associate(reader, writer, src, dst)\n        else:\n            writer.write(encode_reply(Rep.COMMAND_NOT_SUPPORTED))\n            await writer.drain()\n\n    async def _handle_connect(\n        self,\n        client_reader: asyncio.StreamReader,\n        client_writer: asyncio.StreamWriter,\n        src: Address,\n        dst: Address,\n    ) -> None:\n        logger = get_logger()\n        flow = Flow(\n            id=next(self._flow_seq),\n            src=src,\n            dst=dst,\n            protocol=\"tcp\",\n            started_at=time.monotonic(),\n        )\n        conn: Connection | None = None\n        connected = False\n\n        try:\n            try:\n                addon_result = await self._addon_manager.dispatch_connect(flow)\n            except Exception as e:\n                logger.error(f\"{fmt_connection(src, dst)} | addon error: {e}\")\n                client_writer.write(encode_reply(Rep.CONNECTION_NOT_ALLOWED))\n                await client_writer.drain()\n                return\n            if addon_result is not None and isinstance(addon_result, Connection):\n                conn = addon_result\n            else:\n                try:\n                    remote_reader, remote_writer = await asyncio.open_connection(\n                        dst.host, dst.port\n                    )\n                    sock = remote_writer.get_extra_info(\"socket\")\n                    sockname = sock.getsockname() if sock else (\"::\", 0)\n                    conn = Connection(\n                        reader=remote_reader,\n                        writer=remote_writer,\n                        address=Address(sockname[0], sockname[1]),\n                    )\n                except (ConnectionError, OSError) as e:\n                    logger.error(f\"{fmt_connection(src, dst)} | {e}\")\n                    rep = self._error_to_rep(e)\n                    client_writer.write(encode_reply(rep))\n                    await client_writer.drain()\n                    return\n\n            client_writer.write(\n                encode_reply(Rep.SUCCEEDED, conn.address.host, conn.address.port)\n            )\n            await client_writer.drain()\n\n            connected = True\n            logger.info(f\"{fmt_connection(src, dst)} | connected\")\n\n            await handle_tcp_relay(\n                client_reader,\n                client_writer,\n                conn.reader,\n                conn.writer,\n                self._addon_manager,\n                flow,\n            )\n        finally:\n            if connected:\n                elapsed = time.monotonic() - flow.started_at\n                logger.info(\n                    f\"{fmt_connection(src, dst)} | \"\n                    f\"closed {elapsed:.1f}s \"\n                    f\"↑{fmt_bytes(flow.bytes_up)} ↓{fmt_bytes(flow.bytes_down)}\"\n                )\n            await self._addon_manager.dispatch_flow_close(flow)\n\n    async def _handle_udp_associate(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        src: Address,\n        dst: Address,\n    ) -> None:\n        logger = get_logger()\n        flow = Flow(\n            id=next(self._flow_seq),\n            src=src,\n            dst=dst,\n            protocol=\"udp\",\n            started_at=time.monotonic(),\n        )\n\n        try:\n            relay: UdpRelayBase = (\n                await self._addon_manager.dispatch_udp_associate(flow)\n            ) or UdpRelay(client_addr=src, flow=flow)\n        except Exception as e:\n            logger.error(f\"{fmt_connection(src, dst)} | addon error: {e}\")\n            writer.write(encode_reply(Rep.CONNECTION_NOT_ALLOWED))\n            await writer.drain()\n            return\n\n        client_transport: asyncio.DatagramTransport | None = None\n        reply_sent = False\n\n        try:\n            await relay.start()\n\n            loop = asyncio.get_running_loop()\n            client_udp_sock = _create_client_udp_socket(src.host)\n            client_udp_sock.setblocking(False)\n            client_transport, _ = await loop.create_datagram_endpoint(\n                lambda: _ClientUdpProtocol(relay),\n                sock=client_udp_sock,\n            )\n            client_sock = client_transport.get_extra_info(\"socket\")\n            fallback = (\"::\", 0)\n            client_sockname = client_sock.getsockname() if client_sock else fallback\n            client_bind = Address(client_sockname[0], client_sockname[1])\n\n            relay.set_client_transport(client_transport)\n\n            writer.write(\n                encode_reply(Rep.SUCCEEDED, client_bind.host, client_bind.port)\n            )\n            await writer.drain()\n            reply_sent = True\n\n            logger.info(f\"{fmt_connection(src, dst)} | udp associate started\")\n\n            await reader.read()\n        except Exception as e:\n            logger.error(f\"{fmt_connection(src, dst)} | udp associate error: {e}\")\n            await self._addon_manager.dispatch_error(e)\n            if not reply_sent:\n                try:\n                    writer.write(encode_reply(Rep.GENERAL_FAILURE))\n                    await writer.drain()\n                except (ConnectionError, OSError):\n                    pass\n        finally:\n            await relay.stop()\n            if client_transport and not client_transport.is_closing():\n                client_transport.close()\n            logger.info(\n                f\"{fmt_connection(src, dst)} | \"\n                f\"udp closed ↑{fmt_bytes(flow.bytes_up)} ↓{fmt_bytes(flow.bytes_down)}\"\n            )\n            await self._addon_manager.dispatch_flow_close(flow)\n\n    @staticmethod\n    def _error_to_rep(exc: Exception) -> Rep:\n        if isinstance(exc, ConnectionRefusedError):\n            return Rep.CONNECTION_REFUSED\n        if isinstance(exc, OSError) and exc.errno == 101:\n            return Rep.NETWORK_UNREACHABLE\n        return Rep.GENERAL_FAILURE\n\n    def request_shutdown(self) -> None:\n        self._shutdown_event.set()\n\n\ndef _create_client_udp_socket(host: str) -> socket.socket:\n    try:\n        ipaddress.IPv6Address(host)\n    except ValueError:\n        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n        sock.bind((\"0.0.0.0\", 0))\n        return sock\n    return create_dualstack_udp_socket(\"::\", 0)\n\n\nclass _ClientUdpProtocol(asyncio.DatagramProtocol):\n    def __init__(self, relay: UdpRelayBase) -> None:\n        self._relay = relay\n\n    def connection_made(self, transport: asyncio.DatagramTransport) -> None:\n        pass\n\n    def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:\n        self._relay.handle_client_datagram(data, addr)\n\n    def error_received(self, exc: Exception) -> None:\n        pass\n"
  },
  {
    "path": "src/asyncio_socks_server/server/tcp_relay.py",
    "content": "from __future__ import annotations\n\nimport asyncio\n\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.logging import get_logger\nfrom asyncio_socks_server.core.types import Direction, Flow\n\n\nasync def _copy(\n    reader: asyncio.StreamReader,\n    writer: asyncio.StreamWriter,\n    addon_manager: AddonManager,\n    direction: Direction,\n    flow: Flow,\n) -> None:\n    try:\n        while True:\n            data = await reader.read(4096)\n            if not data:\n                break\n            result = await addon_manager.dispatch_data(direction, data, flow)\n            if result is None:\n                continue\n            writer.write(result)\n            await writer.drain()\n            n = len(result)\n            if direction == Direction.UPSTREAM:\n                flow.bytes_up += n\n            else:\n                flow.bytes_down += n\n    except (ConnectionError, asyncio.CancelledError):\n        pass\n    finally:\n        try:\n            writer.close()\n            await writer.wait_closed()\n        except (ConnectionError, OSError):\n            pass\n\n\nasync def handle_tcp_relay(\n    client_reader: asyncio.StreamReader,\n    client_writer: asyncio.StreamWriter,\n    remote_reader: asyncio.StreamReader,\n    remote_writer: asyncio.StreamWriter,\n    addon_manager: AddonManager,\n    flow: Flow,\n) -> None:\n    \"\"\"Bidirectional TCP relay with addon on_data pipeline.\"\"\"\n    try:\n        async with asyncio.TaskGroup() as tg:\n            tg.create_task(\n                _copy(\n                    client_reader,\n                    remote_writer,\n                    addon_manager,\n                    Direction.UPSTREAM,\n                    flow,\n                )\n            )\n            tg.create_task(\n                _copy(\n                    remote_reader,\n                    client_writer,\n                    addon_manager,\n                    Direction.DOWNSTREAM,\n                    flow,\n                )\n            )\n    except ExceptionGroup as eg:\n        get_logger().debug(f\"tcp relay task group ended: {eg.exceptions}\")\n"
  },
  {
    "path": "src/asyncio_socks_server/server/udp_over_tcp.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport struct\n\nfrom asyncio_socks_server.core.address import decode_address, encode_address\nfrom asyncio_socks_server.core.types import Address\n\n\nasync def encode_udp_frame(address: Address, data: bytes) -> bytes:\n    \"\"\"Encode a UDP datagram as a TCP frame.\n\n    Frame format: [4-byte length][ATYP+ADDR+PORT][payload]\n    \"\"\"\n    addr_bytes = encode_address(address.host, address.port)\n    payload = addr_bytes + data\n    length = struct.pack(\"!I\", len(payload))\n    return length + payload\n\n\nasync def read_udp_frame(\n    reader: asyncio.StreamReader,\n) -> tuple[Address, bytes]:\n    \"\"\"Read a UDP-over-TCP frame from a stream.\n\n    Returns (target_address, payload).\n    \"\"\"\n    length_bytes = await reader.readexactly(4)\n    length = struct.unpack(\"!I\", length_bytes)[0]\n    payload = await reader.readexactly(length)\n\n    # Parse address from the beginning of payload\n    atyp_byte = payload[0]\n    if atyp_byte == 0x01:  # IPv4\n        addr_len = 1 + 4 + 2  # ATYP + IPv4 + PORT\n    elif atyp_byte == 0x04:  # IPv6\n        addr_len = 1 + 16 + 2\n    elif atyp_byte == 0x03:  # Domain\n        domain_len = payload[1]\n        addr_len = 1 + 1 + domain_len + 2\n    else:\n        raise ValueError(f\"unsupported ATYP: {atyp_byte}\")\n\n    addr_payload = payload[:addr_len]\n    data = payload[addr_len:]\n\n    # Decode address\n    addr_reader = asyncio.StreamReader()\n    addr_reader.feed_data(addr_payload)\n    addr_reader.feed_eof()\n    address = await decode_address(addr_reader)\n\n    return address, data\n"
  },
  {
    "path": "src/asyncio_socks_server/server/udp_over_tcp_exit.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\n\nfrom asyncio_socks_server.core.logging import get_logger\nfrom asyncio_socks_server.core.socket import (\n    create_dualstack_tcp_socket,\n    create_dualstack_udp_socket,\n)\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.udp_over_tcp import encode_udp_frame, read_udp_frame\n\n\ndef _normalize_host(host: str) -> str:\n    if host.startswith(\"::ffff:\"):\n        return host[7:]\n    return host\n\n\ndef _map_addr_for_sendto(\n    host: str, port: int\n) -> tuple[str, int] | tuple[str, int, int, int]:\n    import ipaddress\n\n    try:\n        addr = ipaddress.ip_address(host)\n        if isinstance(addr, ipaddress.IPv4Address):\n            return (f\"::ffff:{host}\", port, 0, 0)\n    except ValueError:\n        pass\n    return (host, port)\n\n\nclass UdpOverTcpExitServer:\n    \"\"\"Accepts TCP connections carrying UDP-over-TCP frames and relays to raw UDP.\n\n    Used as the exit node in a UDP-over-TCP chain. Not an addon — it's a\n    standalone TCP service that sits at the chain endpoint.\n    \"\"\"\n\n    def __init__(self, host: str = \"::\", port: int = 0, ttl: float = 300.0):\n        self.host = host\n        self.port = port\n        self._ttl = ttl\n        self._shutdown_event = asyncio.Event()\n\n    def run(self) -> None:\n        asyncio.run(self._run())\n\n    def request_shutdown(self) -> None:\n        self._shutdown_event.set()\n\n    async def _run(self) -> None:\n        logger = get_logger()\n        sock = create_dualstack_udp_socket(\"0.0.0.0\", 0)\n        sock.setblocking(False)\n        loop = asyncio.get_running_loop()\n        udp_transport: asyncio.DatagramTransport | None = None\n        route_map: dict[tuple[str, int], asyncio.StreamWriter] = {}\n        route_ts: dict[tuple[str, int], float] = {}\n\n        # Shared outbound UDP socket\n        class UdpProtocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport: asyncio.DatagramTransport) -> None:\n                nonlocal udp_transport\n                udp_transport = transport\n\n            def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:\n                remote_key = (_normalize_host(addr[0]), addr[1])\n                writer = route_map.get(remote_key)\n                if writer is None:\n                    return\n                route_ts[remote_key] = time.monotonic()\n                src_addr = Address(_normalize_host(addr[0]), addr[1])\n                task = asyncio.create_task(_send_frame(writer, src_addr, data))\n                task.add_done_callback(\n                    lambda t: t.exception() if not t.cancelled() else None\n                )\n\n            def error_received(self, exc: Exception) -> None:\n                pass\n\n        async def _send_frame(\n            writer: asyncio.StreamWriter, src_addr: Address, data: bytes\n        ) -> None:\n            try:\n                frame = await encode_udp_frame(src_addr, data)\n                writer.write(frame)\n                await writer.drain()\n            except (ConnectionError, OSError):\n                pass\n\n        _, _ = await loop.create_datagram_endpoint(UdpProtocol, sock=sock)\n\n        # TTL cleanup task\n        async def _ttl_cleanup():\n            while True:\n                await asyncio.sleep(60)\n                now = time.monotonic()\n                expired = [k for k, ts in route_ts.items() if now - ts > self._ttl]\n                for k in expired:\n                    route_map.pop(k, None)\n                    route_ts.pop(k, None)\n\n        ttl_task = asyncio.create_task(_ttl_cleanup())\n\n        # TCP server\n        tcp_sock = create_dualstack_tcp_socket(self.host, self.port)\n        tcp_sock.setblocking(False)\n        tcp_srv = await asyncio.start_server(\n            lambda r, w: _handle_tcp(r, w, udp_transport, route_map, route_ts),\n            sock=tcp_sock,\n        )\n        tcp_sockname = tcp_srv.sockets[0].getsockname()\n        self.port = tcp_sockname[1]\n        logger.info(f\"udp-over-tcp exit started on {self.host}:{self.port}\")\n\n        try:\n            await self._shutdown_event.wait()\n        finally:\n            ttl_task.cancel()\n            try:\n                await ttl_task\n            except asyncio.CancelledError:\n                pass\n            tcp_srv.close()\n            await tcp_srv.wait_closed()\n            if udp_transport:\n                udp_transport.close()\n            logger.info(\"udp-over-tcp exit stopped\")\n\n\nasync def _handle_tcp(\n    reader: asyncio.StreamReader,\n    writer: asyncio.StreamWriter,\n    udp_transport: asyncio.DatagramTransport | None,\n    route_map: dict[tuple[str, int], asyncio.StreamWriter],\n    route_ts: dict[tuple[str, int], float],\n) -> None:\n    try:\n        while True:\n            dst_addr, payload = await read_udp_frame(reader)\n            remote_key = (dst_addr.host, dst_addr.port)\n            route_map[remote_key] = writer\n            route_ts[remote_key] = time.monotonic()\n            if udp_transport:\n                udp_transport.sendto(\n                    payload, _map_addr_for_sendto(dst_addr.host, dst_addr.port)\n                )\n    except (asyncio.IncompleteReadError, ConnectionError, OSError):\n        pass\n    finally:\n        try:\n            writer.close()\n            await writer.wait_closed()\n        except (ConnectionError, OSError):\n            pass\n"
  },
  {
    "path": "src/asyncio_socks_server/server/udp_relay.py",
    "content": "from __future__ import annotations\n\nimport asyncio\nimport time\nfrom typing import Callable\n\nfrom asyncio_socks_server.core.protocol import build_udp_header, parse_udp_header\nfrom asyncio_socks_server.core.socket import create_dualstack_udp_socket\nfrom asyncio_socks_server.core.types import Address, Flow\n\n\ndef _normalize_host(host: str) -> str:\n    \"\"\"Strip IPv4-mapped IPv6 prefix for consistent routing table keys.\"\"\"\n    if host.startswith(\"::ffff:\"):\n        return host[7:]\n    return host\n\n\ndef _map_addr_for_sendto(\n    host: str, port: int\n) -> tuple[str, int] | tuple[str, int, int, int]:\n    \"\"\"Return an address tuple suitable for the outbound socket's family.\n\n    AF_INET6 sockets require IPv4-mapped format (::ffff:x.x.x.x) for IPv4 targets.\n    \"\"\"\n    import ipaddress\n\n    try:\n        addr = ipaddress.ip_address(host)\n        if isinstance(addr, ipaddress.IPv4Address):\n            return (f\"::ffff:{host}\", port, 0, 0)\n    except ValueError:\n        pass\n    return (host, port)\n\n\nclass UdpRelayBase:\n    \"\"\"Interface for UDP relay handlers used by the server and addon system.\"\"\"\n\n    async def start(self) -> Address:\n        raise NotImplementedError\n\n    def set_client_transport(self, transport: asyncio.DatagramTransport) -> None:\n        raise NotImplementedError\n\n    async def stop(self) -> None:\n        raise NotImplementedError\n\n    def handle_client_datagram(self, data: bytes, client_addr: tuple[str, int]) -> None:\n        raise NotImplementedError\n\n\nclass UdpRelay(UdpRelayBase):\n    \"\"\"UDP relay using a shared outbound socket + bidirectional routing table.\n\n    All clients share one outbound UDP socket. A routing table maps\n    remote addresses back to client addresses for response routing.\n    Entries expire after TTL seconds of inactivity.\n    \"\"\"\n\n    def __init__(self, client_addr: Address, flow: Flow, ttl: float = 300.0):\n        self._client_addr = client_addr\n        self._ttl = ttl\n        self._transport: asyncio.DatagramTransport | None = None\n        self._route_map: dict[tuple[str, int], tuple[str, int]] = {}\n        self._route_timestamps: dict[tuple[str, int], float] = {}\n        self._ttl_task: asyncio.Task | None = None\n        self._client_transport: asyncio.DatagramTransport | None = None\n        self._bind_addr: Address | None = None\n        self._flow = flow\n\n    async def start(self) -> Address:\n        loop = asyncio.get_running_loop()\n        outbound_sock = create_dualstack_udp_socket(\"0.0.0.0\", 0)\n        outbound_sock.setblocking(False)\n        transport, _ = await loop.create_datagram_endpoint(\n            lambda: _UdpProtocol(self._on_remote_data),\n            sock=outbound_sock,\n        )\n        self._transport = transport\n        sock = transport.get_extra_info(\"socket\")\n        sockname = sock.getsockname() if sock else (\"::\", 0)\n        self._bind_addr = Address(sockname[0], sockname[1])\n        self._ttl_task = asyncio.create_task(self._ttl_cleanup_loop())\n        return self._bind_addr\n\n    def set_client_transport(self, transport: asyncio.DatagramTransport) -> None:\n        self._client_transport = transport\n\n    async def stop(self) -> None:\n        if self._ttl_task:\n            self._ttl_task.cancel()\n            try:\n                await self._ttl_task\n            except asyncio.CancelledError:\n                pass\n        if self._transport:\n            self._transport.close()\n\n    def handle_client_datagram(self, data: bytes, client_addr: tuple[str, int]) -> None:\n        try:\n            dst, _, payload = parse_udp_header(data)\n        except Exception:\n            return\n\n        if not payload:\n            return\n\n        remote_key = (dst.host, dst.port)\n        self._route_map[remote_key] = client_addr\n        self._route_timestamps[remote_key] = time.monotonic()\n\n        if self._transport:\n            self._transport.sendto(payload, _map_addr_for_sendto(dst.host, dst.port))\n            self._flow.bytes_up += len(payload)\n\n    def _on_remote_data(self, data: bytes, remote_addr: tuple[str, int]) -> None:\n        self._flow.bytes_down += len(data)\n        remote_key = (_normalize_host(remote_addr[0]), remote_addr[1])\n        client_addr = self._route_map.get(remote_key)\n        if client_addr is None:\n            return\n\n        self._route_timestamps[remote_key] = time.monotonic()\n\n        # Build SOCKS5 UDP reply header\n        src_addr = Address(_normalize_host(remote_addr[0]), remote_addr[1])\n        header = build_udp_header(src_addr)\n        packet = header + data\n\n        if self._client_transport:\n            self._client_transport.sendto(packet, client_addr)\n\n    async def _ttl_cleanup_loop(self) -> None:\n        while True:\n            await asyncio.sleep(60)\n            now = time.monotonic()\n            expired = [\n                key\n                for key, ts in self._route_timestamps.items()\n                if now - ts > self._ttl\n            ]\n            for key in expired:\n                self._route_map.pop(key, None)\n                self._route_timestamps.pop(key, None)\n\n\nclass _UdpProtocol(asyncio.DatagramProtocol):\n    def __init__(self, on_data: Callable[[bytes, tuple[str, int]], None]) -> None:\n        self._on_data = on_data\n        self._transport: asyncio.DatagramTransport | None = None\n\n    def connection_made(self, transport: asyncio.DatagramTransport) -> None:\n        self._transport = transport\n\n    def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:\n        self._on_data(data, addr)\n\n    def error_received(self, exc: Exception) -> None:\n        pass\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\n\n\n@pytest.fixture\nasync def echo_server():\n    \"\"\"TCP echo server for testing.\"\"\"\n\n    async def handler(reader, writer):\n        try:\n            while True:\n                data = await reader.read(4096)\n                if not data:\n                    break\n                writer.write(data)\n                await writer.drain()\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n    yield Address(addr[0], addr[1])\n    srv.close()\n    await srv.wait_closed()\n\n\n@pytest.fixture\nasync def udp_echo_server():\n    \"\"\"UDP echo server for testing.\"\"\"\n    received = []\n\n    class Protocol(asyncio.DatagramProtocol):\n        def connection_made(self, transport):\n            self.transport = transport\n\n        def datagram_received(self, data, addr):\n            received.append((data, addr))\n            self.transport.sendto(data, addr)\n\n    loop = asyncio.get_running_loop()\n    transport, _ = await loop.create_datagram_endpoint(\n        Protocol, local_addr=(\"127.0.0.1\", 0)\n    )\n    sock = transport.get_extra_info(\"socket\")\n    sockname = sock.getsockname() if sock else (\"127.0.0.1\", 0)\n    yield Address(sockname[0], sockname[1]), received\n    transport.close()\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n"
  },
  {
    "path": "tests/e2e_helpers.py",
    "content": "import asyncio\nimport ipaddress\nimport struct\n\nfrom asyncio_socks_server.core.address import encode_address\nfrom asyncio_socks_server.core.types import Address\n\n\nasync def socks5_connect(\n    proxy: Address,\n    target: Address,\n    auth: tuple[str, str] | None = None,\n) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:\n    reader, writer = await asyncio.open_connection(proxy.host, proxy.port)\n\n    writer.write(b\"\\x05\\x01\\x02\" if auth else b\"\\x05\\x01\\x00\")\n    await writer.drain()\n\n    resp = await reader.readexactly(2)\n    assert resp[0] == 0x05\n\n    if auth is None:\n        assert resp[1] == 0x00\n    else:\n        assert resp[1] == 0x02\n        username, password = auth\n        uname = username.encode()\n        passwd = password.encode()\n        writer.write(\n            b\"\\x01\"\n            + len(uname).to_bytes(1, \"big\")\n            + uname\n            + len(passwd).to_bytes(1, \"big\")\n            + passwd\n        )\n        await writer.drain()\n        assert await reader.readexactly(2) == b\"\\x01\\x00\"\n\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target.host, target.port))\n    await writer.drain()\n    return reader, writer\n\n\nasync def read_socks_reply(reader: asyncio.StreamReader) -> bytes:\n    reply = await reader.readexactly(3)\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n    return reply\n\n\nasync def open_udp_associate(\n    proxy: Address,\n) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, Address]:\n    reader, writer = await asyncio.open_connection(proxy.host, proxy.port)\n\n    writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n    assert await reader.readexactly(2) == b\"\\x05\\x00\"\n\n    writer.write(b\"\\x05\\x03\\x00\" + encode_address(\"0.0.0.0\", 0))\n    await writer.drain()\n\n    reply = await reader.readexactly(3)\n    assert reply[1] == 0x00\n\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        bind_host = ipaddress.IPv4Address(await reader.readexactly(4)).compressed\n    elif atyp == 0x04:\n        bind_host = str(ipaddress.IPv6Address(await reader.readexactly(16)))\n    else:\n        length = (await reader.readexactly(1))[0]\n        bind_host = (await reader.readexactly(length)).decode(\"ascii\")\n\n    bind_port = struct.unpack(\"!H\", await reader.readexactly(2))[0]\n    return reader, writer, Address(bind_host, bind_port)\n"
  },
  {
    "path": "tests/test_addon_builtins.py",
    "content": "import json\n\nimport pytest\n\nfrom asyncio_socks_server.addons.auth import FileAuth\nfrom asyncio_socks_server.addons.ip_filter import IPFilter\nfrom asyncio_socks_server.addons.logger import Logger\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 0),\n        dst=Address(\"0.0.0.0\", 0),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\nclass TestFileAuth:\n    async def test_valid_credentials(self, tmp_path):\n        cred_file = tmp_path / \"creds.json\"\n        cred_file.write_text(json.dumps({\"admin\": \"secret\", \"user\": \"pass\"}))\n\n        auth = FileAuth(cred_file)\n        assert await auth.on_auth(\"admin\", \"secret\") is True\n        assert await auth.on_auth(\"admin\", \"wrong\") is False\n\n    async def test_unknown_user(self, tmp_path):\n        cred_file = tmp_path / \"creds.json\"\n        cred_file.write_text(json.dumps({\"admin\": \"secret\"}))\n\n        auth = FileAuth(cred_file)\n        assert await auth.on_auth(\"unknown\", \"any\") is None\n\n    async def test_missing_file(self, tmp_path):\n        auth = FileAuth(tmp_path / \"nonexistent.json\")\n        assert await auth.on_auth(\"any\", \"any\") is None\n\n\nclass TestIPFilter:\n    async def test_blocked(self):\n        f = IPFilter(blocked=[\"10.0.0.0/8\", \"192.168.1.1\"])\n        # 172.16.0.1 is NOT blocked → returns None\n        result = await f.on_connect(_make_flow(src=Address(\"172.16.0.1\", 0)))\n        assert result is None\n        # 10.0.0.5 IS blocked → raises\n        with pytest.raises(ConnectionRefusedError):\n            await f.on_connect(_make_flow(src=Address(\"10.0.0.5\", 0)))\n\n    async def test_allowed_list(self):\n        f = IPFilter(allowed=[\"127.0.0.0/8\"])\n        # 127.0.0.1 should be allowed (returns None)\n        result = await f.on_connect(_make_flow())\n        assert result is None\n\n    async def test_not_in_allowed(self):\n        f = IPFilter(allowed=[\"127.0.0.0/8\"])\n        with pytest.raises(ConnectionRefusedError):\n            await f.on_connect(_make_flow(src=Address(\"10.0.0.1\", 0)))\n\n    async def test_no_rules(self):\n        f = IPFilter()\n        result = await f.on_connect(_make_flow(src=Address(\"10.0.0.1\", 0)))\n        assert result is None\n\n\nclass TestLogger:\n    async def test_on_connect(self):\n        logger = Logger()\n        result = await logger.on_connect(\n            _make_flow(src=Address(\"127.0.0.1\", 1080), dst=Address(\"example.com\", 80))\n        )\n        assert result is None\n\n    async def test_on_data(self):\n        logger = Logger()\n        flow = _make_flow()\n        result = await logger.on_data(Direction.UPSTREAM, b\"hello\", flow)\n        assert result == b\"hello\"\n\n    async def test_on_error(self):\n        logger = Logger()\n        await logger.on_error(ValueError(\"test\"))\n"
  },
  {
    "path": "tests/test_addon_builtins_extended.py",
    "content": "\"\"\"Extended tests for built-in addons: FileAuth, IPFilter, Logger.\"\"\"\n\nimport json\n\nimport pytest\n\nfrom asyncio_socks_server.addons.auth import FileAuth\nfrom asyncio_socks_server.addons.ip_filter import IPFilter\nfrom asyncio_socks_server.addons.logger import Logger\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 0),\n        dst=Address(\"0.0.0.0\", 0),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\nclass TestFileAuthExtended:\n    async def test_corrupted_json_file(self, tmp_path):\n        bad_file = tmp_path / \"auth.json\"\n        bad_file.write_text(\"not json at all {{{\")\n        auth = FileAuth(str(bad_file))\n        result = await auth.on_auth(\"user\", \"pass\")\n        assert result is None\n\n    async def test_empty_json_file(self, tmp_path):\n        empty_file = tmp_path / \"auth.json\"\n        empty_file.write_text(\"{}\")\n        auth = FileAuth(str(empty_file))\n        result = await auth.on_auth(\"user\", \"pass\")\n        assert result is None\n\n    async def test_credentials_cached_after_first_load(self, tmp_path):\n        auth_file = tmp_path / \"auth.json\"\n        auth_file.write_text(json.dumps({\"user\": \"pass\"}))\n        auth = FileAuth(str(auth_file))\n\n        # First load\n        result = await auth.on_auth(\"user\", \"pass\")\n        assert result is True\n\n        # Modify file\n        auth_file.write_text(\"{}\")\n\n        # Should still use cached credentials\n        result = await auth.on_auth(\"user\", \"pass\")\n        assert result is True\n\n    async def test_unicode_credentials(self, tmp_path):\n        auth_file = tmp_path / \"auth.json\"\n        auth_file.write_text(json.dumps({\"用户\": \"密码\"}))\n        auth = FileAuth(str(auth_file))\n        result = await auth.on_auth(\"用户\", \"密码\")\n        assert result is True\n\n    async def test_unknown_user(self, tmp_path):\n        auth_file = tmp_path / \"auth.json\"\n        auth_file.write_text(json.dumps({\"admin\": \"secret\"}))\n        auth = FileAuth(str(auth_file))\n        result = await auth.on_auth(\"unknown\", \"pass\")\n        assert result is None\n\n\nclass TestIPFilterExtended:\n    async def test_ipv6_blocked(self):\n        filt = IPFilter(blocked=[\"::1/128\"])\n        with pytest.raises(ConnectionRefusedError, match=\"IP blocked\"):\n            await filt.on_connect(\n                _make_flow(src=Address(\"::1\", 1234), dst=Address(\"1.2.3.4\", 80))\n            )\n\n    async def test_ipv6_allowed(self):\n        filt = IPFilter(allowed=[\"::1/128\", \"127.0.0.1/32\"])\n        # 127.0.0.1 should be allowed (returns None)\n        result = await filt.on_connect(\n            _make_flow(src=Address(\"127.0.0.1\", 1234), dst=Address(\"1.2.3.4\", 80))\n        )\n        assert result is None\n\n    async def test_domain_source_falls_back(self):\n        filt = IPFilter(blocked=[\"10.0.0.0/8\"])\n        # Domain source host — ip_address will raise ValueError\n        try:\n            await filt.on_connect(\n                _make_flow(src=Address(\"example.com\", 1234), dst=Address(\"1.2.3.4\", 80))\n            )\n        except (ValueError, ConnectionRefusedError):\n            pass  # Either is acceptable\n\n    async def test_empty_rules(self):\n        filt = IPFilter()\n        # No rules → nothing blocked, should return None\n        result = await filt.on_connect(\n            _make_flow(src=Address(\"10.0.0.1\", 1234), dst=Address(\"1.2.3.4\", 80))\n        )\n        assert result is None\n\n\nclass TestLoggerExtended:\n    async def test_on_data_returns_data_passthrough(self):\n        logger = Logger()\n        flow = _make_flow()\n        result = await logger.on_data(Direction.UPSTREAM, b\"test\", flow)\n        assert result == b\"test\"\n\n    async def test_on_connect_returns_none(self):\n        logger = Logger()\n        result = await logger.on_connect(\n            _make_flow(src=Address(\"1.2.3.4\", 1234), dst=Address(\"5.6.7.8\", 80))\n        )\n        assert result is None\n\n    async def test_on_error_does_not_raise(self):\n        logger = Logger()\n        await logger.on_error(RuntimeError(\"test\"))\n        await logger.on_error(ConnectionError(\"test\"))\n        await logger.on_error(ValueError(\"test\"))\n"
  },
  {
    "path": "tests/test_addon_chain.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.addons.chain import ChainRouter\nfrom asyncio_socks_server.client.client import connect\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\n@pytest.fixture\nasync def echo_server():\n    async def handler(reader, writer):\n        try:\n            while True:\n                data = await reader.read(4096)\n                if not data:\n                    break\n                writer.write(data)\n                await writer.drain()\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n    yield Address(addr[0], addr[1])\n    srv.close()\n    await srv.wait_closed()\n\n\nclass TestChainRouter:\n    async def test_two_hop_chain(self, echo_server):\n        # Exit node: direct to target\n        exit_server, exit_task = await _start_server()\n\n        # Entry node: routes through exit node\n        chain_addon = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[chain_addon])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port),\n                echo_server,\n            )\n            conn.writer.write(b\"through the chain\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"through the chain\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n    async def test_chain_with_auth(self, echo_server):\n        exit_server, exit_task = await _start_server(auth=(\"proxy\", \"secret\"))\n\n        chain_addon = ChainRouter(\n            next_hop=f\"127.0.0.1:{exit_server.port}\",\n            username=\"proxy\",\n            password=\"secret\",\n        )\n        entry_server, entry_task = await _start_server(addons=[chain_addon])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port),\n                echo_server,\n            )\n            conn.writer.write(b\"auth chain\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"auth chain\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n"
  },
  {
    "path": "tests/test_addon_edge_cases.py",
    "content": "\"\"\"Addon dispatch edge cases: competitive, pipeline, exceptions.\"\"\"\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 0),\n        dst=Address(\"0.0.0.0\", 0),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\nclass ConnectReturning(Addon):\n    def __init__(self, value=None):\n        self._value = value\n\n    async def on_connect(self, flow):\n        return self._value\n\n\nclass TrackingAddon(Addon):\n    def __init__(self, name, calls):\n        self._name = name\n        self._calls = calls\n\n    async def on_start(self):\n        self._calls.append(f\"{self._name}:on_start\")\n\n    async def on_stop(self):\n        self._calls.append(f\"{self._name}:on_stop\")\n\n\nclass DataTransform(Addon):\n    def __init__(self, transform_fn):\n        self._fn = transform_fn\n\n    async def on_data(self, direction, data, flow):\n        return self._fn(data)\n\n\nclass ErrorRaiser(Addon):\n    def __init__(self, raise_on_error=False):\n        self._raise_on_error = raise_on_error\n        self.errors = []\n\n    async def on_error(self, error):\n        self.errors.append(error)\n        if self._raise_on_error:\n            raise RuntimeError(\"addon error\")\n\n\nclass AuthAddon(Addon):\n    def __init__(self, result):\n        self._result = result\n\n    async def on_auth(self, username, password):\n        return self._result\n\n\nclass TestCompetitiveConnect:\n    async def test_first_addon_returns_connection(self):\n        # Use a simple object as Connection proxy\n        sentinel = object()\n        a1 = ConnectReturning(value=sentinel)\n        a2 = ConnectReturning(value=None)\n        manager = AddonManager([a1, a2])\n        result = await manager.dispatch_connect(\n            _make_flow(src=Address(\"1.2.3.4\", 0), dst=Address(\"5.6.7.8\", 80))\n        )\n        assert result is sentinel\n\n    async def test_second_addon_returns_connection(self):\n        sentinel = object()\n        a1 = ConnectReturning(value=None)\n        a2 = ConnectReturning(value=sentinel)\n        manager = AddonManager([a1, a2])\n        result = await manager.dispatch_connect(\n            _make_flow(src=Address(\"1.2.3.4\", 0), dst=Address(\"5.6.7.8\", 80))\n        )\n        assert result is sentinel\n\n    async def test_no_addon_returns_connection(self):\n        a1 = ConnectReturning(value=None)\n        a2 = ConnectReturning(value=None)\n        manager = AddonManager([a1, a2])\n        result = await manager.dispatch_connect(\n            _make_flow(src=Address(\"1.2.3.4\", 0), dst=Address(\"5.6.7.8\", 80))\n        )\n        assert result is None\n\n\nclass TestPipelineEdgeCases:\n    async def test_pipeline_with_intermediate_none(self):\n        call_log = []\n\n        class LogAddon(Addon):\n            def __init__(self, name, ret):\n                self._name = name\n                self._ret = ret\n\n            async def on_data(self, direction, data, flow):\n                call_log.append(self._name)\n                return self._ret\n\n        # First transforms, second returns None (drops), third should NOT be called\n        manager = AddonManager(\n            [\n                LogAddon(\"upper\", b\"HELLO\"),\n                LogAddon(\"drop\", None),\n                LogAddon(\"lower\", b\"hello\"),\n            ]\n        )\n        result = await manager.dispatch_data(Direction.UPSTREAM, b\"hello\", _make_flow())\n        assert result is None\n        assert call_log == [\"upper\", \"drop\"]\n\n    async def test_pipeline_empty_bytes(self):\n        received = []\n\n        class Capture(Addon):\n            async def on_data(self, direction, data, flow):\n                received.append(data)\n                return data\n\n        manager = AddonManager([Capture()])\n        result = await manager.dispatch_data(Direction.UPSTREAM, b\"\", _make_flow())\n        assert result == b\"\"\n        assert received == [b\"\"]\n\n\nclass TestAddonExceptions:\n    async def test_auth_addon_raises_exception(self):\n        class FailAuth(Addon):\n            async def on_auth(self, username, password):\n                raise PermissionError(\"blocked\")\n\n        manager = AddonManager([FailAuth()])\n        try:\n            await manager.dispatch_auth(\"user\", \"pass\")\n            assert False, \"Should have raised\"\n        except PermissionError:\n            pass\n\n    async def test_data_addon_raises_exception(self):\n        class FailData(Addon):\n            async def on_data(self, direction, data, flow):\n                raise ValueError(\"bad data\")\n\n        manager = AddonManager([FailData()])\n        try:\n            await manager.dispatch_data(Direction.UPSTREAM, b\"test\", _make_flow())\n            assert False, \"Should have raised\"\n        except ValueError:\n            pass\n\n    async def test_error_addon_exception_suppressed(self):\n        a1 = ErrorRaiser(raise_on_error=True)\n        a2 = ErrorRaiser()\n        manager = AddonManager([a1, a2])\n        # Should not raise even though a1 raises in on_error\n        await manager.dispatch_error(RuntimeError(\"test\"))\n        # a2 should still have been called\n        assert len(a2.errors) == 1\n\n    async def test_error_addon_all_called(self):\n        a1 = ErrorRaiser()\n        a2 = ErrorRaiser()\n        a3 = ErrorRaiser()\n        manager = AddonManager([a1, a2, a3])\n        err = RuntimeError(\"test\")\n        await manager.dispatch_error(err)\n        assert len(a1.errors) == 1\n        assert len(a2.errors) == 1\n        assert len(a3.errors) == 1\n\n\nclass TestLifecycleOrder:\n    async def test_multiple_addons_start_stop_order(self):\n        calls = []\n        a1 = TrackingAddon(\"a1\", calls)\n        a2 = TrackingAddon(\"a2\", calls)\n        a3 = TrackingAddon(\"a3\", calls)\n        manager = AddonManager([a1, a2, a3])\n\n        await manager.dispatch_start()\n        assert calls == [\"a1:on_start\", \"a2:on_start\", \"a3:on_start\"]\n\n        calls.clear()\n        await manager.dispatch_stop()\n        assert calls == [\"a1:on_stop\", \"a2:on_stop\", \"a3:on_stop\"]\n\n    async def test_addon_with_only_data_override(self):\n        class DataOnly(Addon):\n            async def on_data(self, direction, data, flow):\n                return data\n\n        manager = AddonManager([DataOnly()])\n        # auth should return None (not overridden)\n        result = await manager.dispatch_auth(\"user\", \"pass\")\n        assert result is None\n        # connect should return None\n        result = await manager.dispatch_connect(\n            _make_flow(src=Address(\"a\", 1), dst=Address(\"b\", 2))\n        )\n        assert result is None\n        # data should pass through\n        result = await manager.dispatch_data(Direction.UPSTREAM, b\"test\", _make_flow())\n        assert result == b\"test\"\n\n\nclass TestCompetitiveAuth:\n    async def test_first_auth_wins_true(self):\n        manager = AddonManager([AuthAddon(True), AuthAddon(False)])\n        result = await manager.dispatch_auth(\"user\", \"pass\")\n        assert result is True\n\n    async def test_first_auth_wins_false(self):\n        manager = AddonManager([AuthAddon(False), AuthAddon(True)])\n        result = await manager.dispatch_auth(\"user\", \"pass\")\n        assert result is False\n\n    async def test_all_none_passes_through(self):\n        manager = AddonManager([AuthAddon(None), AuthAddon(None)])\n        result = await manager.dispatch_auth(\"user\", \"pass\")\n        assert result is None\n"
  },
  {
    "path": "tests/test_addon_manager.py",
    "content": "from asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 0),\n        dst=Address(\"0.0.0.0\", 0),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\nclass LifeCycleAddon(Addon):\n    def __init__(self):\n        self.started = False\n        self.stopped = False\n\n    async def on_start(self):\n        self.started = True\n\n    async def on_stop(self):\n        self.stopped = True\n\n\nclass TestLifecycle:\n    async def test_start_stop(self):\n        addon = LifeCycleAddon()\n        mgr = AddonManager([addon])\n        await mgr.dispatch_start()\n        assert addon.started\n        await mgr.dispatch_stop()\n        assert addon.stopped\n\n    async def test_empty_manager(self):\n        mgr = AddonManager([])\n        await mgr.dispatch_start()\n        await mgr.dispatch_stop()\n\n    async def test_base_addon_skipped(self):\n        mgr = AddonManager([Addon()])\n        await mgr.dispatch_start()  # should not raise\n\n\nclass AuthAllow(Addon):\n    async def on_auth(self, username, password):\n        return True\n\n\nclass AuthDeny(Addon):\n    async def on_auth(self, username, password):\n        return False\n\n\nclass AuthPass(Addon):\n    async def on_auth(self, username, password):\n        return None\n\n\nclass TestCompetitiveAuth:\n    async def test_first_allow_wins(self):\n        mgr = AddonManager([AuthAllow(), AuthDeny()])\n        result = await mgr.dispatch_auth(\"user\", \"pass\")\n        assert result is True\n\n    async def test_first_deny_wins(self):\n        mgr = AddonManager([AuthDeny(), AuthAllow()])\n        result = await mgr.dispatch_auth(\"user\", \"pass\")\n        assert result is False\n\n    async def test_all_pass(self):\n        mgr = AddonManager([AuthPass(), AuthPass()])\n        result = await mgr.dispatch_auth(\"user\", \"pass\")\n        assert result is None\n\n    async def test_passthrough_then_allow(self):\n        mgr = AddonManager([AuthPass(), AuthAllow()])\n        result = await mgr.dispatch_auth(\"user\", \"pass\")\n        assert result is True\n\n\nclass UpperAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return data.upper()\n\n\nclass AppendAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return data + b\"!\"\n\n\nclass DropAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return None\n\n\nclass TestPipelineData:\n    async def test_single_transform(self):\n        mgr = AddonManager([UpperAddon()])\n        result = await mgr.dispatch_data(Direction.UPSTREAM, b\"hello\", _make_flow())\n        assert result == b\"HELLO\"\n\n    async def test_chain_transforms(self):\n        mgr = AddonManager([UpperAddon(), AppendAddon()])\n        result = await mgr.dispatch_data(Direction.UPSTREAM, b\"hello\", _make_flow())\n        assert result == b\"HELLO!\"\n\n    async def test_drop_stops_pipeline(self):\n        mgr = AddonManager([DropAddon(), UpperAddon()])\n        result = await mgr.dispatch_data(Direction.UPSTREAM, b\"hello\", _make_flow())\n        assert result is None\n\n    async def test_no_addons(self):\n        mgr = AddonManager([])\n        result = await mgr.dispatch_data(Direction.UPSTREAM, b\"hello\", _make_flow())\n        assert result == b\"hello\"\n\n\nclass ErrorAddon(Addon):\n    def __init__(self):\n        self.errors: list[Exception] = []\n\n    async def on_error(self, error):\n        self.errors.append(error)\n\n\nclass ErrorRaisingAddon(Addon):\n    async def on_error(self, error):\n        raise RuntimeError(\"observer crashed\")\n\n\nclass TestObservationalError:\n    async def test_all_called(self):\n        a1 = ErrorAddon()\n        a2 = ErrorAddon()\n        mgr = AddonManager([a1, a2])\n        err = ValueError(\"test\")\n        await mgr.dispatch_error(err)\n        assert len(a1.errors) == 1\n        assert len(a2.errors) == 1\n\n    async def test_exception_doesnt_propagate(self):\n        a1 = ErrorRaisingAddon()\n        a2 = ErrorAddon()\n        mgr = AddonManager([a1, a2])\n        await mgr.dispatch_error(ValueError(\"test\"))\n        assert len(a2.errors) == 1  # second addon still called\n"
  },
  {
    "path": "tests/test_addon_stats.py",
    "content": "import asyncio\nimport json\n\nfrom asyncio_socks_server import (\n    Address,\n    FlowAudit,\n    FlowStats,\n    Server,\n    StatsAPI,\n    StatsServer,\n    connect,\n)\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nasync def _get_json(port: int, path: str):\n    return await _request_json(port, \"GET\", path)\n\n\nasync def _request_json(port: int, method: str, path: str):\n    reader, writer = await asyncio.open_connection(\"127.0.0.1\", port)\n    writer.write(f\"{method} {path} HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\n\\r\\n\".encode(\"ascii\"))\n    await writer.drain()\n    data = await reader.read()\n    writer.close()\n    await writer.wait_closed()\n\n    header, body = data.split(b\"\\r\\n\\r\\n\", 1)\n    status = int(header.split(b\" \", 2)[1])\n    return status, json.loads(body)\n\n\nclass TestStatsServer:\n    async def test_flow_stats_has_no_network_side_effects(self, echo_server):\n        stats = FlowStats()\n        server, task = await _start_server(addons=[stats])\n        conn = None\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"flowstats\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"flowstats\"\n\n            payload = stats.snapshot()\n            assert payload[\"active_flows\"] == 1\n            assert payload[\"active_bytes_up\"] == 9\n            assert payload[\"active_bytes_down\"] == 9\n            assert payload[\"active\"][0][\"started_at\"].endswith(\"Z\")\n        finally:\n            if conn is not None:\n                conn.writer.close()\n                await conn.writer.wait_closed()\n            await _stop_server(server, task)\n\n    async def test_health_endpoint(self):\n        stats = StatsAPI()\n        server, task = await _start_server(addons=[stats])\n        try:\n            status, payload = await _get_json(stats.port, \"/health\")\n            assert status == 200\n            assert payload == {\"ok\": True}\n        finally:\n            await _stop_server(server, task)\n\n    async def test_stats_api_can_present_external_flow_stats_without_double_counting(\n        self,\n        echo_server,\n    ):\n        stats = FlowStats()\n        api = StatsAPI(stats=stats)\n        server, task = await _start_server(addons=[stats, api])\n        conn = None\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"external\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"external\"\n\n            status, payload = await _get_json(api.port, \"/stats\")\n            assert status == 200\n            assert payload[\"total_flows\"] == 1\n            assert stats.snapshot()[\"total_flows\"] == 1\n        finally:\n            if conn is not None:\n                conn.writer.close()\n                await conn.writer.wait_closed()\n            await _stop_server(server, task)\n\n    async def test_errors_endpoint(self):\n        stats = StatsAPI()\n        await stats.on_error(RuntimeError(\"boom\"))\n        server, task = await _start_server(addons=[stats])\n        try:\n            status, payload = await _get_json(stats.port, \"/errors\")\n            assert status == 200\n            assert payload[\"total\"] == 1\n            assert payload[\"by_type\"] == {\"RuntimeError\": 1}\n            assert payload[\"recent\"][0][\"message\"] == \"boom\"\n        finally:\n            await _stop_server(server, task)\n\n    async def test_flow_audit_has_no_network_side_effects(self, echo_server):\n        audit = FlowAudit()\n        server, task = await _start_server(addons=[audit])\n        conn = None\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"audit\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"audit\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n            await asyncio.sleep(0.05)\n\n            payload = audit.snapshot()\n            assert payload[\"status\"] == \"ready\"\n            assert payload[\"records\"] == 1\n            assert payload[\"total\"] == {\"upload\": 5, \"download\": 5, \"total\": 10}\n            assert payload[\"devices\"][0][\"total\"] == 10\n            assert payload[\"traffic\"][0][\"total\"] == 10\n            assert payload[\"recent\"][0][\"started_at\"].endswith(\"Z\")\n        finally:\n            if conn is not None and not conn.writer.is_closing():\n                conn.writer.close()\n                await conn.writer.wait_closed()\n            await _stop_server(server, task)\n\n    async def test_stats_api_exposes_flow_audit(self, echo_server):\n        audit = FlowAudit()\n        api = StatsAPI(audit=audit)\n        server, task = await _start_server(addons=[audit, api])\n        conn = None\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"audit-api\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"audit-api\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n            await asyncio.sleep(0.05)\n\n            status, payload = await _get_json(api.port, \"/audit?top=1\")\n            assert status == 200\n            assert payload[\"records\"] == 1\n            assert len(payload[\"devices\"]) == 1\n            assert len(payload[\"traffic\"]) == 1\n\n            status, payload = await _request_json(api.port, \"POST\", \"/audit/refresh\")\n            assert status == 200\n            assert payload[\"records\"] == 1\n        finally:\n            if conn is not None and not conn.writer.is_closing():\n                conn.writer.close()\n                await conn.writer.wait_closed()\n            await _stop_server(server, task)\n\n    async def test_stats_api_reports_audit_disabled(self):\n        stats = StatsAPI()\n        server, task = await _start_server(addons=[stats])\n        try:\n            status, payload = await _get_json(stats.port, \"/audit\")\n            assert status == 404\n            assert payload == {\"error\": \"audit disabled\"}\n        finally:\n            await _stop_server(server, task)\n\n    async def test_tracks_active_and_closed_tcp_flows(self, echo_server):\n        stats = StatsServer()\n        server, task = await _start_server(addons=[stats])\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"stats\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"stats\"\n\n            status, payload = await _get_json(stats.port, \"/stats\")\n            assert status == 200\n            assert payload[\"started_at\"].endswith(\"Z\")\n            assert payload[\"active_flows\"] == 1\n            assert payload[\"closed_flows\"] == 0\n            assert payload[\"total_closed_flows\"] == 0\n            assert payload[\"total_flows\"] == 1\n            assert payload[\"total_tcp_flows\"] == 1\n            assert payload[\"active_bytes_up\"] == 5\n            assert payload[\"active_bytes_down\"] == 5\n            assert payload[\"total_bytes_up\"] == 5\n            assert payload[\"total_bytes_down\"] == 5\n            assert payload[\"upload_rate\"] >= 0\n            assert payload[\"download_rate\"] >= 0\n            assert payload[\"errors\"] == {\"total\": 0, \"by_type\": {}, \"recent\": []}\n            assert payload[\"active\"][0][\"started_at\"].endswith(\"Z\")\n            assert payload[\"active\"][0][\"bytes_up\"] == 5\n            assert payload[\"active\"][0][\"bytes_down\"] == 5\n            assert payload[\"active\"][0][\"upload_rate\"] >= 0\n            assert payload[\"active\"][0][\"download_rate\"] >= 0\n\n            conn.writer.close()\n            await conn.writer.wait_closed()\n            await asyncio.sleep(0.05)\n\n            status, flows = await _get_json(stats.port, \"/flows\")\n            assert status == 200\n            assert flows[\"active\"] == []\n            assert len(flows[\"recent_closed\"]) == 1\n            assert flows[\"recent_closed\"][0][\"bytes_up\"] == 5\n            assert flows[\"recent_closed\"][0][\"bytes_down\"] == 5\n\n            snapshot = stats.snapshot()\n            assert snapshot[\"active_flows\"] == 0\n            assert snapshot[\"closed_flows\"] == 1\n            assert snapshot[\"total_closed_flows\"] == 1\n            assert snapshot[\"closed_bytes_up\"] == 5\n            assert snapshot[\"closed_bytes_down\"] == 5\n            assert snapshot[\"total_bytes_up\"] == 5\n            assert snapshot[\"total_bytes_down\"] == 5\n        finally:\n            await _stop_server(server, task)\n\n    async def test_tracks_errors(self):\n        stats = StatsServer()\n        await stats.on_error(RuntimeError(\"boom\"))\n\n        snapshot = stats.snapshot()\n        assert snapshot[\"errors\"][\"total\"] == 1\n        assert snapshot[\"errors\"][\"by_type\"] == {\"RuntimeError\": 1}\n        assert snapshot[\"errors\"][\"recent\"][0][\"type\"] == \"RuntimeError\"\n        assert snapshot[\"errors\"][\"recent\"][0][\"message\"] == \"boom\"\n\n    async def test_not_found(self):\n        stats = StatsServer()\n        server, task = await _start_server(addons=[stats])\n        try:\n            status, payload = await _get_json(stats.port, \"/missing\")\n            assert status == 404\n            assert payload == {\"error\": \"not found\"}\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_cli.py",
    "content": "\"\"\"Tests for CLI argument parsing.\"\"\"\n\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom asyncio_socks_server.cli import main\n\n\nclass TestCliArgs:\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_default_values(self, mock_server_cls):\n        with pytest.raises(SystemExit):\n            # argparse exits on --help\n            with patch(\"sys.argv\", [\"asyncio_socks_server\", \"--help\"]):\n                main()\n\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_custom_host_port(self, mock_server_cls):\n        mock_instance = MagicMock()\n        mock_server_cls.return_value = mock_instance\n        with patch(\n            \"sys.argv\",\n            [\"asyncio_socks_server\", \"--host\", \"127.0.0.1\", \"--port\", \"9050\"],\n        ):\n            main()\n        mock_server_cls.assert_called_once_with(\n            host=\"127.0.0.1\", port=9050, auth=None, log_level=\"INFO\"\n        )\n        mock_instance.run.assert_called_once()\n\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_auth_parsing(self, mock_server_cls):\n        mock_instance = MagicMock()\n        mock_server_cls.return_value = mock_instance\n        with patch(\"sys.argv\", [\"asyncio_socks_server\", \"--auth\", \"user:pass\"]):\n            main()\n        mock_server_cls.assert_called_once_with(\n            host=\"::\", port=1080, auth=(\"user\", \"pass\"), log_level=\"INFO\"\n        )\n\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_auth_with_colon_in_password(self, mock_server_cls):\n        mock_instance = MagicMock()\n        mock_server_cls.return_value = mock_instance\n        with patch(\"sys.argv\", [\"asyncio_socks_server\", \"--auth\", \"user:pass:word\"]):\n            main()\n        mock_server_cls.assert_called_once_with(\n            host=\"::\", port=1080, auth=(\"user\", \"pass:word\"), log_level=\"INFO\"\n        )\n\n    def test_invalid_log_level(self):\n        with pytest.raises(SystemExit):\n            with patch(\"sys.argv\", [\"asyncio_socks_server\", \"--log-level\", \"INVALID\"]):\n                main()\n\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_debug_log_level(self, mock_server_cls):\n        mock_instance = MagicMock()\n        mock_server_cls.return_value = mock_instance\n        with patch(\"sys.argv\", [\"asyncio_socks_server\", \"--log-level\", \"DEBUG\"]):\n            main()\n        mock_server_cls.assert_called_once_with(\n            host=\"::\", port=1080, auth=None, log_level=\"DEBUG\"\n        )\n\n    @patch(\"asyncio_socks_server.cli.Server\")\n    def test_no_auth_flag(self, mock_server_cls):\n        mock_instance = MagicMock()\n        mock_server_cls.return_value = mock_instance\n        with patch(\"sys.argv\", [\"asyncio_socks_server\"]):\n            main()\n        call_kwargs = mock_server_cls.call_args[1]\n        assert call_kwargs[\"auth\"] is None\n"
  },
  {
    "path": "tests/test_client.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.client.client import connect\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\n@pytest.fixture\nasync def echo_server():\n    async def handler(reader, writer):\n        try:\n            while True:\n                data = await reader.read(4096)\n                if not data:\n                    break\n                writer.write(data)\n                await writer.drain()\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n    yield Address(addr[0], addr[1])\n    srv.close()\n    await srv.wait_closed()\n\n\nclass TestClientConnect:\n    async def test_no_auth(self, echo_server):\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"hello\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"hello\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_with_auth(self, echo_server):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            conn = await connect(\n                Address(server.host, server.port),\n                echo_server,\n                username=\"user\",\n                password=\"pass\",\n            )\n            conn.writer.write(b\"secret\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"secret\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_auth_failure(self, echo_server):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            from asyncio_socks_server.core.protocol import ProtocolError\n\n            with pytest.raises(ProtocolError, match=\"authentication failed\"):\n                await connect(\n                    Address(server.host, server.port),\n                    echo_server,\n                    username=\"user\",\n                    password=\"wrong\",\n                )\n        finally:\n            await _stop_server(server, task)\n\n    async def test_connection_refused(self):\n        server, task = await _start_server()\n        try:\n            with pytest.raises(Exception):\n                await connect(\n                    Address(server.host, server.port),\n                    Address(\"127.0.0.1\", 1),  # port 1 should refuse\n                )\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_client_edge_cases.py",
    "content": "\"\"\"Client edge cases: negotiation failures, unexpected responses.\"\"\"\n\nimport asyncio\nimport socket\n\nimport pytest\n\nfrom asyncio_socks_server.client.client import _happy_eyeballs_connect, connect\nfrom asyncio_socks_server.core.types import Address\n\n\nasync def _fake_socks_server(*responses):\n    \"\"\"Start a fake SOCKS server that sends predefined responses.\n\n    Returns (Address, close_event) where Address is the server's listen address.\n    \"\"\"\n    close_event = asyncio.Event()\n    received = []\n\n    async def handler(reader, writer):\n        try:\n            while not close_event.is_set():\n                try:\n                    data = await asyncio.wait_for(reader.read(4096), timeout=0.5)\n                    if not data:\n                        break\n                    received.append(data)\n                except asyncio.TimeoutError:\n                    continue\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n\n    return Address(addr[0], addr[1]), srv, close_event, received\n\n\nasync def _fake_socks_server_with_responses(responses):\n    \"\"\"Start a fake SOCKS server that sends specific byte sequences.\n\n    Each response is sent after receiving data from the client.\n    Returns (Address, server, close_event).\n    \"\"\"\n    close_event = asyncio.Event()\n    resp_idx = [0]\n\n    async def handler(reader, writer):\n        try:\n            while resp_idx[0] < len(responses):\n                try:\n                    data = await asyncio.wait_for(reader.read(4096), timeout=1.0)\n                    if not data:\n                        break\n                    if resp_idx[0] < len(responses):\n                        writer.write(responses[resp_idx[0]])\n                        await writer.drain()\n                        resp_idx[0] += 1\n                except asyncio.TimeoutError:\n                    break\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n    return Address(addr[0], addr[1]), srv, close_event\n\n\nclass TestClientNegotiationFailures:\n    async def test_proxy_returns_wrong_version(self):\n        # Reply with version 0x04 instead of 0x05\n        proxy_addr, srv, close = await _fake_socks_server_with_responses([b\"\\x04\\x00\"])\n        try:\n            with pytest.raises(Exception):\n                await connect(proxy_addr, Address(\"127.0.0.1\", 80))\n        finally:\n            close.set()\n            srv.close()\n            await srv.wait_closed()\n\n    async def test_proxy_returns_no_acceptable_method(self):\n        # Reply with 0xFF method (NO_ACCEPTABLE)\n        proxy_addr, srv, close = await _fake_socks_server_with_responses([b\"\\x05\\xff\"])\n        try:\n            with pytest.raises(Exception, match=\"no acceptable\"):\n                await connect(proxy_addr, Address(\"127.0.0.1\", 80))\n        finally:\n            close.set()\n            srv.close()\n            await srv.wait_closed()\n\n    async def test_connect_reply_failure(self):\n        # Method selection: accept NO_AUTH, then reply CONNECTION_REFUSED\n        proxy_addr, srv, close = await _fake_socks_server_with_responses(\n            [\n                b\"\\x05\\x00\",  # Method reply: NO_AUTH\n                # CONNECT reply: CONNECTION_REFUSED\n                b\"\\x05\\x05\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\\x50\",\n            ]\n        )\n        try:\n            with pytest.raises(Exception, match=\"refused|failed|CONNECT\"):\n                await connect(proxy_addr, Address(\"127.0.0.1\", 80))\n        finally:\n            close.set()\n            srv.close()\n            await srv.wait_closed()\n\n    async def test_connect_reply_wrong_version(self):\n        proxy_addr, srv, close = await _fake_socks_server_with_responses(\n            [\n                b\"\\x05\\x00\",  # Method reply OK\n                b\"\\x04\\x00\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\\x50\",  # Wrong version in reply\n            ]\n        )\n        try:\n            with pytest.raises(Exception):\n                await connect(proxy_addr, Address(\"127.0.0.1\", 80))\n        finally:\n            close.set()\n            srv.close()\n            await srv.wait_closed()\n\n\nclass TestClientConnectionFailures:\n    async def test_happy_eyeballs_falls_back_after_fast_first_failure(\n        self, monkeypatch\n    ):\n        loop = asyncio.get_running_loop()\n        attempts = []\n\n        async def fake_getaddrinfo(host, port, type):\n            return [\n                (\n                    socket.AF_INET6,\n                    socket.SOCK_STREAM,\n                    0,\n                    \"\",\n                    (\"2001:db8::1\", port, 0, 0),\n                ),\n                (\n                    socket.AF_INET,\n                    socket.SOCK_STREAM,\n                    0,\n                    \"\",\n                    (\"127.0.0.1\", port),\n                ),\n            ]\n\n        async def fake_open_connection(host, port):\n            attempts.append(host)\n            if host == \"2001:db8::1\":\n                raise OSError(\"ipv6 unavailable\")\n            return \"reader\", \"writer\"\n\n        monkeypatch.setattr(loop, \"getaddrinfo\", fake_getaddrinfo)\n        monkeypatch.setattr(asyncio, \"open_connection\", fake_open_connection)\n\n        result = await _happy_eyeballs_connect(Address(\"example.test\", 1080))\n\n        assert result == (\"reader\", \"writer\")\n        assert attempts == [\"2001:db8::1\", \"127.0.0.1\"]\n\n    async def test_connection_refused(self):\n        # Connect to a port that nobody is listening on\n        proxy_addr = Address(\"127.0.0.1\", 1)\n        with pytest.raises((ConnectionError, OSError)):\n            await connect(proxy_addr, Address(\"127.0.0.1\", 80))\n\n    async def test_auth_failure(self):\n        # Test auth failure via real server\n        from tests.conftest import _start_server, _stop_server\n\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            with pytest.raises(Exception, match=\"authentication failed\"):\n                await connect(\n                    Address(server.host, server.port),\n                    Address(\"127.0.0.1\", 80),\n                    username=\"user\",\n                    password=\"wrong\",\n                )\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_concurrent.py",
    "content": "\"\"\"Concurrency and stress tests.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server import TrafficCounter\nfrom asyncio_socks_server.core.types import Address\nfrom tests.conftest import _start_server, _stop_server\n\n\nasync def _socks5_proxy_connect(proxy: Address, target: Address):\n    \"\"\"Quick SOCKS5 CONNECT through proxy.\"\"\"\n    reader, writer = await asyncio.open_connection(proxy.host, proxy.port)\n    writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n    resp = await reader.readexactly(2)\n    assert resp == b\"\\x05\\x00\"\n\n    from asyncio_socks_server.core.address import encode_address\n\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target.host, target.port))\n    await writer.drain()\n    reply = await reader.readexactly(3)\n    assert reply[1] == 0x00\n\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n    return reader, writer\n\n\nclass TestConcurrentConnections:\n    async def test_20_simultaneous_connections(self, echo_server):\n        server, task = await _start_server()\n        try:\n            conns = await asyncio.gather(\n                *[\n                    _socks5_proxy_connect(\n                        Address(server.host, server.port), echo_server\n                    )\n                    for _ in range(20)\n                ]\n            )\n\n            # Send data on all\n            for r, w in conns:\n                w.write(b\"ping\")\n                await w.drain()\n\n            # Read all responses\n            for r, w in conns:\n                data = await asyncio.wait_for(r.read(4096), timeout=2.0)\n                assert data == b\"ping\"\n                w.close()\n                await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_concurrent_with_addon(self, echo_server):\n        counter = TrafficCounter()\n        server, task = await _start_server(addons=[counter])\n        try:\n            conns = await asyncio.gather(\n                *[\n                    _socks5_proxy_connect(\n                        Address(server.host, server.port), echo_server\n                    )\n                    for _ in range(10)\n                ]\n            )\n            for r, w in conns:\n                w.write(b\"test\")\n                await w.drain()\n            for r, w in conns:\n                await r.read(4096)\n                w.close()\n                await w.wait_closed()\n            await asyncio.sleep(0.3)\n            assert counter.bytes_up == 40  # 10 * 4 bytes\n            assert counter.bytes_down == 40\n            assert counter.connections == 10\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestLargePayloads:\n    async def test_1mb_payload(self, echo_server):\n        server, task = await _start_server()\n        try:\n            r, w = await _socks5_proxy_connect(\n                Address(server.host, server.port), echo_server\n            )\n            payload = b\"A\" * (1024 * 1024)\n            w.write(payload)\n            await w.drain()\n\n            received = b\"\"\n            while len(received) < len(payload):\n                chunk = await asyncio.wait_for(r.read(65536), timeout=5.0)\n                if not chunk:\n                    break\n                received += chunk\n            assert received == payload\n\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_many_small_writes(self, echo_server):\n        server, task = await _start_server()\n        try:\n            r, w = await _socks5_proxy_connect(\n                Address(server.host, server.port), echo_server\n            )\n            expected = b\"\".join(f\"msg{i:03d}\".encode() for i in range(100))\n            w.write(expected)\n            await w.drain()\n\n            # Read all echoed data\n            total = b\"\"\n            while len(total) < len(expected):\n                chunk = await asyncio.wait_for(r.read(65536), timeout=3.0)\n                if not chunk:\n                    break\n                total += chunk\n\n            assert total == expected\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestRapidConnectDisconnect:\n    async def test_rapid_10_cycles(self):\n        server, task = await _start_server()\n        try:\n            for _ in range(10):\n                r, w = await asyncio.open_connection(server.host, server.port)\n                w.write(b\"\\x05\\x01\\x00\")\n                await w.drain()\n                resp = await r.readexactly(2)\n                assert resp == b\"\\x05\\x00\"\n                w.close()\n                await w.wait_closed()\n\n            # Verify server is still responsive\n            r, w = await asyncio.open_connection(server.host, server.port)\n            w.write(b\"\\x05\\x01\\x00\")\n            await w.drain()\n            resp = await r.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_connection.py",
    "content": "\"\"\"Tests for Connection dataclass.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.connection import Connection\n\n\nclass TestConnection:\n    async def test_dataclass_fields(self):\n        reader = asyncio.StreamReader()\n        writer = None  # Writer not needed for this test\n        addr = Address(\"127.0.0.1\", 1080)\n        conn = Connection(reader=reader, writer=writer, address=addr)\n        assert conn.reader is reader\n        assert conn.writer is writer\n        assert conn.address == addr\n\n    async def test_address_type(self):\n        reader = asyncio.StreamReader()\n        conn = Connection(reader=reader, writer=None, address=Address(\"::1\", 443))\n        assert isinstance(conn.address, Address)\n        assert conn.address.host == \"::1\"\n        assert conn.address.port == 443\n"
  },
  {
    "path": "tests/test_core_address.py",
    "content": "import asyncio\n\nfrom asyncio_socks_server.core.address import (\n    decode_address,\n    detect_atyp,\n    encode_address,\n    encode_reply,\n)\nfrom asyncio_socks_server.core.types import Atyp, Rep\n\n\nclass TestDetectAtyp:\n    def test_ipv4(self):\n        assert detect_atyp(\"127.0.0.1\") == Atyp.IPV4\n        assert detect_atyp(\"0.0.0.0\") == Atyp.IPV4\n\n    def test_ipv6(self):\n        assert detect_atyp(\"::1\") == Atyp.IPV6\n        assert detect_atyp(\"2001:db8::1\") == Atyp.IPV6\n\n    def test_domain(self):\n        assert detect_atyp(\"example.com\") == Atyp.DOMAIN\n        assert detect_atyp(\"sub.example.com\") == Atyp.DOMAIN\n\n\nclass TestEncodeDecodeAddress:\n    def _roundtrip(self, host: str, port: int):\n        encoded = encode_address(host, port)\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(encoded)\n            reader.feed_eof()\n            return await decode_address(reader)\n\n        result = asyncio.get_event_loop().run_until_complete(do())\n        return result\n\n    def test_ipv4_roundtrip(self):\n        result = self._roundtrip(\"127.0.0.1\", 1080)\n        assert result.host == \"127.0.0.1\"\n        assert result.port == 1080\n\n    def test_ipv6_roundtrip(self):\n        result = self._roundtrip(\"::1\", 443)\n        assert result.host == \"::1\"\n        assert result.port == 443\n\n    def test_domain_roundtrip(self):\n        result = self._roundtrip(\"example.com\", 80)\n        assert result.host == \"example.com\"\n        assert result.port == 80\n\n    def test_encode_ipv4_binary(self):\n        # ATYP(1) + IPv4(4) + PORT(2) = 7 bytes\n        data = encode_address(\"0.0.0.0\", 0)\n        assert len(data) == 7\n        assert data[0] == 0x01\n\n    def test_encode_ipv6_binary(self):\n        # ATYP(1) + IPv6(16) + PORT(2) = 19 bytes\n        data = encode_address(\"::1\", 0)\n        assert len(data) == 19\n        assert data[0] == 0x04\n\n    def test_encode_domain_binary(self):\n        # ATYP(1) + LEN(1) + \"example.com\"(11) + PORT(2) = 15 bytes\n        data = encode_address(\"example.com\", 80)\n        assert len(data) == 15\n        assert data[0] == 0x03\n        assert data[1] == 11\n\n\nclass TestEncodeReply:\n    def test_success_reply(self):\n        reply = encode_reply(Rep.SUCCEEDED, \"0.0.0.0\", 0)\n        assert reply[0] == 0x05  # VER\n        assert reply[1] == 0x00  # REP = succeeded\n        assert reply[2] == 0x00  # RSV\n\n    def test_failure_reply(self):\n        reply = encode_reply(Rep.CONNECTION_REFUSED)\n        assert reply[1] == 0x05  # REP = connection refused\n"
  },
  {
    "path": "tests/test_core_protocol.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.core.protocol import (\n    ProtocolError,\n    build_auth_reply,\n    build_method_reply,\n    build_udp_header,\n    parse_method_selection,\n    parse_request,\n    parse_udp_header,\n    parse_username_password,\n)\nfrom asyncio_socks_server.core.types import Address, AuthMethod, Cmd\n\n\nclass TestMethodSelection:\n    def test_valid_no_auth(self):\n        data = b\"\\x05\\x01\\x00\"  # VER=5, NMETHODS=1, METHOD=NO_AUTH\n        ver, methods = parse_method_selection(data)\n        assert ver == 0x05\n        assert AuthMethod.NO_AUTH in methods\n\n    def test_valid_username_password(self):\n        data = b\"\\x05\\x02\\x00\\x02\"\n        ver, methods = parse_method_selection(data)\n        assert AuthMethod.NO_AUTH in methods\n        assert AuthMethod.USERNAME_PASSWORD in methods\n\n    def test_wrong_version(self):\n        with pytest.raises(ProtocolError, match=\"unsupported SOCKS version\"):\n            parse_method_selection(b\"\\x04\\x01\\x00\")\n\n    def test_too_short(self):\n        with pytest.raises(ProtocolError, match=\"too short\"):\n            parse_method_selection(b\"\\x05\")\n\n    def test_build_method_reply(self):\n        assert build_method_reply(0x00) == b\"\\x05\\x00\"\n        assert build_method_reply(0x02) == b\"\\x05\\x02\"\n        assert build_method_reply(0xFF) == b\"\\x05\\xff\"\n\n\nclass TestUsernamePassword:\n    def test_parse(self):\n        # VER=1, ULEN=4, UNAME=\"user\", PLEN=4, PASSWD=\"pass\"\n        data = b\"\\x01\\x04user\\x04pass\"\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_username_password(reader)\n\n        username, password = asyncio.get_event_loop().run_until_complete(do())\n        assert username == \"user\"\n        assert password == \"pass\"\n\n    def test_wrong_version(self):\n        data = b\"\\x02\\x04user\\x04pass\"\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_username_password(reader)\n\n        with pytest.raises(ProtocolError, match=\"unsupported auth version\"):\n            asyncio.get_event_loop().run_until_complete(do())\n\n    def test_build_auth_reply(self):\n        assert build_auth_reply(True) == b\"\\x01\\x00\"\n        assert build_auth_reply(False) == b\"\\x01\\x01\"\n\n\nclass TestParseRequest:\n    def _make_request(self, cmd: int, host: str, port: int) -> bytes:\n        from asyncio_socks_server.core.address import encode_address\n\n        VER = b\"\\x05\"\n        CMD = cmd.to_bytes(1, \"big\")\n        RSV = b\"\\x00\"\n        return VER + CMD + RSV + encode_address(host, port)\n\n    def test_connect_ipv4(self):\n        data = self._make_request(0x01, \"127.0.0.1\", 1080)\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        cmd, addr = asyncio.get_event_loop().run_until_complete(do())\n        assert cmd == Cmd.CONNECT\n        assert addr.host == \"127.0.0.1\"\n        assert addr.port == 1080\n\n    def test_connect_ipv6(self):\n        data = self._make_request(0x01, \"::1\", 443)\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        cmd, addr = asyncio.get_event_loop().run_until_complete(do())\n        assert cmd == Cmd.CONNECT\n        assert addr.host == \"::1\"\n        assert addr.port == 443\n\n    def test_connect_domain(self):\n        data = self._make_request(0x01, \"example.com\", 80)\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        cmd, addr = asyncio.get_event_loop().run_until_complete(do())\n        assert cmd == Cmd.CONNECT\n        assert addr.host == \"example.com\"\n        assert addr.port == 80\n\n    def test_udp_associate(self):\n        data = self._make_request(0x03, \"0.0.0.0\", 0)\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        cmd, addr = asyncio.get_event_loop().run_until_complete(do())\n        assert cmd == Cmd.UDP_ASSOCIATE\n\n    def test_wrong_version(self):\n        data = b\"\\x04\\x01\\x00\\x01\\x7f\\x00\\x00\\x01\\x04\\x38\"\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        with pytest.raises(ProtocolError, match=\"unsupported SOCKS version\"):\n            asyncio.get_event_loop().run_until_complete(do())\n\n    def test_unsupported_command(self):\n        data = self._make_request(0x02, \"127.0.0.1\", 1080)  # BIND\n        reader = asyncio.StreamReader()\n\n        async def do():\n            reader.feed_data(data)\n            reader.feed_eof()\n            return await parse_request(reader)\n\n        with pytest.raises(ProtocolError, match=\"unsupported command\"):\n            asyncio.get_event_loop().run_until_complete(do())\n\n\nclass TestUdpHeader:\n    def test_parse_ipv4(self):\n        from asyncio_socks_server.core.address import encode_address\n\n        header = b\"\\x00\\x00\\x00\" + encode_address(\"127.0.0.1\", 1080)\n        payload = b\"hello\"\n        data = header + payload\n\n        addr, hdr_len, pl = parse_udp_header(data)\n        assert addr.host == \"127.0.0.1\"\n        assert addr.port == 1080\n        assert hdr_len == 3 + 7  # RSV(2)+FRAG(1)+ATYP(1)+IPv4(4)+PORT(2)\n        assert pl == b\"hello\"\n\n    def test_parse_domain(self):\n        from asyncio_socks_server.core.address import encode_address\n\n        header = b\"\\x00\\x00\\x00\" + encode_address(\"example.com\", 80)\n        payload = b\"world\"\n        data = header + payload\n\n        addr, hdr_len, pl = parse_udp_header(data)\n        assert addr.host == \"example.com\"\n        assert addr.port == 80\n        assert pl == b\"world\"\n\n    def test_build_udp_header(self):\n        header = build_udp_header(Address(\"127.0.0.1\", 1080))\n        assert header[0:2] == b\"\\x00\\x00\"  # RSV\n        assert header[2] == 0x00  # FRAG\n        assert header[3] == 0x01  # ATYP IPv4\n\n    def test_too_short(self):\n        with pytest.raises(ProtocolError, match=\"too short\"):\n            parse_udp_header(b\"\\x00\\x00\")\n"
  },
  {
    "path": "tests/test_core_socket.py",
    "content": "import socket\n\nfrom asyncio_socks_server.core.socket import (\n    create_dualstack_tcp_socket,\n    create_dualstack_udp_socket,\n)\n\n\ndef test_tcp_unspecified_ipv4_uses_ipv4_socket():\n    sock = create_dualstack_tcp_socket(\"0.0.0.0\", 0)\n    try:\n        assert sock.family == socket.AF_INET\n    finally:\n        sock.close()\n\n\ndef test_udp_unspecified_ipv4_uses_ipv4_socket():\n    sock = create_dualstack_udp_socket(\"0.0.0.0\", 0)\n    try:\n        assert sock.family == socket.AF_INET6\n        assert sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) == 0\n    finally:\n        sock.close()\n\n\ndef test_tcp_unspecified_ipv6_keeps_dualstack_socket():\n    sock = create_dualstack_tcp_socket(\"::\", 0)\n    try:\n        assert sock.family == socket.AF_INET6\n        assert sock.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY) == 0\n    finally:\n        sock.close()\n"
  },
  {
    "path": "tests/test_core_types.py",
    "content": "from asyncio_socks_server.core.types import (\n    Address,\n    Atyp,\n    AuthMethod,\n    Cmd,\n    Direction,\n    Rep,\n)\n\n\ndef test_rep_values():\n    assert Rep.SUCCEEDED == 0x00\n    assert Rep.GENERAL_FAILURE == 0x01\n    assert Rep.COMMAND_NOT_SUPPORTED == 0x07\n\n\ndef test_auth_method_values():\n    assert AuthMethod.NO_AUTH == 0x00\n    assert AuthMethod.USERNAME_PASSWORD == 0x02\n    assert AuthMethod.NO_ACCEPTABLE == 0xFF\n\n\ndef test_cmd_values():\n    assert Cmd.CONNECT == 0x01\n    assert Cmd.UDP_ASSOCIATE == 0x03\n\n\ndef test_atyp_values():\n    assert Atyp.IPV4 == 0x01\n    assert Atyp.DOMAIN == 0x03\n    assert Atyp.IPV6 == 0x04\n\n\ndef test_direction_constants():\n    assert Direction.UPSTREAM == \"upstream\"\n    assert Direction.DOWNSTREAM == \"downstream\"\n\n\ndef test_address_frozen():\n    addr = Address(\"127.0.0.1\", 1080)\n    assert addr.host == \"127.0.0.1\"\n    assert addr.port == 1080\n\n\ndef test_address_str():\n    assert str(Address(\"127.0.0.1\", 1080)) == \"127.0.0.1:1080\"\n    assert str(Address(\"example.com\", 443)) == \"example.com:443\"\n"
  },
  {
    "path": "tests/test_e2e.py",
    "content": "\"\"\"End-to-end tests: full proxy scenarios.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server import Addon, ChainRouter, TrafficCounter, connect\nfrom asyncio_socks_server.core.types import Address\n\n\nasync def _socks5_proxy_connect(proxy: Address, target: Address, auth=None):\n    \"\"\"Raw SOCKS5 CONNECT through proxy.\"\"\"\n    reader, writer = await asyncio.open_connection(proxy.host, proxy.port)\n\n    if auth:\n        writer.write(b\"\\x05\\x01\\x02\")\n    else:\n        writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n\n    resp = await reader.readexactly(2)\n    assert resp[0] == 0x05\n\n    if auth:\n        assert resp[1] == 0x02\n        uname = auth[0].encode()\n        passwd = auth[1].encode()\n        writer.write(\n            b\"\\x01\"\n            + len(uname).to_bytes(1, \"big\")\n            + uname\n            + len(passwd).to_bytes(1, \"big\")\n            + passwd\n        )\n        await writer.drain()\n        auth_resp = await reader.readexactly(2)\n        assert auth_resp == b\"\\x01\\x00\"\n    else:\n        assert resp[1] == 0x00\n\n    from asyncio_socks_server.core.address import encode_address\n\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target.host, target.port))\n    await writer.drain()\n\n    reply = await reader.readexactly(3)\n    assert reply[1] == 0x00  # succeeded\n\n    # Skip bound address\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n\n    return reader, writer\n\n\nclass TestE2ETcp:\n    async def test_bidirectional_relay(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        server, task = await _start_server()\n        try:\n            r, w = await _socks5_proxy_connect(\n                Address(server.host, server.port), echo_server\n            )\n            w.write(b\"ping\")\n            await w.drain()\n            assert await r.read(4096) == b\"ping\"\n\n            w.write(b\"pong\")\n            await w.drain()\n            assert await r.read(4096) == b\"pong\"\n\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_multiple_connections(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        server, task = await _start_server()\n        try:\n            conns = []\n            for _ in range(5):\n                r, w = await _socks5_proxy_connect(\n                    Address(server.host, server.port), echo_server\n                )\n                conns.append((r, w))\n\n            for r, w in conns:\n                w.write(b\"test\")\n                await w.drain()\n                assert await r.read(4096) == b\"test\"\n                w.close()\n                await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_client_library(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"via client library\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"via client library\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestE2EChain:\n    async def test_three_hop_chain(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        exit_server, exit_task = await _start_server()\n\n        mid_addon = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        mid_server, mid_task = await _start_server(addons=[mid_addon])\n\n        entry_addon = ChainRouter(next_hop=f\"127.0.0.1:{mid_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[entry_addon])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            conn.writer.write(b\"three hops!\")\n            await conn.writer.drain()\n            data = await conn.reader.read(4096)\n            assert data == b\"three hops!\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(mid_server, mid_task)\n            await _stop_server(exit_server, exit_task)\n\n\nclass UpperAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return data.upper()\n\n\nclass TestE2EAddons:\n    async def test_pipeline_transform(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        server, task = await _start_server(addons=[UpperAddon()])\n        try:\n            r, w = await _socks5_proxy_connect(\n                Address(server.host, server.port), echo_server\n            )\n            w.write(b\"hello\")\n            await w.drain()\n\n            # Echo server receives \"HELLO\" and echoes it back\n            # The upstream addon transforms to uppercase\n            # The downstream addon also transforms, but echo returns it\n            data = await r.read(4096)\n            assert data == b\"HELLO\"\n\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_traffic_counter(self, echo_server):\n        from tests.conftest import _start_server, _stop_server\n\n        counter = TrafficCounter()\n        server, task = await _start_server(addons=[counter])\n        try:\n            r, w = await _socks5_proxy_connect(\n                Address(server.host, server.port), echo_server\n            )\n            w.write(b\"count me\")\n            await w.drain()\n            await r.read(4096)\n\n            # TrafficCounter now reads from flow on close,\n            # so assertions must happen after connection teardown.\n            w.close()\n            await w.wait_closed()\n            await asyncio.sleep(0.2)\n\n            assert counter.bytes_up == 8\n            assert counter.bytes_down == 8\n            assert counter.connections == 1\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_e2e_auth_chain.py",
    "content": "import pytest\n\nfrom asyncio_socks_server import ChainRouter, connect\nfrom asyncio_socks_server.core.types import Address\nfrom tests.conftest import _start_server, _stop_server\n\n\nclass TestAuthChain:\n    async def test_chain_with_auth_at_entry(self, echo_server):\n        exit_server, exit_task = await _start_server()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(\n            auth=(\"admin\", \"secret\"),\n            addons=[chain],\n        )\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port),\n                echo_server,\n                username=\"admin\",\n                password=\"secret\",\n            )\n            conn.writer.write(b\"auth-chain\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"auth-chain\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n    async def test_chain_with_auth_at_exit(self, echo_server):\n        exit_server, exit_task = await _start_server(auth=(\"u\", \"p\"))\n        chain = ChainRouter(\n            next_hop=f\"127.0.0.1:{exit_server.port}\",\n            username=\"u\",\n            password=\"p\",\n        )\n        entry_server, entry_task = await _start_server(addons=[chain])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            conn.writer.write(b\"chain-auth-exit\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"chain-auth-exit\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n    async def test_chain_both_hops_require_auth(self, echo_server):\n        exit_server, exit_task = await _start_server(auth=(\"exit_u\", \"exit_p\"))\n        chain = ChainRouter(\n            next_hop=f\"127.0.0.1:{exit_server.port}\",\n            username=\"exit_u\",\n            password=\"exit_p\",\n        )\n        entry_server, entry_task = await _start_server(\n            auth=(\"entry_u\", \"entry_p\"),\n            addons=[chain],\n        )\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port),\n                echo_server,\n                username=\"entry_u\",\n                password=\"entry_p\",\n            )\n            conn.writer.write(b\"double-auth\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"double-auth\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n    async def test_chain_auth_failure_propagates(self, echo_server):\n        from asyncio_socks_server.core.protocol import ProtocolError\n\n        exit_server, exit_task = await _start_server()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(\n            auth=(\"good\", \"creds\"),\n            addons=[chain],\n        )\n\n        try:\n            with pytest.raises(ProtocolError, match=\"authentication failed\"):\n                await connect(\n                    Address(entry_server.host, entry_server.port),\n                    echo_server,\n                    username=\"good\",\n                    password=\"wrong\",\n                )\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n"
  },
  {
    "path": "tests/test_e2e_data_paths.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server import Addon, ChainRouter, IPFilter, TrafficCounter, connect\nfrom asyncio_socks_server.core.protocol import build_udp_header, parse_udp_header\nfrom asyncio_socks_server.core.types import Address, Direction\nfrom tests.conftest import _start_server, _stop_server\nfrom tests.e2e_helpers import open_udp_associate, read_socks_reply, socks5_connect\n\n\nclass TestBidirectionalData:\n    async def test_simultaneous_bidirectional(self, echo_server):\n        server, task = await _start_server()\n        try:\n            reader, writer = await socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x00\n\n            writer.write(b\"simul\")\n            await writer.drain()\n            assert await asyncio.wait_for(reader.read(4096), timeout=2.0) == b\"simul\"\n\n            writer.write(b\"simul2\")\n            await writer.drain()\n            assert await asyncio.wait_for(reader.read(4096), timeout=2.0) == b\"simul2\"\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestLargeDataChain:\n    async def test_512kb_through_chain(self, echo_server):\n        exit_server, exit_task = await _start_server()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[chain])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            payload = b\"X\" * (512 * 1024)\n            conn.writer.write(payload)\n            await conn.writer.drain()\n\n            received = b\"\"\n            while len(received) < len(payload):\n                chunk = await asyncio.wait_for(conn.reader.read(65536), timeout=5.0)\n                if not chunk:\n                    break\n                received += chunk\n\n            assert received == payload\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n\nclass TestMultiAddonComposition:\n    async def test_ipfilter_and_traffic_counter(self, echo_server):\n        counter = TrafficCounter()\n        filter_addon = IPFilter(allowed=[\"127.0.0.0/8\"])\n        server, task = await _start_server(addons=[filter_addon, counter])\n\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"filtered\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"filtered\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n\n            await asyncio.sleep(0.2)\n            assert counter.connections == 1\n            assert counter.bytes_up == 8\n            assert counter.bytes_down == 8\n        finally:\n            await _stop_server(server, task)\n\n    async def test_ipfilter_blocks_then_traffic_zero(self, echo_server):\n        counter = TrafficCounter()\n        filter_addon = IPFilter(blocked=[\"127.0.0.0/8\"])\n        server, task = await _start_server(addons=[filter_addon, counter])\n\n        try:\n            reader, writer = await socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x02\n            writer.close()\n            await writer.wait_closed()\n\n            await asyncio.sleep(0.1)\n            assert counter.connections == 0\n        finally:\n            await _stop_server(server, task)\n\n    async def test_pipeline_and_chain_combined(self, echo_server):\n        class UpperAddon(Addon):\n            async def on_data(self, direction, data, flow):\n                return data.upper()\n\n        exit_server, exit_task = await _start_server(addons=[UpperAddon()])\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[chain])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            conn.writer.write(b\"transform-me\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"TRANSFORM-ME\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n\nclass TestAddonDataDrop:\n    async def test_drop_addon_silences_upstream(self, echo_server):\n        class DropUpstream(Addon):\n            async def on_data(self, direction, data, flow):\n                if direction == Direction.UPSTREAM:\n                    return None\n                return data\n\n        server, task = await _start_server(addons=[DropUpstream()])\n        try:\n            reader, writer = await socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x00\n\n            writer.write(b\"dropped\")\n            await writer.drain()\n            with pytest.raises(asyncio.TimeoutError):\n                await asyncio.wait_for(reader.read(4096), timeout=0.5)\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestFlowBytesAccuracy:\n    async def test_traffic_counter_through_chain(self, echo_server):\n        exit_counter = TrafficCounter()\n        exit_server, exit_task = await _start_server(addons=[exit_counter])\n\n        entry_counter = TrafficCounter()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[entry_counter, chain])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            conn.writer.write(b\"12345\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"12345\"\n\n            conn.writer.close()\n            await conn.writer.wait_closed()\n            await asyncio.sleep(0.3)\n\n            assert entry_counter.connections == 1\n            assert entry_counter.bytes_up == 5\n            assert entry_counter.bytes_down == 5\n            assert exit_counter.connections == 1\n            assert exit_counter.bytes_up == 5\n            assert exit_counter.bytes_down == 5\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n\nclass TestMixedProtocol:\n    async def test_tcp_and_udp_concurrent(self, echo_server, udp_echo_server):\n        server, task = await _start_server()\n        try:\n            tcp_reader, tcp_writer = await socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            reply = await read_socks_reply(tcp_reader)\n            assert reply[1] == 0x00\n\n            _, udp_writer, udp_bind = await open_udp_associate(\n                Address(server.host, server.port)\n            )\n\n            tcp_writer.write(b\"tcp-data\")\n            await tcp_writer.drain()\n\n            echo_addr, _ = udp_echo_server\n            loop = asyncio.get_running_loop()\n            udp_received = loop.create_future()\n\n            class ClientProto(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    if not udp_received.done():\n                        udp_received.set_result(data)\n\n            transport, _ = await loop.create_datagram_endpoint(\n                ClientProto,\n                local_addr=(\"127.0.0.1\", 0),\n            )\n            try:\n                transport.sendto(\n                    build_udp_header(echo_addr) + b\"udp-data\",\n                    (udp_bind.host, udp_bind.port),\n                )\n\n                tcp_data = await asyncio.wait_for(tcp_reader.read(4096), timeout=2.0)\n                assert tcp_data == b\"tcp-data\"\n\n                udp_data = await asyncio.wait_for(udp_received, timeout=2.0)\n                _, _, payload = parse_udp_header(udp_data)\n                assert payload == b\"udp-data\"\n            finally:\n                transport.close()\n                tcp_writer.close()\n                await tcp_writer.wait_closed()\n                udp_writer.close()\n                await udp_writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestBinaryDataRoundtrip:\n    async def test_null_bytes_and_binary(self, echo_server):\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            payload = b\"\\x00\\x01\\x02\\xff\\xfe\\xfd\" + bytes(range(256))\n            conn.writer.write(payload)\n            await conn.writer.drain()\n\n            received = b\"\"\n            while len(received) < len(payload):\n                chunk = await asyncio.wait_for(conn.reader.read(4096), timeout=3.0)\n                if not chunk:\n                    break\n                received += chunk\n\n            assert received == payload\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_binary_through_chain(self, echo_server):\n        exit_server, exit_task = await _start_server()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[chain])\n\n        try:\n            conn = await connect(\n                Address(entry_server.host, entry_server.port), echo_server\n            )\n            payload = bytes(range(256)) * 4\n            conn.writer.write(payload)\n            await conn.writer.drain()\n\n            received = b\"\"\n            while len(received) < len(payload):\n                chunk = await asyncio.wait_for(conn.reader.read(4096), timeout=3.0)\n                if not chunk:\n                    break\n                received += chunk\n\n            assert received == payload\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n"
  },
  {
    "path": "tests/test_e2e_lifecycle.py",
    "content": "import asyncio\n\nfrom asyncio_socks_server import connect\nfrom asyncio_socks_server.core.types import Address\nfrom tests.conftest import _start_server, _stop_server\n\n\nclass TestClientDisconnect:\n    async def test_abrupt_client_disconnect_no_crash(self, echo_server):\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"before-disconnect\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"before-disconnect\"\n\n            conn.writer.close()\n            await conn.writer.wait_closed()\n\n            conn2 = await connect(Address(server.host, server.port), echo_server)\n            conn2.writer.write(b\"after-disconnect\")\n            await conn2.writer.drain()\n            assert await conn2.reader.read(4096) == b\"after-disconnect\"\n            conn2.writer.close()\n            await conn2.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_target_disconnect_mid_relay(self):\n        async def disconnect_after_first(reader, writer):\n            try:\n                data = await reader.read(4096)\n                writer.write(data)\n                await writer.drain()\n                writer.close()\n                await writer.wait_closed()\n            except Exception:\n                pass\n\n        srv = await asyncio.start_server(disconnect_after_first, \"127.0.0.1\", 0)\n        addr = srv.sockets[0].getsockname()\n        target = Address(addr[0], addr[1])\n\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), target)\n            conn.writer.write(b\"first\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"first\"\n            assert await conn.reader.read(4096) == b\"\"\n\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n            srv.close()\n            await srv.wait_closed()\n\n\nclass TestGracefulShutdown:\n    async def test_active_connections_complete_on_shutdown(self):\n        async def slow_echo(reader, writer):\n            try:\n                data = await reader.read(4096)\n                await asyncio.sleep(0.2)\n                writer.write(data)\n                await writer.drain()\n            finally:\n                writer.close()\n                await writer.wait_closed()\n\n        srv = await asyncio.start_server(slow_echo, \"127.0.0.1\", 0)\n        addr = srv.sockets[0].getsockname()\n        target = Address(addr[0], addr[1])\n\n        server, task = await _start_server()\n        try:\n            conn = await connect(Address(server.host, server.port), target)\n            conn.writer.write(b\"slow\")\n            await conn.writer.drain()\n\n            server.request_shutdown()\n            data = await asyncio.wait_for(conn.reader.read(4096), timeout=3.0)\n            assert data == b\"slow\"\n\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await task\n            srv.close()\n            await srv.wait_closed()\n\n\nclass TestRepeatedConnections:\n    async def test_50_sequential_connections(self, echo_server):\n        server, task = await _start_server()\n        try:\n            for i in range(50):\n                conn = await connect(Address(server.host, server.port), echo_server)\n                msg = f\"msg-{i:03d}\".encode()\n                conn.writer.write(msg)\n                await conn.writer.drain()\n                assert await conn.reader.read(4096) == msg\n                conn.writer.close()\n                await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_connection_reuse_stability(self, echo_server):\n        server, task = await _start_server()\n        try:\n            for round_num in range(3):\n                conns = []\n                for i in range(10):\n                    conn = await connect(Address(server.host, server.port), echo_server)\n                    msg = f\"r{round_num}-{i}\".encode()\n                    conn.writer.write(msg)\n                    await conn.writer.drain()\n                    conns.append((conn, msg))\n\n                for conn, msg in conns:\n                    assert await conn.reader.read(4096) == msg\n                    conn.writer.close()\n                    await conn.writer.wait_closed()\n\n                await asyncio.sleep(0.1)\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_e2e_policy_errors.py",
    "content": "import asyncio\n\nfrom asyncio_socks_server import ChainRouter, IPFilter, connect\nfrom asyncio_socks_server.core.address import encode_address\nfrom asyncio_socks_server.core.types import Address\nfrom tests.conftest import _start_server, _stop_server\nfrom tests.e2e_helpers import read_socks_reply, socks5_connect\n\n\nclass TestIPFilterE2E:\n    async def test_allowed_ip_connects(self, echo_server):\n        filter_addon = IPFilter(allowed=[\"127.0.0.0/8\"])\n        server, task = await _start_server(addons=[filter_addon])\n        try:\n            conn = await connect(Address(server.host, server.port), echo_server)\n            conn.writer.write(b\"allowed\")\n            await conn.writer.drain()\n            assert await conn.reader.read(4096) == b\"allowed\"\n            conn.writer.close()\n            await conn.writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_blocked_ip_rejected(self, echo_server):\n        filter_addon = IPFilter(blocked=[\"127.0.0.0/8\"])\n        server, task = await _start_server(addons=[filter_addon])\n        try:\n            reader, writer = await socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x02\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestConnectionRefusedE2E:\n    async def test_target_refused_returns_error_reply(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await socks5_connect(\n                Address(server.host, server.port),\n                Address(\"127.0.0.1\", 1),\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] != 0x00\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_unreachable_target_through_chain(self):\n        exit_server, exit_task = await _start_server()\n        chain = ChainRouter(next_hop=f\"127.0.0.1:{exit_server.port}\")\n        entry_server, entry_task = await _start_server(addons=[chain])\n\n        try:\n            reader, writer = await socks5_connect(\n                Address(entry_server.host, entry_server.port),\n                Address(\"127.0.0.1\", 1),\n            )\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x02\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(entry_server, entry_task)\n            await _stop_server(exit_server, exit_task)\n\n\nclass TestDomainNameTarget:\n    async def test_domain_target_resolved(self, echo_server):\n        server, task = await _start_server()\n        try:\n            reader, writer = await asyncio.open_connection(server.host, server.port)\n\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            assert await reader.readexactly(2) == b\"\\x05\\x00\"\n\n            writer.write(\n                b\"\\x05\\x01\\x00\" + encode_address(\"127.0.0.1\", echo_server.port)\n            )\n            await writer.drain()\n            reply = await read_socks_reply(reader)\n            assert reply[1] == 0x00\n\n            writer.write(b\"domain-test\")\n            await writer.drain()\n            assert await reader.read(4096) == b\"domain-test\"\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_flow.py",
    "content": "\"\"\"Flow context tests: on_flow_close, bytes accuracy, dataclass, UdpRelay injection.\"\"\"\n\nimport asyncio\nimport time\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.protocol import build_udp_header\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\nfrom asyncio_socks_server.server.tcp_relay import _copy, handle_tcp_relay\nfrom asyncio_socks_server.server.udp_relay import UdpRelay\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 1000),\n        dst=Address(\"127.0.0.1\", 2000),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\n# --- Flow dataclass tests ---\n\n\nclass TestFlowDataclass:\n    def test_construction_with_defaults(self):\n        flow = Flow(\n            id=42,\n            src=Address(\"10.0.0.1\", 1234),\n            dst=Address(\"10.0.0.2\", 5678),\n            protocol=\"tcp\",\n            started_at=100.0,\n        )\n        assert flow.id == 42\n        assert flow.src.host == \"10.0.0.1\"\n        assert flow.bytes_up == 0\n        assert flow.bytes_down == 0\n\n    def test_mutable_bytes(self):\n        flow = _make_flow()\n        flow.bytes_up += 100\n        flow.bytes_down += 200\n        assert flow.bytes_up == 100\n        assert flow.bytes_down == 200\n\n    def test_protocol_literal(self):\n        flow_tcp = _make_flow(protocol=\"tcp\")\n        flow_udp = _make_flow(protocol=\"udp\")\n        assert flow_tcp.protocol == \"tcp\"\n        assert flow_udp.protocol == \"udp\"\n\n    def test_started_at_monotonic(self):\n        before = time.monotonic()\n        flow = Flow(\n            id=1,\n            src=Address(\"::\", 0),\n            dst=Address(\"::\", 0),\n            protocol=\"tcp\",\n            started_at=time.monotonic(),\n        )\n        after = time.monotonic()\n        assert before <= flow.started_at <= after\n\n\n# --- on_flow_close hook tests ---\n\n\nclass CloseCapture(Addon):\n    def __init__(self):\n        self.closed_flows: list[Flow] = []\n\n    async def on_flow_close(self, flow):\n        self.closed_flows.append(flow)\n\n\nclass CloseCrasher(Addon):\n    async def on_flow_close(self, flow):\n        raise RuntimeError(\"crash in on_flow_close\")\n\n\nclass TestOnFlowClose:\n    async def test_called_for_all_addons(self):\n        a1 = CloseCapture()\n        a2 = CloseCapture()\n        mgr = AddonManager([a1, a2])\n        flow = _make_flow()\n        await mgr.dispatch_flow_close(flow)\n        assert len(a1.closed_flows) == 1\n        assert len(a2.closed_flows) == 1\n        assert a1.closed_flows[0] is flow\n\n    async def test_exception_does_not_propagate(self):\n        a1 = CloseCrasher()\n        a2 = CloseCapture()\n        mgr = AddonManager([a1, a2])\n        flow = _make_flow()\n        await mgr.dispatch_flow_close(flow)\n        assert len(a2.closed_flows) == 1\n\n    async def test_receives_final_flow_snapshot(self):\n        capture = CloseCapture()\n        mgr = AddonManager([capture])\n        flow = _make_flow()\n        flow.bytes_up = 1024\n        flow.bytes_down = 2048\n        await mgr.dispatch_flow_close(flow)\n        assert capture.closed_flows[0].bytes_up == 1024\n        assert capture.closed_flows[0].bytes_down == 2048\n\n    async def test_base_addon_skipped(self):\n        mgr = AddonManager([Addon()])\n        await mgr.dispatch_flow_close(_make_flow())\n\n    async def test_no_addons(self):\n        mgr = AddonManager([])\n        await mgr.dispatch_flow_close(_make_flow())\n\n\n# --- flow.bytes accuracy for TCP path ---\n\n\nclass TestTcpFlowBytes:\n    async def _pipe(self):\n        \"\"\"Create a pipe: write to [0] → read from [3]. Keep all refs.\"\"\"\n        import socket\n\n        sock_a, sock_b = socket.socketpair()\n        sock_a.setblocking(False)\n        sock_b.setblocking(False)\n        reader_a, writer_a = await asyncio.open_connection(sock=sock_a)\n        reader_b, writer_b = await asyncio.open_connection(sock=sock_b)\n        return writer_a, writer_b, reader_a, reader_b\n\n    async def test_copy_updates_bytes_up(self):\n        in_wa, _in_wb, _in_ra, in_rb = await self._pipe()\n        out_wa, _out_wb, _out_ra, out_rb = await self._pipe()\n\n        flow = _make_flow()\n        mgr = AddonManager()\n        task = asyncio.create_task(_copy(in_rb, out_wa, mgr, Direction.UPSTREAM, flow))\n\n        in_wa.write(b\"hello\")\n        await in_wa.drain()\n        data = await asyncio.wait_for(out_rb.read(4096), timeout=1.0)\n        assert data == b\"hello\"\n\n        in_wa.close()\n        await in_wa.wait_closed()\n        await asyncio.wait_for(task, timeout=1.0)\n\n        assert flow.bytes_up == 5\n        assert flow.bytes_down == 0\n\n    async def test_copy_updates_bytes_down(self):\n        in_wa, _in_wb, _in_ra, in_rb = await self._pipe()\n        out_wa, _out_wb, _out_ra, out_rb = await self._pipe()\n\n        flow = _make_flow()\n        mgr = AddonManager()\n        task = asyncio.create_task(\n            _copy(in_rb, out_wa, mgr, Direction.DOWNSTREAM, flow)\n        )\n\n        in_wa.write(b\"world\")\n        await in_wa.drain()\n        data = await asyncio.wait_for(out_rb.read(4096), timeout=1.0)\n        assert data == b\"world\"\n\n        in_wa.close()\n        await in_wa.wait_closed()\n        await asyncio.wait_for(task, timeout=1.0)\n\n        assert flow.bytes_up == 0\n        assert flow.bytes_down == 5\n\n    async def test_bidirectional_relay_bytes(self):\n        import socket\n\n        # Client pipe\n        c_a, c_b = socket.socketpair()\n        c_a.setblocking(False)\n        c_b.setblocking(False)\n        cr_app, cw_app = await asyncio.open_connection(sock=c_a)\n        cr_relay, cw_relay = await asyncio.open_connection(sock=c_b)\n\n        # Remote pipe\n        r_a, r_b = socket.socketpair()\n        r_a.setblocking(False)\n        r_b.setblocking(False)\n        rr_app, rw_app = await asyncio.open_connection(sock=r_a)\n        rr_relay, rw_relay = await asyncio.open_connection(sock=r_b)\n\n        flow = _make_flow()\n        mgr = AddonManager()\n\n        relay_task = asyncio.create_task(\n            handle_tcp_relay(cr_relay, cw_relay, rr_relay, rw_relay, mgr, flow)\n        )\n\n        cw_app.write(b\"abc\")\n        await cw_app.drain()\n        data = await asyncio.wait_for(rr_app.read(4096), timeout=1.0)\n        assert data == b\"abc\"\n\n        rw_app.write(b\"xyz\")\n        await rw_app.drain()\n        data = await asyncio.wait_for(cr_app.read(4096), timeout=1.0)\n        assert data == b\"xyz\"\n\n        cw_app.close()\n        await cw_app.wait_closed()\n        rw_app.close()\n        await rw_app.wait_closed()\n        await asyncio.wait_for(relay_task, timeout=2.0)\n\n        assert flow.bytes_up == 3\n        assert flow.bytes_down == 3\n\n\n# --- UdpRelay constructor injection tests ---\n\n\nclass TestUdpRelayFlowInjection:\n    async def test_constructor_stores_flow(self):\n        flow = _make_flow(protocol=\"udp\")\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=flow)\n        assert relay._flow is flow\n\n    async def test_udp_bytes_single_write(self, udp_echo_server):\n        echo_addr, _ = udp_echo_server\n        flow = _make_flow(protocol=\"udp\")\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=flow)\n        try:\n            await relay.start()\n\n            datagram = build_udp_header(echo_addr) + b\"hello\"\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n\n            await asyncio.sleep(0.1)\n\n            assert flow.bytes_up == 5\n        finally:\n            await relay.stop()\n"
  },
  {
    "path": "tests/test_ipv6.py",
    "content": "\"\"\"Tests for IPv6 dual-stack support.\"\"\"\n\nimport asyncio\nimport ipaddress\nimport socket\nimport struct\n\nimport pytest\n\nfrom asyncio_socks_server.core.address import encode_address\nfrom asyncio_socks_server.core.protocol import build_udp_header\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\n\n\nasync def _start_server_ipv6(**kwargs):\n    server = Server(host=\"::\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nasync def _skip_bind_address(reader):\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n\n\nasync def _socks5_connect_ipv6(proxy_addr: Address, target: Address):\n    reader, writer = await asyncio.open_connection(proxy_addr.host, proxy_addr.port)\n    writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n    resp = await reader.readexactly(2)\n    assert resp[0] == 0x05 and resp[1] == 0x00\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target.host, target.port))\n    await writer.drain()\n    reply = await reader.readexactly(3)\n    assert reply[1] == 0x00\n    await _skip_bind_address(reader)\n    return reader, writer\n\n\nclass TestIPv6TCP:\n    @pytest.fixture\n    async def ipv6_echo_server(self):\n        async def handler(reader, writer):\n            try:\n                while True:\n                    data = await reader.read(4096)\n                    if not data:\n                        break\n                    writer.write(data)\n                    await writer.drain()\n            finally:\n                writer.close()\n                await writer.wait_closed()\n\n        srv = await asyncio.start_server(handler, \"::1\", 0)\n        addr = srv.sockets[0].getsockname()\n        yield Address(addr[0], addr[1])\n        srv.close()\n        await srv.wait_closed()\n\n    async def test_tcp_connect_ipv6_loopback(self, ipv6_echo_server):\n        server, task = await _start_server_ipv6()\n        try:\n            tcp_r, tcp_w = await _socks5_connect_ipv6(\n                Address(\"::1\", server.port), ipv6_echo_server\n            )\n            tcp_w.write(b\"hello ipv6\")\n            await tcp_w.drain()\n            data = await tcp_r.read(1024)\n            assert data == b\"hello ipv6\"\n            tcp_w.close()\n            await tcp_w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_tcp_ipv4_on_dualstack(self):\n        async def echo_handler(reader, writer):\n            try:\n                while True:\n                    data = await reader.read(4096)\n                    if not data:\n                        break\n                    writer.write(data)\n                    await writer.drain()\n            finally:\n                writer.close()\n                await writer.wait_closed()\n\n        echo_srv = await asyncio.start_server(echo_handler, \"127.0.0.1\", 0)\n        echo_addr = echo_srv.sockets[0].getsockname()\n        echo_target = Address(echo_addr[0], echo_addr[1])\n\n        server, task = await _start_server_ipv6()\n        try:\n            r, w = await asyncio.open_connection(\"127.0.0.1\", server.port)\n            w.write(b\"\\x05\\x01\\x00\")\n            await w.drain()\n            resp = await r.readexactly(2)\n            assert resp[1] == 0x00\n            target_bytes = encode_address(echo_target.host, echo_target.port)\n            w.write(b\"\\x05\\x01\\x00\" + target_bytes)\n            await w.drain()\n            reply = await r.readexactly(3)\n            assert reply[1] == 0x00\n            await _skip_bind_address(r)\n\n            w.write(b\"dualstack works\")\n            await w.drain()\n            data = await r.read(1024)\n            assert data == b\"dualstack works\"\n            w.close()\n            await w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n            echo_srv.close()\n            await echo_srv.wait_closed()\n\n\nclass TestIPv6UDP:\n    @pytest.fixture\n    async def ipv6_udp_echo_server(self):\n        received = []\n\n        class Protocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport):\n                self.transport = transport\n\n            def datagram_received(self, data, addr):\n                received.append((data, addr))\n                self.transport.sendto(data, addr)\n\n        loop = asyncio.get_running_loop()\n        s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)\n        s.bind((\"::1\", 0))\n        s.setblocking(False)\n        transport, _ = await loop.create_datagram_endpoint(Protocol, sock=s)\n        sockname = s.getsockname()\n        yield Address(sockname[0], sockname[1]), received\n        transport.close()\n\n    async def test_udp_associate_ipv6(self, ipv6_udp_echo_server):\n        echo_addr, _ = ipv6_udp_echo_server\n        server, task = await _start_server_ipv6()\n        try:\n            # SOCKS5 handshake via IPv6\n            reader, writer = await asyncio.open_connection(\"::1\", server.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[0] == 0x05 and resp[1] == 0x00\n\n            # UDP ASSOCIATE\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n\n            reply = await reader.readexactly(3)\n            assert reply[0] == 0x05\n            assert reply[1] == 0x00\n\n            atyp = (await reader.readexactly(1))[0]\n            if atyp == 0x04:\n                host_bytes = await reader.readexactly(16)\n                host = str(ipaddress.IPv6Address(host_bytes))\n            elif atyp == 0x01:\n                host_bytes = await reader.readexactly(4)\n                host = ipaddress.IPv4Address(host_bytes).compressed\n            else:\n                length = (await reader.readexactly(1))[0]\n                host = (await reader.readexactly(length)).decode(\"ascii\")\n\n            port_bytes = await reader.readexactly(2)\n            port = struct.unpack(\"!H\", port_bytes)[0]\n\n            udp_bind = Address(host, port)\n\n            # Client UDP socket on IPv6\n            loop = asyncio.get_running_loop()\n            received = loop.create_future()\n\n            class ClientProtocol(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    if not received.done():\n                        received.set_result(data)\n\n            client_sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)\n            client_sock.bind((\"::1\", 0))\n            client_sock.setblocking(False)\n            transport, _ = await loop.create_datagram_endpoint(\n                ClientProtocol, sock=client_sock\n            )\n\n            datagram = build_udp_header(echo_addr) + b\"hello ipv6 udp\"\n            transport.sendto(datagram, (udp_bind.host, udp_bind.port))\n\n            try:\n                resp_data = await asyncio.wait_for(received, timeout=2.0)\n                from asyncio_socks_server.core.protocol import parse_udp_header\n\n                _, _, payload = parse_udp_header(resp_data)\n                assert payload == b\"hello ipv6 udp\"\n            finally:\n                transport.close()\n                writer.close()\n                await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_logging.py",
    "content": "\"\"\"Tests for core logging module.\"\"\"\n\nimport logging\n\nfrom asyncio_socks_server.core.logging import (\n    fmt_addr,\n    fmt_bytes,\n    fmt_connection,\n    get_logger,\n    setup_logging,\n)\nfrom asyncio_socks_server.core.types import Address\n\n\nclass TestSetupLogging:\n    def test_sets_level_debug(self):\n        setup_logging(\"DEBUG\")\n        logger = get_logger()\n        assert logger.parent.level == logging.DEBUG\n\n    def test_sets_level_info(self):\n        setup_logging(\"INFO\")\n        logger = get_logger()\n        assert logger.parent.level == logging.INFO\n\n\nclass TestGetLogger:\n    def test_returns_named_logger(self):\n        logger = get_logger()\n        assert logger.name == \"asyncio_socks_server\"\n\n\nclass TestFmtAddr:\n    def test_ipv4(self):\n        assert fmt_addr(Address(\"127.0.0.1\", 1080)) == \"127.0.0.1:1080\"\n\n    def test_ipv6(self):\n        assert fmt_addr(Address(\"::1\", 443)) == \"::1:443\"\n\n    def test_domain(self):\n        assert fmt_addr(Address(\"example.com\", 80)) == \"example.com:80\"\n\n\nclass TestFmtConnection:\n    def test_format(self):\n        src = Address(\"10.0.0.1\", 54321)\n        dst = Address(\"93.184.216.34\", 443)\n        result = fmt_connection(src, dst)\n        assert result == \"10.0.0.1:54321 → 93.184.216.34:443\"\n\n\nclass TestFmtBytes:\n    def test_zero(self):\n        assert fmt_bytes(0) == \"0B\"\n\n    def test_bytes(self):\n        assert fmt_bytes(512) == \"512B\"\n\n    def test_boundary_1023(self):\n        assert fmt_bytes(1023) == \"1023B\"\n\n    def test_boundary_1024(self):\n        assert fmt_bytes(1024) == \"1.0KB\"\n\n    def test_kilobytes(self):\n        assert fmt_bytes(2048) == \"2.0KB\"\n\n    def test_just_under_mb(self):\n        assert fmt_bytes(1024 * 1024 - 1) == \"1024.0KB\"\n\n    def test_exact_mb(self):\n        assert fmt_bytes(1024 * 1024) == \"1.0MB\"\n\n    def test_megabytes(self):\n        assert fmt_bytes(5 * 1024 * 1024) == \"5.0MB\"\n"
  },
  {
    "path": "tests/test_protocol_robustness.py",
    "content": "\"\"\"Protocol parser edge cases and boundary conditions.\"\"\"\n\nimport asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.core.protocol import (\n    ProtocolError,\n    parse_method_selection,\n    parse_request,\n    parse_udp_header,\n    parse_username_password,\n)\n\n\nclass TestMethodSelectionEdgeCases:\n    def test_empty_data(self):\n        with pytest.raises(ProtocolError, match=\"too short\"):\n            parse_method_selection(b\"\")\n\n    def test_single_byte(self):\n        with pytest.raises(ProtocolError, match=\"too short\"):\n            parse_method_selection(b\"\\x05\")\n\n    def test_wrong_version(self):\n        with pytest.raises(ProtocolError, match=\"unsupported SOCKS version\"):\n            parse_method_selection(b\"\\x04\\x01\\x00\")\n\n    def test_nmethods_zero(self):\n        ver, methods = parse_method_selection(b\"\\x05\\x00\")\n        assert ver == 0x05\n        assert methods == set()\n\n    def test_extra_bytes_beyond_methods(self):\n        # NMETHODS=1 but data has 3 bytes — extra ignored\n        ver, methods = parse_method_selection(b\"\\x05\\x01\\x00\\xff\\xfe\")\n        assert ver == 0x05\n        assert methods == {0x00}\n\n    def test_all_methods(self):\n        data = b\"\\x05\\xff\" + bytes(range(255))\n        ver, methods = parse_method_selection(data)\n        assert len(methods) == 255\n\n\nclass TestUsernamePasswordEdgeCases:\n    async def test_empty_username(self):\n        reader = asyncio.StreamReader()\n        reader.feed_data(b\"\\x01\\x00\\x04test\")\n        reader.feed_eof()\n        username, password = await parse_username_password(reader)\n        assert username == \"\"\n        assert password == \"test\"\n\n    async def test_empty_password(self):\n        reader = asyncio.StreamReader()\n        reader.feed_data(b\"\\x01\\x04test\\x00\")\n        reader.feed_eof()\n        username, password = await parse_username_password(reader)\n        assert username == \"test\"\n        assert password == \"\"\n\n    async def test_max_length_username(self):\n        reader = asyncio.StreamReader()\n        uname = b\"a\" * 255\n        reader.feed_data(b\"\\x01\\xff\" + uname + b\"\\x01x\")\n        reader.feed_eof()\n        username, password = await parse_username_password(reader)\n        assert len(username) == 255\n        assert password == \"x\"\n\n    async def test_wrong_auth_version(self):\n        reader = asyncio.StreamReader()\n        reader.feed_data(b\"\\x02\\x04test\\x04test\")\n        reader.feed_eof()\n        with pytest.raises(ProtocolError, match=\"unsupported auth version\"):\n            await parse_username_password(reader)\n\n    async def test_truncated_password(self):\n        reader = asyncio.StreamReader()\n        # Claims PLEN=10 but only provides 4 bytes then EOF\n        reader.feed_data(b\"\\x01\\x04test\\x0ashor\")\n        reader.feed_eof()\n        with pytest.raises(asyncio.IncompleteReadError):\n            await parse_username_password(reader)\n\n\nclass TestParseRequestEdgeCases:\n    async def test_unsupported_atyp(self):\n        reader = asyncio.StreamReader()\n        # VER=5, CMD=CONNECT(1), RSV=0, ATYP=0x05 (invalid)\n        reader.feed_data(b\"\\x05\\x01\\x00\\x05\")\n        reader.feed_eof()\n        with pytest.raises(ProtocolError, match=\"unsupported ATYP\"):\n            await parse_request(reader)\n\n    async def test_unsupported_command(self):\n        reader = asyncio.StreamReader()\n        # VER=5, CMD=0x02 (BIND, not supported), RSV=0, ATYP=1\n        reader.feed_data(b\"\\x05\\x02\\x00\\x01\")\n        reader.feed_eof()\n        with pytest.raises(ProtocolError, match=\"unsupported command\"):\n            await parse_request(reader)\n\n    async def test_wrong_version_in_request(self):\n        reader = asyncio.StreamReader()\n        reader.feed_data(b\"\\x04\\x01\\x00\\x01\")\n        reader.feed_eof()\n        with pytest.raises(ProtocolError, match=\"unsupported SOCKS version\"):\n            await parse_request(reader)\n\n    async def test_ipv4_truncated(self):\n        reader = asyncio.StreamReader()\n        # ATYP=0x01 (IPv4, needs 4 bytes) but only 2 bytes\n        reader.feed_data(b\"\\x05\\x01\\x00\\x01\\x7f\\x00\")\n        reader.feed_eof()\n        with pytest.raises(asyncio.IncompleteReadError):\n            await parse_request(reader)\n\n    async def test_domain_empty_label(self):\n        reader = asyncio.StreamReader()\n        # ATYP=0x03, length=0, then 2 port bytes\n        reader.feed_data(b\"\\x05\\x01\\x00\\x03\\x00\\x00\\x50\")\n        cmd, addr = await parse_request(reader)\n        assert addr.host == \"\"\n\n    async def test_domain_max_length(self):\n        reader = asyncio.StreamReader()\n        domain = b\"a\" * 255\n        reader.feed_data(b\"\\x05\\x01\\x00\\x03\" + bytes([255]) + domain + b\"\\x00\\x50\")\n        cmd, addr = await parse_request(reader)\n        assert len(addr.host) == 255\n\n    async def test_domain_truncated(self):\n        reader = asyncio.StreamReader()\n        # Claims domain length 20 but only 5 bytes\n        reader.feed_data(b\"\\x05\\x01\\x00\\x03\\x14hello\")\n        reader.feed_eof()\n        with pytest.raises(asyncio.IncompleteReadError):\n            await parse_request(reader)\n\n    async def test_port_truncated(self):\n        reader = asyncio.StreamReader()\n        # IPv4 address present, but only 1 port byte\n        reader.feed_data(b\"\\x05\\x01\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\")\n        reader.feed_eof()\n        with pytest.raises(asyncio.IncompleteReadError):\n            await parse_request(reader)\n\n\nclass TestUdpHeaderEdgeCases:\n    def test_too_short(self):\n        with pytest.raises(ProtocolError, match=\"too short\"):\n            parse_udp_header(b\"\\x00\\x00\")\n\n    def test_ipv4_truncated(self):\n        # ATYP=0x01 but only 2 of 4 IPv4 bytes\n        with pytest.raises(ProtocolError, match=\"truncated\"):\n            parse_udp_header(b\"\\x00\\x00\\x00\\x01\\x7f\\x00\")\n\n    def test_ipv6_truncated(self):\n        # ATYP=0x04 but only 10 of 16 IPv6 bytes\n        with pytest.raises(ProtocolError, match=\"truncated\"):\n            parse_udp_header(b\"\\x00\\x00\\x00\\x04\" + b\"\\x00\" * 10)\n\n    def test_domain_truncated(self):\n        # ATYP=0x03, length=20 but only 5 domain bytes\n        with pytest.raises(ProtocolError, match=\"truncated\"):\n            parse_udp_header(b\"\\x00\\x00\\x00\\x03\\x14hello\")\n\n    def test_unsupported_atyp(self):\n        with pytest.raises(ProtocolError, match=\"unsupported ATYP\"):\n            parse_udp_header(b\"\\x00\\x00\\x00\\x02\" + b\"\\x00\" * 10)\n\n    def test_header_only_no_payload(self):\n        # Valid IPv4 header with zero payload\n        addr, hdr_len, payload = parse_udp_header(\n            b\"\\x00\\x00\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\\x50\"\n        )\n        assert payload == b\"\"\n        assert hdr_len == 10\n\n    def test_ipv6_full_roundtrip(self):\n        import ipaddress\n\n        ipv6 = ipaddress.IPv6Address(\"::1\").compressed\n        header = b\"\\x00\\x00\\x00\\x04\" + ipaddress.IPv6Address(\"::1\").packed + b\"\\x01\\xbb\"\n        header += b\"payload\"\n        addr, hdr_len, payload = parse_udp_header(header)\n        assert addr.host == ipv6\n        assert addr.port == 443\n        assert payload == b\"payload\"\n        assert hdr_len == 22\n\n    def test_domain_roundtrip(self):\n        header = b\"\\x00\\x00\\x00\\x03\\x0bexample.com\\x00\\x50payload\"\n        addr, hdr_len, payload = parse_udp_header(header)\n        assert addr.host == \"example.com\"\n        assert addr.port == 80\n        assert payload == b\"payload\"\n        assert hdr_len == 18\n"
  },
  {
    "path": "tests/test_server.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.types import Address, Direction\nfrom asyncio_socks_server.server.server import Server\n\n\nasync def _start_server(\n    host: str = \"127.0.0.1\",\n    auth: tuple[str, str] | None = None,\n    addons: list[Addon] | None = None,\n) -> tuple[Server, asyncio.Task]:\n    server = Server(host=host, port=0, auth=auth, addons=addons)\n    task = asyncio.create_task(server._run())\n    # Wait for the server to be ready\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server: Server, task: asyncio.Task):\n    server.request_shutdown()\n    await task\n\n\n@pytest.fixture\nasync def echo_server():\n    async def handler(reader, writer):\n        try:\n            while True:\n                data = await reader.read(4096)\n                if not data:\n                    break\n                writer.write(data)\n                await writer.drain()\n        finally:\n            writer.close()\n            await writer.wait_closed()\n\n    srv = await asyncio.start_server(handler, \"127.0.0.1\", 0)\n    addr = srv.sockets[0].getsockname()\n    yield Address(addr[0], addr[1])\n    srv.close()\n    await srv.wait_closed()\n\n\nasync def _socks5_connect(\n    proxy_addr: Address, target_addr: Address, auth: tuple[str, str] | None = None\n):\n    reader, writer = await asyncio.open_connection(proxy_addr.host, proxy_addr.port)\n\n    if auth:\n        writer.write(b\"\\x05\\x01\\x02\")\n    else:\n        writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n\n    resp = await reader.readexactly(2)\n    assert resp[0] == 0x05\n\n    if auth:\n        assert resp[1] == 0x02\n        uname = auth[0].encode()\n        passwd = auth[1].encode()\n        writer.write(\n            b\"\\x01\"\n            + len(uname).to_bytes(1, \"big\")\n            + uname\n            + len(passwd).to_bytes(1, \"big\")\n            + passwd\n        )\n        await writer.drain()\n        auth_resp = await reader.readexactly(2)\n        assert auth_resp == b\"\\x01\\x00\"\n    else:\n        assert resp[1] == 0x00\n\n    from asyncio_socks_server.core.address import encode_address\n\n    writer.write(b\"\\x05\\x01\\x00\" + encode_address(target_addr.host, target_addr.port))\n    await writer.drain()\n\n    reply = await reader.readexactly(3)\n    assert reply[1] == 0x00\n\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n\n    return reader, writer\n\n\nclass TestServerConnect:\n    async def test_no_auth_connect(self, echo_server):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            writer.write(b\"hello\")\n            await writer.drain()\n            data = await reader.read(4096)\n            assert data == b\"hello\"\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_no_auth_rejected_when_auth_required(self, echo_server):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            reader, writer = await asyncio.open_connection(server.host, server.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0xFF\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_auth_success(self, echo_server):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            reader, writer = await _socks5_connect(\n                Address(server.host, server.port),\n                echo_server,\n                auth=(\"user\", \"pass\"),\n            )\n            writer.write(b\"secret\")\n            await writer.drain()\n            data = await reader.read(4096)\n            assert data == b\"secret\"\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_auth_failure(self, echo_server):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            reader, writer = await asyncio.open_connection(server.host, server.port)\n            writer.write(b\"\\x05\\x01\\x02\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0x02\n\n            writer.write(b\"\\x01\\x04user\\x04xxxx\")\n            await writer.drain()\n            auth_resp = await reader.readexactly(2)\n            assert auth_resp == b\"\\x01\\x01\"\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass DataCounter(Addon):\n    def __init__(self):\n        self.bytes_up = 0\n        self.bytes_down = 0\n\n    async def on_data(self, direction, data, flow):\n        if direction == Direction.UPSTREAM:\n            self.bytes_up += len(data)\n        else:\n            self.bytes_down += len(data)\n        return data\n\n\nclass TestServerWithAddon:\n    async def test_data_counting(self, echo_server):\n        addon = DataCounter()\n        server, task = await _start_server(addons=[addon])\n        try:\n            reader, writer = await _socks5_connect(\n                Address(server.host, server.port), echo_server\n            )\n            writer.write(b\"hello world\")\n            await writer.drain()\n            data = await reader.read(4096)\n            assert data == b\"hello world\"\n            await asyncio.sleep(0.1)\n\n            assert addon.bytes_up == 11\n            assert addon.bytes_down == 11\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_server_errors.py",
    "content": "\"\"\"Server error handling tests: malformed input, disconnects, error mapping.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server import FlowStats\nfrom asyncio_socks_server.core.types import Address, Rep\nfrom asyncio_socks_server.server.server import Server\nfrom tests.conftest import _start_server, _stop_server\n\n\nasync def _raw_connect(proxy):\n    \"\"\"Open a raw TCP connection to the proxy.\"\"\"\n    return await asyncio.open_connection(proxy.host, proxy.port)\n\n\nasync def _read_reply(reader):\n    \"\"\"Read a SOCKS5 CONNECT reply and return (rep_code, bound_addr).\"\"\"\n    ver, rep, rsv = await reader.readexactly(3)\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n    return rep\n\n\nclass TestHandshakeErrors:\n    async def test_truncated_method_selection(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\")\n            await writer.drain()\n            # Server expects 2 bytes minimum; send 1 then close\n            writer.close()\n            await writer.wait_closed()\n            # Server should handle this without crashing\n            await asyncio.sleep(0.1)\n        finally:\n            await _stop_server(server, task)\n\n    async def test_wrong_socks_version(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x04\\x01\\x00\")\n            await writer.drain()\n            # Server should close or reject — just verify no crash\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_disconnect_after_method_reply(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"  # NO_AUTH selected\n            # Now disconnect without sending a request\n            writer.close()\n            await writer.wait_closed()\n            await asyncio.sleep(0.1)\n        finally:\n            await _stop_server(server, task)\n\n    async def test_disconnect_during_auth(self):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x01\\x02\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0x02  # USERNAME_PASSWORD selected\n            # Disconnect without sending credentials\n            writer.close()\n            await writer.wait_closed()\n            await asyncio.sleep(0.1)\n        finally:\n            await _stop_server(server, task)\n\n    async def test_nmethods_zero(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0xFF  # NO_ACCEPTABLE\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestRequestErrors:\n    async def test_connect_to_refused_port(self):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            # Method selection\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0x00\n\n            # CONNECT to 127.0.0.1:1 (should refuse)\n            writer.write(b\"\\x05\\x01\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\\x01\")\n            await writer.drain()\n\n            rep = await _read_reply(reader)\n            assert rep == Rep.CONNECTION_REFUSED\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_failed_connect_closes_observed_flow(self):\n        stats = FlowStats()\n        server, task = await _start_server(addons=[stats])\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp[1] == 0x00\n\n            writer.write(b\"\\x05\\x01\\x00\\x01\\x7f\\x00\\x00\\x01\\x00\\x01\")\n            await writer.drain()\n\n            rep = await _read_reply(reader)\n            assert rep == Rep.CONNECTION_REFUSED\n            await asyncio.sleep(0.05)\n\n            snapshot = stats.snapshot()\n            assert snapshot[\"active_flows\"] == 0\n            assert snapshot[\"total_flows\"] == 1\n            assert snapshot[\"total_closed_flows\"] == 1\n            assert snapshot[\"closed_flows\"] == 1\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestConnectionDrop:\n    async def test_drop_during_relay(self, echo_server):\n        server, task = await _start_server()\n        try:\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            await reader.readexactly(2)\n\n            from asyncio_socks_server.core.address import encode_address\n\n            writer.write(\n                b\"\\x05\\x01\\x00\" + encode_address(echo_server.host, echo_server.port)\n            )\n            await writer.drain()\n            await _read_reply(reader)\n\n            # Abruptly close\n            writer.close()\n            await writer.wait_closed()\n            await asyncio.sleep(0.1)\n        finally:\n            await _stop_server(server, task)\n\n    async def test_multiple_rapid_connect_disconnect(self):\n        server, task = await _start_server()\n        try:\n            for _ in range(10):\n                reader, writer = await _raw_connect(Address(server.host, server.port))\n                writer.write(b\"\\x05\\x01\\x00\")\n                await writer.drain()\n                await reader.readexactly(2)\n                writer.close()\n                await writer.wait_closed()\n            # Verify server is still responsive\n            reader, writer = await _raw_connect(Address(server.host, server.port))\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestErrorToRep:\n    def test_connection_refused(self):\n        assert Server._error_to_rep(ConnectionRefusedError()) == Rep.CONNECTION_REFUSED\n\n    def test_network_unreachable(self):\n        exc = OSError(101, \"Network is unreachable\")\n        assert Server._error_to_rep(exc) == Rep.NETWORK_UNREACHABLE\n\n    def test_generic_oserror(self):\n        exc = OSError(99, \"Some error\")\n        assert Server._error_to_rep(exc) == Rep.GENERAL_FAILURE\n\n    def test_generic_exception(self):\n        assert Server._error_to_rep(RuntimeError(\"oops\")) == Rep.GENERAL_FAILURE\n"
  },
  {
    "path": "tests/test_server_lifecycle.py",
    "content": "\"\"\"Server lifecycle tests: startup, shutdown, addon lifecycle during shutdown.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom tests.conftest import _start_server, _stop_server\n\n\nclass StopTracker(Addon):\n    def __init__(self):\n        self.started = False\n        self.stopped = False\n\n    async def on_start(self):\n        self.started = True\n\n    async def on_stop(self):\n        self.stopped = True\n\n\nclass TestServerStartup:\n    async def test_server_binds_to_port(self):\n        server, task = await _start_server()\n        try:\n            assert server.port > 0\n        finally:\n            await _stop_server(server, task)\n\n    async def test_server_with_zero_port_gets_ephemeral(self):\n        server, task = await _start_server()\n        try:\n            assert server.port > 0\n            # Verify we can connect\n            _, writer = await asyncio.open_connection(server.host, server.port)\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n\nclass TestServerShutdown:\n    async def test_request_shutdown_stops_server(self):\n        server, task = await _start_server()\n        await _stop_server(server, task)\n        # Task should be done\n        assert task.done()\n\n    async def test_shutdown_calls_addon_stop(self):\n        tracker = StopTracker()\n        server, task = await _start_server(addons=[tracker])\n        assert tracker.started\n        await _stop_server(server, task)\n        assert tracker.stopped\n\n    async def test_shutdown_closes_listening_socket(self):\n        server, task = await _start_server()\n        port = server.port\n        await _stop_server(server, task)\n\n        # New connections should be refused\n        for _ in range(5):\n            try:\n                _, writer = await asyncio.open_connection(server.host, port)\n                writer.close()\n                await writer.wait_closed()\n                # Connection succeeded — server might still be closing\n                await asyncio.sleep(0.1)\n            except (ConnectionError, OSError):\n                return  # Expected\n        # Should have failed by now\n        assert False, \"Server should have closed listening socket\"\n"
  },
  {
    "path": "tests/test_tcp_relay.py",
    "content": "\"\"\"Unit tests for TCP relay: _copy() and handle_tcp_relay().\"\"\"\n\nimport asyncio\nimport socket\n\nimport pytest\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.addons.manager import AddonManager\nfrom asyncio_socks_server.core.types import Address, Direction, Flow\nfrom asyncio_socks_server.server.tcp_relay import _copy, handle_tcp_relay\n\n\ndef _make_flow(**kwargs):\n    defaults = dict(\n        id=1,\n        src=Address(\"127.0.0.1\", 1000),\n        dst=Address(\"127.0.0.1\", 2000),\n        protocol=\"tcp\",\n        started_at=0.0,\n    )\n    defaults.update(kwargs)\n    return Flow(**defaults)\n\n\nasync def _pipe():\n    \"\"\"Create a pipe with two ends.\n\n    Returns (write_end, read_end):\n      - Write to write_end → data appears on read_end.\n    \"\"\"\n    sock_a, sock_b = socket.socketpair()\n    sock_a.setblocking(False)\n    sock_b.setblocking(False)\n    # writer_a writes to sock_a → reader_b reads from sock_b\n    reader_a, writer_a = await asyncio.open_connection(sock=sock_a)\n    reader_b, writer_b = await asyncio.open_connection(sock=sock_b)\n    # writer_a → reader_b is our pipe direction\n    return (writer_a, writer_b, reader_a, reader_b)\n\n\nclass UpperAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return data.upper()\n\n\nclass DropAddon(Addon):\n    async def on_data(self, direction, data, flow):\n        return None\n\n\nclass TestCopy:\n    async def test_copies_data(self):\n        # Input: app writes → _copy reads\n        in_wa, in_wb, in_ra, in_rb = await _pipe()  # in_wa → in_rb\n        # Output: _copy writes → app reads\n        out_wa, out_wb, out_ra, out_rb = await _pipe()  # out_wa → out_rb\n\n        flow = _make_flow()\n        manager = AddonManager()\n        copy_task = asyncio.create_task(\n            _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        )\n\n        in_wa.write(b\"hello\")\n        await in_wa.drain()\n        data = await asyncio.wait_for(out_rb.read(4096), timeout=1.0)\n        assert data == b\"hello\"\n\n        in_wa.close()\n        await in_wa.wait_closed()\n        await asyncio.wait_for(copy_task, timeout=2.0)\n\n    async def test_stops_on_eof(self):\n        in_wa, in_wb, in_ra, in_rb = await _pipe()\n        out_wa, out_wb, out_ra, out_rb = await _pipe()\n\n        flow = _make_flow()\n        manager = AddonManager()\n        in_wa.close()\n        await in_wa.wait_closed()\n\n        copy_task = asyncio.create_task(\n            _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        )\n        await asyncio.wait_for(copy_task, timeout=1.0)\n\n    async def test_addon_pipeline_applied(self):\n        in_wa, in_wb, in_ra, in_rb = await _pipe()\n        out_wa, out_wb, out_ra, out_rb = await _pipe()\n\n        flow = _make_flow()\n        manager = AddonManager([UpperAddon()])\n        copy_task = asyncio.create_task(\n            _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        )\n\n        in_wa.write(b\"hello\")\n        await in_wa.drain()\n        data = await asyncio.wait_for(out_rb.read(4096), timeout=1.0)\n        assert data == b\"HELLO\"\n\n        in_wa.close()\n        await in_wa.wait_closed()\n        await asyncio.wait_for(copy_task, timeout=2.0)\n\n    async def test_addon_returns_none_skips_write(self):\n        in_wa, in_wb, in_ra, in_rb = await _pipe()\n        out_wa, out_wb, out_ra, out_rb = await _pipe()\n\n        flow = _make_flow()\n        manager = AddonManager([DropAddon()])\n        copy_task = asyncio.create_task(\n            _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        )\n\n        in_wa.write(b\"dropped\")\n        await in_wa.drain()\n\n        with pytest.raises(asyncio.TimeoutError):\n            await asyncio.wait_for(out_rb.read(4096), timeout=0.3)\n\n        in_wa.close()\n        await in_wa.wait_closed()\n        await asyncio.wait_for(copy_task, timeout=2.0)\n\n    async def test_connection_error_handled(self):\n        in_wa, in_wb, in_ra, in_rb = await _pipe()\n        out_wa, out_wb, out_ra, out_rb = await _pipe()\n\n        flow = _make_flow()\n        manager = AddonManager()\n        out_wa.close()\n        await out_wa.wait_closed()\n\n        copy_task = asyncio.create_task(\n            _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        )\n\n        in_wa.write(b\"trigger\")\n        await in_wa.drain()\n\n        await asyncio.wait_for(copy_task, timeout=2.0)\n        in_wa.close()\n        await in_wa.wait_closed()\n\n    async def test_writer_closed_on_finish(self):\n        in_wa, in_wb, in_ra, in_rb = await _pipe()\n        out_wa, out_wb, out_ra, out_rb = await _pipe()\n\n        flow = _make_flow()\n        manager = AddonManager()\n        in_wa.close()\n        await in_wa.wait_closed()\n\n        await _copy(in_rb, out_wa, manager, Direction.UPSTREAM, flow)\n        assert out_wa.is_closing()\n\n\nclass TestHandleTcpRelay:\n    async def test_bidirectional_relay(self):\n        # Client pipe: cw_app → cr_relay, cw_relay → cr_app\n        c_sock_a, c_sock_b = socket.socketpair()\n        c_sock_a.setblocking(False)\n        c_sock_b.setblocking(False)\n        cr_app, cw_app = await asyncio.open_connection(sock=c_sock_a)\n        cr_relay, cw_relay = await asyncio.open_connection(sock=c_sock_b)\n\n        # Remote pipe: rw_app → rr_relay, rw_relay → rr_app\n        r_sock_a, r_sock_b = socket.socketpair()\n        r_sock_a.setblocking(False)\n        r_sock_b.setblocking(False)\n        rr_app, rw_app = await asyncio.open_connection(sock=r_sock_a)\n        rr_relay, rw_relay = await asyncio.open_connection(sock=r_sock_b)\n\n        flow = _make_flow()\n        manager = AddonManager()\n\n        relay_task = asyncio.create_task(\n            handle_tcp_relay(cr_relay, cw_relay, rr_relay, rw_relay, manager, flow)\n        )\n\n        # Client → Remote\n        cw_app.write(b\"to-remote\")\n        await cw_app.drain()\n        data = await asyncio.wait_for(rr_app.read(4096), timeout=1.0)\n        assert data == b\"to-remote\"\n\n        # Remote → Client\n        rw_app.write(b\"to-client\")\n        await rw_app.drain()\n        data = await asyncio.wait_for(cr_app.read(4096), timeout=1.0)\n        assert data == b\"to-client\"\n\n        cw_app.close()\n        await cw_app.wait_closed()\n        rw_app.close()\n        await rw_app.wait_closed()\n        await asyncio.wait_for(relay_task, timeout=2.0)\n\n    async def test_relay_stops_when_client_closes(self):\n        c_sock_a, c_sock_b = socket.socketpair()\n        c_sock_a.setblocking(False)\n        c_sock_b.setblocking(False)\n        cr_app, cw_app = await asyncio.open_connection(sock=c_sock_a)\n        cr_relay, cw_relay = await asyncio.open_connection(sock=c_sock_b)\n\n        r_sock_a, r_sock_b = socket.socketpair()\n        r_sock_a.setblocking(False)\n        r_sock_b.setblocking(False)\n        rr_app, rw_app = await asyncio.open_connection(sock=r_sock_a)\n        rr_relay, rw_relay = await asyncio.open_connection(sock=r_sock_b)\n\n        flow = _make_flow()\n        manager = AddonManager()\n\n        relay_task = asyncio.create_task(\n            handle_tcp_relay(cr_relay, cw_relay, rr_relay, rw_relay, manager, flow)\n        )\n\n        cw_app.close()\n        await cw_app.wait_closed()\n        await asyncio.wait_for(relay_task, timeout=2.0)\n"
  },
  {
    "path": "tests/test_udp_associate_hook.py",
    "content": "\"\"\"Tests for the on_udp_associate competitive hook.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server.addons.base import Addon\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\nfrom asyncio_socks_server.server.udp_relay import UdpRelayBase\n\n\nclass _CustomRelay(UdpRelayBase):\n    \"\"\"Minimal UdpRelayBase that records calls.\"\"\"\n\n    def __init__(self):\n        self.started = False\n        self.stopped = False\n        self.client_transport_set = False\n        self.datagrams: list[bytes] = []\n\n    async def start(self) -> Address:\n        self.started = True\n        return Address(\"127.0.0.1\", 12345)\n\n    def set_client_transport(self, transport: asyncio.DatagramTransport) -> None:\n        self.client_transport_set = True\n\n    async def stop(self) -> None:\n        self.stopped = True\n\n    def handle_client_datagram(self, data: bytes, client_addr: tuple[str, int]) -> None:\n        self.datagrams.append(data)\n\n\nclass _CustomAddon(Addon):\n    def __init__(self, relay: UdpRelayBase):\n        self._relay = relay\n\n    async def on_udp_associate(self, flow) -> UdpRelayBase | None:\n        return self._relay\n\n\nclass _PassAddon(Addon):\n    async def on_udp_associate(self, flow) -> UdpRelayBase | None:\n        return None\n\n\nclass _FailingRelay(UdpRelayBase):\n    def __init__(self):\n        self.stopped = False\n\n    async def start(self) -> Address:\n        raise RuntimeError(\"udp relay failed\")\n\n    def set_client_transport(self, transport: asyncio.DatagramTransport) -> None:\n        pass\n\n    async def stop(self) -> None:\n        self.stopped = True\n\n    def handle_client_datagram(self, data: bytes, client_addr: tuple[str, int]) -> None:\n        pass\n\n\nclass _ErrorTracker(Addon):\n    def __init__(self):\n        self.errors: list[Exception] = []\n\n    async def on_error(self, error: Exception) -> None:\n        self.errors.append(error)\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nclass TestUdpAssociateHook:\n    async def test_addon_returns_custom_handler(self):\n        \"\"\"Addon returning a custom UdpRelayBase replaces the default.\"\"\"\n        relay = _CustomRelay()\n        addon = _CustomAddon(relay)\n        server, task = await _start_server(addons=[addon])\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", server.port)\n            # SOCKS5 handshake\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n            # UDP ASSOCIATE\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n            # Read reply\n            reply = await reader.readexactly(3)\n            assert reply[0] == 0x05\n            # reply[1] == 0x00 means success (custom relay returned its addr)\n            # Read bound address\n            atyp = (await reader.readexactly(1))[0]\n            if atyp == 0x01:\n                await reader.readexactly(4 + 2)\n            elif atyp == 0x04:\n                await reader.readexactly(16 + 2)\n            assert relay.started\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_addon_returns_none_uses_default(self):\n        \"\"\"Addon returning None falls through to default UdpRelay.\"\"\"\n        addon = _PassAddon()\n        server, task = await _start_server(addons=[addon])\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", server.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n            reply = await reader.readexactly(3)\n            assert reply[0] == 0x05\n            assert reply[1] == 0x00\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_competitive_first_wins(self):\n        \"\"\"Multiple addons: first non-None result wins.\"\"\"\n        relay_a = _CustomRelay()\n        relay_b = _CustomRelay()\n\n        class AddonA(Addon):\n            async def on_udp_associate(self, flow):\n                return relay_a\n\n        class AddonB(Addon):\n            async def on_udp_associate(self, flow):\n                return relay_b\n\n        server, task = await _start_server(addons=[AddonA(), AddonB()])\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", server.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n            reply = await reader.readexactly(3)\n            assert reply[1] == 0x00\n            await reader.readexactly(1)  # atyp\n            await reader.readexactly(4 + 2)  # ipv4+port\n            assert relay_a.started\n            assert not relay_b.started\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_relay_start_failure_returns_socks_error_and_dispatches_error(self):\n        relay = _FailingRelay()\n        tracker = _ErrorTracker()\n        server, task = await _start_server(\n            addons=[_CustomAddon(relay), tracker],\n        )\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", server.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n\n            reply = await reader.readexactly(3)\n            assert reply == b\"\\x05\\x01\\x00\"\n            atyp = await reader.readexactly(1)\n            assert atyp == b\"\\x01\"\n            await reader.readexactly(4 + 2)\n\n            assert relay.stopped\n            assert len(tracker.errors) == 1\n            assert isinstance(tracker.errors[0], RuntimeError)\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(server, task)\n"
  },
  {
    "path": "tests/test_udp_over_tcp.py",
    "content": "import asyncio\n\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.udp_over_tcp import encode_udp_frame, read_udp_frame\n\n\nclass TestUdpOverTcpFrame:\n    async def test_roundtrip_ipv4(self):\n        addr = Address(\"127.0.0.1\", 1080)\n        payload = b\"hello world\"\n\n        frame = await encode_udp_frame(addr, payload)\n\n        reader = asyncio.StreamReader()\n        reader.feed_data(frame)\n        reader.feed_eof()\n\n        result_addr, result_data = await read_udp_frame(reader)\n        assert result_addr.host == \"127.0.0.1\"\n        assert result_addr.port == 1080\n        assert result_data == payload\n\n    async def test_roundtrip_ipv6(self):\n        addr = Address(\"::1\", 443)\n        payload = b\"test data\"\n\n        frame = await encode_udp_frame(addr, payload)\n\n        reader = asyncio.StreamReader()\n        reader.feed_data(frame)\n        reader.feed_eof()\n\n        result_addr, result_data = await read_udp_frame(reader)\n        assert result_addr.host == \"::1\"\n        assert result_addr.port == 443\n        assert result_data == payload\n\n    async def test_roundtrip_domain(self):\n        addr = Address(\"example.com\", 80)\n        payload = b\"http request\"\n\n        frame = await encode_udp_frame(addr, payload)\n\n        reader = asyncio.StreamReader()\n        reader.feed_data(frame)\n        reader.feed_eof()\n\n        result_addr, result_data = await read_udp_frame(reader)\n        assert result_addr.host == \"example.com\"\n        assert result_addr.port == 80\n        assert result_data == payload\n\n    async def test_multiple_frames(self):\n        frames_data = b\"\"\n        expected = []\n\n        for i in range(3):\n            addr = Address(\"127.0.0.1\", 1000 + i)\n            payload = f\"packet {i}\".encode()\n            frame = await encode_udp_frame(addr, payload)\n            frames_data += frame\n            expected.append((addr, payload))\n\n        reader = asyncio.StreamReader()\n        reader.feed_data(frames_data)\n        reader.feed_eof()\n\n        for exp_addr, exp_data in expected:\n            result_addr, result_data = await read_udp_frame(reader)\n            assert result_addr.host == exp_addr.host\n            assert result_addr.port == exp_addr.port\n            assert result_data == exp_data\n\n    async def test_empty_payload(self):\n        addr = Address(\"10.0.0.1\", 53)\n        payload = b\"\"\n\n        frame = await encode_udp_frame(addr, payload)\n\n        reader = asyncio.StreamReader()\n        reader.feed_data(frame)\n        reader.feed_eof()\n\n        result_addr, result_data = await read_udp_frame(reader)\n        assert result_addr.host == \"10.0.0.1\"\n        assert result_addr.port == 53\n        assert result_data == b\"\"\n"
  },
  {
    "path": "tests/test_udp_over_tcp_e2e.py",
    "content": "\"\"\"End-to-end test: UDP client → Entry SOCKS5 server → Exit server → UDP echo.\"\"\"\n\nimport asyncio\nimport socket\nimport struct\n\nfrom asyncio_socks_server.addons.udp_over_tcp_entry import UdpOverTcpEntry\nfrom asyncio_socks_server.core.protocol import build_udp_header, parse_udp_header\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.server import Server\nfrom asyncio_socks_server.server.udp_over_tcp_exit import UdpOverTcpExitServer\n\n\nasync def _start_server(**kwargs):\n    server = Server(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nasync def _start_exit_server(**kwargs):\n    server = UdpOverTcpExitServer(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_exit_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nasync def _skip_bind_address(reader):\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        await reader.readexactly(4 + 2)\n    elif atyp == 0x04:\n        await reader.readexactly(16 + 2)\n    elif atyp == 0x03:\n        length = (await reader.readexactly(1))[0]\n        await reader.readexactly(length + 2)\n\n\nclass TestUdpOverTcpE2E:\n    async def test_full_chain_udp_roundtrip(self):\n        \"\"\"UDP client → Entry SOCKS5 → Exit server → UDP echo → back.\"\"\"\n\n        # 1. UDP echo server\n        class EchoProtocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport):\n                self.transport = transport\n\n            def datagram_received(self, data, addr):\n                self.transport.sendto(data, addr)\n\n        loop = asyncio.get_running_loop()\n        echo_transport, _ = await loop.create_datagram_endpoint(\n            EchoProtocol, local_addr=(\"127.0.0.1\", 0)\n        )\n        echo_sock = echo_transport.get_extra_info(\"socket\")\n        echo_sockname = echo_sock.getsockname() if echo_sock else (\"127.0.0.1\", 0)\n        echo_addr = Address(echo_sockname[0], echo_sockname[1])\n\n        # 2. Exit server\n        exit_srv, exit_task = await _start_exit_server()\n\n        # 3. Entry SOCKS5 server with UdpOverTcpEntry addon\n        entry_addon = UdpOverTcpEntry(f\"127.0.0.1:{exit_srv.port}\")\n        entry_srv, entry_task = await _start_server(addons=[entry_addon])\n\n        try:\n            # 4. Client: SOCKS5 handshake\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", entry_srv.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n\n            # 5. UDP ASSOCIATE\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n\n            reply = await reader.readexactly(3)\n            assert reply[0] == 0x05\n            assert reply[1] == 0x00\n\n            # Read bind address\n            atyp = (await reader.readexactly(1))[0]\n            if atyp == 0x01:\n                host_bytes = await reader.readexactly(4)\n                import ipaddress\n\n                host = ipaddress.IPv4Address(host_bytes).compressed\n            elif atyp == 0x04:\n                host_bytes = await reader.readexactly(16)\n                host = str(ipaddress.IPv6Address(host_bytes))\n            else:\n                length = (await reader.readexactly(1))[0]\n                host = (await reader.readexactly(length)).decode(\"ascii\")\n            port_bytes = await reader.readexactly(2)\n            port = struct.unpack(\"!H\", port_bytes)[0]\n            udp_bind = Address(host, port)\n\n            # 6. Client sends UDP datagram through the entry server's UDP bind\n            received_future = loop.create_future()\n\n            class ClientProtocol(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    if not received_future.done():\n                        received_future.set_result(data)\n\n            client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n            client_sock.bind((\"127.0.0.1\", 0))\n            client_sock.setblocking(False)\n            client_transport, _ = await loop.create_datagram_endpoint(\n                ClientProtocol, sock=client_sock\n            )\n\n            datagram = build_udp_header(echo_addr) + b\"hello chain\"\n            client_transport.sendto(datagram, (udp_bind.host, udp_bind.port))\n\n            resp_data = await asyncio.wait_for(received_future, timeout=3.0)\n            _, _, payload = parse_udp_header(resp_data)\n            assert payload == b\"hello chain\"\n\n            client_transport.close()\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(entry_srv, entry_task)\n            await _stop_exit_server(exit_srv, exit_task)\n            echo_transport.close()\n\n    async def test_chain_multiple_datagrams(self):\n        \"\"\"Multiple UDP datagrams through the full chain.\"\"\"\n\n        class EchoProtocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport):\n                self.transport = transport\n\n            def datagram_received(self, data, addr):\n                self.transport.sendto(data, addr)\n\n        loop = asyncio.get_running_loop()\n        echo_transport, _ = await loop.create_datagram_endpoint(\n            EchoProtocol, local_addr=(\"127.0.0.1\", 0)\n        )\n        echo_sock = echo_transport.get_extra_info(\"socket\")\n        echo_sockname = echo_sock.getsockname() if echo_sock else (\"127.0.0.1\", 0)\n        echo_addr = Address(echo_sockname[0], echo_sockname[1])\n\n        exit_srv, exit_task = await _start_exit_server()\n        entry_addon = UdpOverTcpEntry(f\"127.0.0.1:{exit_srv.port}\")\n        entry_srv, entry_task = await _start_server(addons=[entry_addon])\n\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", entry_srv.port)\n            writer.write(b\"\\x05\\x01\\x00\")\n            await writer.drain()\n            resp = await reader.readexactly(2)\n            assert resp == b\"\\x05\\x00\"\n\n            writer.write(b\"\\x05\\x03\\x00\\x01\\x00\\x00\\x00\\x00\\x00\\x00\")\n            await writer.drain()\n            reply = await reader.readexactly(3)\n            assert reply[1] == 0x00\n\n            atyp = (await reader.readexactly(1))[0]\n            if atyp == 0x01:\n                host_bytes = await reader.readexactly(4)\n                import ipaddress\n\n                host = ipaddress.IPv4Address(host_bytes).compressed\n            elif atyp == 0x04:\n                host_bytes = await reader.readexactly(16)\n                host = str(ipaddress.IPv6Address(host_bytes))\n            else:\n                length = (await reader.readexactly(1))[0]\n                host = (await reader.readexactly(length)).decode(\"ascii\")\n            port_bytes = await reader.readexactly(2)\n            port = struct.unpack(\"!H\", port_bytes)[0]\n            udp_bind = Address(host, port)\n\n            received_queue: asyncio.Queue[bytes] = asyncio.Queue()\n\n            class ClientProtocol(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    received_queue.put_nowait(data)\n\n            client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n            client_sock.bind((\"127.0.0.1\", 0))\n            client_sock.setblocking(False)\n            client_transport, _ = await loop.create_datagram_endpoint(\n                ClientProtocol, sock=client_sock\n            )\n\n            for i in range(3):\n                datagram = build_udp_header(echo_addr) + f\"pkt{i}\".encode()\n                client_transport.sendto(datagram, (udp_bind.host, udp_bind.port))\n                await asyncio.sleep(0.05)\n\n            for i in range(3):\n                resp_data = await asyncio.wait_for(received_queue.get(), timeout=3.0)\n                _, _, payload = parse_udp_header(resp_data)\n                assert payload == f\"pkt{i}\".encode()\n\n            client_transport.close()\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_server(entry_srv, entry_task)\n            await _stop_exit_server(exit_srv, exit_task)\n            echo_transport.close()\n"
  },
  {
    "path": "tests/test_udp_over_tcp_exit.py",
    "content": "\"\"\"Tests for UdpOverTcpExitServer.\"\"\"\n\nimport asyncio\n\nfrom asyncio_socks_server.core.types import Address\nfrom asyncio_socks_server.server.udp_over_tcp import encode_udp_frame, read_udp_frame\nfrom asyncio_socks_server.server.udp_over_tcp_exit import UdpOverTcpExitServer\n\n\nasync def _start_exit_server(**kwargs):\n    server = UdpOverTcpExitServer(host=\"127.0.0.1\", port=0, **kwargs)\n    task = asyncio.create_task(server._run())\n    for _ in range(50):\n        if server.port != 0:\n            break\n        await asyncio.sleep(0.01)\n    return server, task\n\n\nasync def _stop_exit_server(server, task):\n    server.request_shutdown()\n    await task\n\n\nclass TestUdpOverTcpExit:\n    async def test_tcp_to_udp_roundtrip(self):\n        \"\"\"Send UDP-over-TCP frame → exit server → UDP echo → TCP frame back.\"\"\"\n        # UDP echo server\n        received = []\n\n        class EchoProtocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport):\n                self.transport = transport\n\n            def datagram_received(self, data, addr):\n                received.append((data, addr))\n                self.transport.sendto(data, addr)\n\n        loop = asyncio.get_running_loop()\n        echo_transport, _ = await loop.create_datagram_endpoint(\n            EchoProtocol, local_addr=(\"127.0.0.1\", 0)\n        )\n        echo_sock = echo_transport.get_extra_info(\"socket\")\n        echo_sockname = echo_sock.getsockname() if echo_sock else (\"127.0.0.1\", 0)\n        echo_addr = Address(echo_sockname[0], echo_sockname[1])\n\n        exit_srv, exit_task = await _start_exit_server()\n        try:\n            # Connect to exit server via TCP\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", exit_srv.port)\n\n            # Send UDP-over-TCP frame targeting the echo server\n            frame = await encode_udp_frame(echo_addr, b\"hello exit\")\n            writer.write(frame)\n            await writer.drain()\n\n            # Wait for echo reply to come back via TCP\n            src_addr, payload = await asyncio.wait_for(\n                read_udp_frame(reader), timeout=2.0\n            )\n            assert payload == b\"hello exit\"\n            assert src_addr.host == echo_addr.host\n            assert src_addr.port == echo_addr.port\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_exit_server(exit_srv, exit_task)\n            echo_transport.close()\n\n    async def test_multiple_datagrams(self):\n        \"\"\"Multiple frames in sequence.\"\"\"\n\n        class EchoProtocol(asyncio.DatagramProtocol):\n            def connection_made(self, transport):\n                self.transport = transport\n\n            def datagram_received(self, data, addr):\n                self.transport.sendto(data, addr)\n\n        loop = asyncio.get_running_loop()\n        echo_transport, _ = await loop.create_datagram_endpoint(\n            EchoProtocol, local_addr=(\"127.0.0.1\", 0)\n        )\n        echo_sock = echo_transport.get_extra_info(\"socket\")\n        echo_sockname = echo_sock.getsockname() if echo_sock else (\"127.0.0.1\", 0)\n        echo_addr = Address(echo_sockname[0], echo_sockname[1])\n\n        exit_srv, exit_task = await _start_exit_server()\n        try:\n            reader, writer = await asyncio.open_connection(\"127.0.0.1\", exit_srv.port)\n\n            for i in range(5):\n                frame = await encode_udp_frame(echo_addr, f\"msg{i}\".encode())\n                writer.write(frame)\n            await writer.drain()\n\n            for i in range(5):\n                src_addr, payload = await asyncio.wait_for(\n                    read_udp_frame(reader), timeout=2.0\n                )\n                assert payload == f\"msg{i}\".encode()\n\n            writer.close()\n            await writer.wait_closed()\n        finally:\n            await _stop_exit_server(exit_srv, exit_task)\n            echo_transport.close()\n"
  },
  {
    "path": "tests/test_udp_relay.py",
    "content": "\"\"\"UDP relay tests: UdpRelay unit + UDP ASSOCIATE end-to-end.\"\"\"\n\nimport asyncio\nimport time\n\nfrom asyncio_socks_server.core.address import encode_address\nfrom asyncio_socks_server.core.protocol import build_udp_header\nfrom asyncio_socks_server.core.types import Address, Flow\nfrom asyncio_socks_server.server.udp_relay import UdpRelay\nfrom tests.conftest import _start_server, _stop_server\n\n\ndef _udp_flow():\n    return Flow(\n        id=1,\n        src=Address(\"127.0.0.1\", 12345),\n        dst=Address(\"0.0.0.0\", 0),\n        protocol=\"udp\",\n        started_at=0.0,\n    )\n\n\ndef _build_udp_datagram(dst: Address, payload: bytes) -> bytes:\n    \"\"\"Build a SOCKS5-encapsulated UDP datagram.\"\"\"\n    return build_udp_header(dst) + payload\n\n\nasync def _socks5_udp_associate(proxy: Address, auth=None):\n    \"\"\"Perform SOCKS5 handshake + UDP ASSOCIATE.\n\n    Returns (tcp_reader, tcp_writer, udp_bind_addr).\n    \"\"\"\n    reader, writer = await asyncio.open_connection(proxy.host, proxy.port)\n\n    # Method selection\n    if auth:\n        writer.write(b\"\\x05\\x01\\x02\")\n    else:\n        writer.write(b\"\\x05\\x01\\x00\")\n    await writer.drain()\n    resp = await reader.readexactly(2)\n    assert resp[0] == 0x05\n\n    if auth:\n        assert resp[1] == 0x02\n        uname = auth[0].encode()\n        passwd = auth[1].encode()\n        writer.write(\n            b\"\\x01\"\n            + len(uname).to_bytes(1, \"big\")\n            + uname\n            + len(passwd).to_bytes(1, \"big\")\n            + passwd\n        )\n        await writer.drain()\n        auth_resp = await reader.readexactly(2)\n        assert auth_resp == b\"\\x01\\x00\"\n    else:\n        assert resp[1] == 0x00\n\n    # UDP ASSOCIATE request (dst = 0.0.0.0:0)\n    writer.write(b\"\\x05\\x03\\x00\" + encode_address(\"0.0.0.0\", 0))\n    await writer.drain()\n\n    reply = await reader.readexactly(3)\n    assert reply[0] == 0x05\n    assert reply[1] == 0x00  # succeeded\n\n    # Read bound address\n    atyp = (await reader.readexactly(1))[0]\n    if atyp == 0x01:\n        host_bytes = await reader.readexactly(4)\n        import ipaddress\n\n        bind_host = ipaddress.IPv4Address(host_bytes).compressed\n    elif atyp == 0x04:\n        host_bytes = await reader.readexactly(16)\n        import ipaddress\n\n        bind_host = ipaddress.IPv6Address(host_bytes).compressed\n    else:\n        length = (await reader.readexactly(1))[0]\n        bind_host = (await reader.readexactly(length)).decode(\"ascii\")\n    port_bytes = await reader.readexactly(2)\n    import struct\n\n    bind_port = struct.unpack(\"!H\", port_bytes)[0]\n\n    return reader, writer, Address(bind_host, bind_port)\n\n\nclass TestUdpRelayUnit:\n    async def test_start_returns_bind_address(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            bind_addr = await relay.start()\n            assert bind_addr.port > 0\n            assert bind_addr.host != \"\"\n        finally:\n            await relay.stop()\n\n    async def test_stop_cancels_ttl_task(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        await relay.start()\n        ttl_task = relay._ttl_task\n        await relay.stop()\n        assert ttl_task.cancelled() or ttl_task.done()\n\n    async def test_handle_client_datagram_routes_outbound(self, udp_echo_server):\n        echo_addr, received = udp_echo_server\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            await relay.start()\n\n            datagram = _build_udp_datagram(echo_addr, b\"hello\")\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n\n            # Wait for echo server to receive\n            for _ in range(50):\n                if received:\n                    break\n                await asyncio.sleep(0.02)\n\n            assert len(received) == 1\n            assert received[0][0] == b\"hello\"\n        finally:\n            await relay.stop()\n\n    async def test_handle_client_datagram_empty_payload_ignored(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            await relay.start()\n            # Valid header but empty payload\n            datagram = _build_udp_datagram(Address(\"127.0.0.1\", 80), b\"\")\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n            # No route should be created (empty payload is ignored)\n            assert len(relay._route_map) == 0\n        finally:\n            await relay.stop()\n\n    async def test_handle_client_datagram_malformed_ignored(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            await relay.start()\n            relay.handle_client_datagram(b\"garbage\", (\"127.0.0.1\", 12345))\n            assert len(relay._route_map) == 0\n        finally:\n            await relay.stop()\n\n    async def test_routing_table_entries_created(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            await relay.start()\n            datagram = _build_udp_datagram(Address(\"127.0.0.1\", 80), b\"data\")\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n            assert (\"127.0.0.1\", 80) in relay._route_map\n        finally:\n            await relay.stop()\n\n    async def test_routing_table_entries_refreshed(self):\n        relay = UdpRelay(client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow())\n        try:\n            await relay.start()\n            datagram = _build_udp_datagram(Address(\"127.0.0.1\", 80), b\"data\")\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n            ts1 = relay._route_timestamps[(\"127.0.0.1\", 80)]\n\n            await asyncio.sleep(0.05)\n            relay.handle_client_datagram(datagram, (\"127.0.0.1\", 12345))\n            ts2 = relay._route_timestamps[(\"127.0.0.1\", 80)]\n            assert ts2 > ts1\n        finally:\n            await relay.stop()\n\n\nclass TestUdpRelayTTL:\n    async def test_ttl_cleanup_removes_expired(self):\n        relay = UdpRelay(\n            client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow(), ttl=0.1\n        )\n        try:\n            await relay.start()\n            # Manually inject a route with old timestamp\n            relay._route_map[(\"10.0.0.1\", 80)] = (\"127.0.0.1\", 12345)\n            relay._route_timestamps[(\"10.0.0.1\", 80)] = time.monotonic() - 1.0\n\n            # Wait for TTL cleanup (runs every 60s, but we can trigger manually)\n            # Let's just wait enough time — the cleanup loop runs every 60s,\n            # so we manually trigger it\n            now = time.monotonic()\n            expired = [\n                key\n                for key, ts in relay._route_timestamps.items()\n                if now - ts > relay._ttl\n            ]\n            for key in expired:\n                relay._route_map.pop(key, None)\n                relay._route_timestamps.pop(key, None)\n\n            assert (\"10.0.0.1\", 80) not in relay._route_map\n        finally:\n            await relay.stop()\n\n    async def test_ttl_cleanup_keeps_active(self):\n        relay = UdpRelay(\n            client_addr=Address(\"127.0.0.1\", 12345), flow=_udp_flow(), ttl=300\n        )\n        try:\n            await relay.start()\n            relay._route_map[(\"10.0.0.1\", 80)] = (\"127.0.0.1\", 12345)\n            relay._route_timestamps[(\"10.0.0.1\", 80)] = time.monotonic()\n\n            now = time.monotonic()\n            expired = [\n                key\n                for key, ts in relay._route_timestamps.items()\n                if now - ts > relay._ttl\n            ]\n            assert len(expired) == 0\n            assert (\"10.0.0.1\", 80) in relay._route_map\n        finally:\n            await relay.stop()\n\n\nclass TestUdpAssociateE2E:\n    async def test_udp_associate_handshake(self):\n        \"\"\"Test UDP ASSOCIATE returns a valid bind address.\"\"\"\n        server, task = await _start_server()\n        try:\n            tcp_r, tcp_w, udp_bind = await _socks5_udp_associate(\n                Address(server.host, server.port)\n            )\n            assert udp_bind.port > 0\n            tcp_w.close()\n            await tcp_w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_udp_associate_with_auth(self):\n        server, task = await _start_server(auth=(\"user\", \"pass\"))\n        try:\n            tcp_r, tcp_w, udp_bind = await _socks5_udp_associate(\n                Address(server.host, server.port), auth=(\"user\", \"pass\")\n            )\n            assert udp_bind.port > 0\n            tcp_w.close()\n            await tcp_w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_udp_associate_send_and_receive(self, udp_echo_server):\n        \"\"\"Test sending a UDP datagram through the proxy and receiving the echo.\"\"\"\n        echo_addr, _ = udp_echo_server\n        server, task = await _start_server()\n        try:\n            tcp_r, tcp_w, udp_bind = await _socks5_udp_associate(\n                Address(server.host, server.port)\n            )\n            await asyncio.sleep(0.05)  # Let server settle\n\n            # Set up a UDP client that can send and receive\n            loop = asyncio.get_running_loop()\n            received = asyncio.get_event_loop().create_future()\n\n            class ClientProtocol(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    if not received.done():\n                        received.set_result(data)\n\n            transport, _ = await loop.create_datagram_endpoint(\n                ClientProtocol, local_addr=(\"127.0.0.1\", 0)\n            )\n\n            # Send SOCKS5-encapsulated datagram to proxy's UDP bind\n            datagram = _build_udp_datagram(echo_addr, b\"hello\")\n            transport.sendto(datagram, (udp_bind.host, udp_bind.port))\n\n            # Wait for echo response\n            try:\n                resp_data = await asyncio.wait_for(received, timeout=2.0)\n                from asyncio_socks_server.core.protocol import parse_udp_header\n\n                resp_addr, _, resp_payload = parse_udp_header(resp_data)\n                assert resp_payload == b\"hello\"\n            finally:\n                transport.close()\n                tcp_w.close()\n                await tcp_w.wait_closed()\n        finally:\n            await _stop_server(server, task)\n\n    async def test_tcp_close_ends_relay(self):\n        server, task = await _start_server()\n        try:\n            tcp_r, tcp_w, udp_bind = await _socks5_udp_associate(\n                Address(server.host, server.port)\n            )\n            assert udp_bind.port > 0\n\n            tcp_w.close()\n            await tcp_w.wait_closed()\n            await asyncio.sleep(0.3)\n\n            # Sending to the closed relay should not crash\n            loop = asyncio.get_running_loop()\n\n            class SilentProtocol(asyncio.DatagramProtocol):\n                def datagram_received(self, data, addr):\n                    pass\n\n            transport, _ = await loop.create_datagram_endpoint(\n                SilentProtocol, local_addr=(\"127.0.0.1\", 0)\n            )\n            try:\n                transport.sendto(\n                    _build_udp_datagram(Address(\"127.0.0.1\", 1), b\"x\"),\n                    (udp_bind.host, udp_bind.port),\n                )\n            except OSError:\n                pass\n            transport.close()\n        finally:\n            await _stop_server(server, task)\n"
  }
]