From 9cef0e3ee1078b85e14331cda33f64510fd8fdcc Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 28 May 2026 14:20:31 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=B8=85=E9=99=A4=E6=89=80=E6=9C=89?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 28 - .github/workflows/docker-build.yml | 84 -- .gitignore | 29 - Dockerfile | 51 - README.md | 167 ---- compose.yaml | 16 - design.md | 0 docs/config.md | 54 -- docs/config/assets.md | 34 - docs/config/core.md | 61 -- docs/config/dns.md | 46 - docs/config/inbounds.md | 78 -- docs/config/outbounds-auto-update.md | 22 - docs/config/routing.md | 43 - docs/config/transparent.md | 75 -- docs/infra.md | 220 ----- docs/issues/easytier.md | 74 -- docs/issues/vless-reality-vision-mux.md | 89 -- docs/routing-custom-rules.md | 183 ---- docs/transparent-iptables.md | 302 ------ pyproject.toml | 30 - pyxray/__init__.py | 10 - pyxray/cli.py | 124 --- pyxray/libs/__init__.py | 1 - pyxray/libs/app_data.py | 149 --- pyxray/libs/nodes/__init__.py | 21 - pyxray/libs/nodes/common.py | 27 - pyxray/libs/nodes/errors.py | 13 - pyxray/libs/nodes/importer.py | 43 - pyxray/libs/nodes/manager.py | 110 --- pyxray/libs/nodes/model.py | 61 -- pyxray/libs/nodes/normalize.py | 205 ---- pyxray/libs/nodes/parser.py | 28 - pyxray/libs/nodes/parsers/__init__.py | 11 - pyxray/libs/nodes/parsers/shadowsocks.py | 92 -- pyxray/libs/nodes/parsers/trojan.py | 42 - pyxray/libs/nodes/parsers/vless.py | 60 -- pyxray/libs/nodes/parsers/vmess.py | 118 --- pyxray/libs/nodes/store.py | 92 -- pyxray/libs/xray_asset_settings.py | 75 -- pyxray/libs/xray_assets.py | 334 ------- pyxray/libs/xray_config/__init__.py | 22 - pyxray/libs/xray_config/generator.py | 463 --------- pyxray/libs/xray_config/geoip_dat.py | 73 -- pyxray/libs/xray_config/outbound.py | 225 ----- pyxray/libs/xray_config/settings.py | 242 ----- pyxray/libs/xray_config/store.py | 73 -- pyxray/libs/xray_config/tinytun_config.py | 253 ----- pyxray/libs/xray_config/transparent_rules.py | 685 -------------- pyxray/libs/xray_runtime.py | 314 ------ pyxray/libs/xray_transparent_runtime.py | 265 ------ pyxray/web/__init__.py | 1 - pyxray/web/activity_log.py | 14 - pyxray/web/dashboard.py | 55 -- pyxray/web/jobs.py | 72 -- pyxray/web/nodes.py | 89 -- pyxray/web/server.py | 98 -- pyxray/web/static/css/app.css | 183 ---- pyxray/web/static/js/app.js | 562 ----------- pyxray/web/templates/configs/core.html | 26 - pyxray/web/templates/configs/dns.html | 55 -- pyxray/web/templates/configs/inbounds.html | 56 -- pyxray/web/templates/configs/routing.html | 18 - pyxray/web/templates/configs/transparent.html | 64 -- pyxray/web/templates/index.html | 57 -- pyxray/web/templates/partials/config_tab.html | 20 - .../web/templates/partials/download_tab.html | 89 -- pyxray/web/templates/partials/logs_tab.html | 22 - pyxray/web/templates/partials/nodes_tab.html | 23 - pyxray/web/xray_assets.py | 233 ----- pyxray/web/xray_config.py | 257 ----- pyxray/web/xray_service.py | 170 ---- scripts/build.sh | 29 - tests/libs/test_node_manager.py | 72 -- tests/libs/test_nodes.py | 212 ----- tests/libs/test_tinytun_config.py | 71 -- tests/libs/test_transparent_rules.py | 305 ------ tests/libs/test_xray_assets.py | 208 ---- tests/libs/test_xray_config.py | 458 --------- tests/libs/test_xray_runtime.py | 119 --- tests/test_cli.py | 138 --- tests/web/test_server_startup.py | 12 - tests/web/test_xray_assets_web.py | 890 ------------------ uv.lock | 197 ---- 84 files changed, 10792 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/docker-build.yml delete mode 100644 .gitignore delete mode 100644 Dockerfile delete mode 100644 README.md delete mode 100644 compose.yaml delete mode 100644 design.md delete mode 100644 docs/config.md delete mode 100644 docs/config/assets.md delete mode 100644 docs/config/core.md delete mode 100644 docs/config/dns.md delete mode 100644 docs/config/inbounds.md delete mode 100644 docs/config/outbounds-auto-update.md delete mode 100644 docs/config/routing.md delete mode 100644 docs/config/transparent.md delete mode 100644 docs/infra.md delete mode 100644 docs/issues/easytier.md delete mode 100644 docs/issues/vless-reality-vision-mux.md delete mode 100644 docs/routing-custom-rules.md delete mode 100644 docs/transparent-iptables.md delete mode 100644 pyproject.toml delete mode 100644 pyxray/__init__.py delete mode 100644 pyxray/cli.py delete mode 100644 pyxray/libs/__init__.py delete mode 100644 pyxray/libs/app_data.py delete mode 100644 pyxray/libs/nodes/__init__.py delete mode 100644 pyxray/libs/nodes/common.py delete mode 100644 pyxray/libs/nodes/errors.py delete mode 100644 pyxray/libs/nodes/importer.py delete mode 100644 pyxray/libs/nodes/manager.py delete mode 100644 pyxray/libs/nodes/model.py delete mode 100644 pyxray/libs/nodes/normalize.py delete mode 100644 pyxray/libs/nodes/parser.py delete mode 100644 pyxray/libs/nodes/parsers/__init__.py delete mode 100644 pyxray/libs/nodes/parsers/shadowsocks.py delete mode 100644 pyxray/libs/nodes/parsers/trojan.py delete mode 100644 pyxray/libs/nodes/parsers/vless.py delete mode 100644 pyxray/libs/nodes/parsers/vmess.py delete mode 100644 pyxray/libs/nodes/store.py delete mode 100644 pyxray/libs/xray_asset_settings.py delete mode 100644 pyxray/libs/xray_assets.py delete mode 100644 pyxray/libs/xray_config/__init__.py delete mode 100644 pyxray/libs/xray_config/generator.py delete mode 100644 pyxray/libs/xray_config/geoip_dat.py delete mode 100644 pyxray/libs/xray_config/outbound.py delete mode 100644 pyxray/libs/xray_config/settings.py delete mode 100644 pyxray/libs/xray_config/store.py delete mode 100644 pyxray/libs/xray_config/tinytun_config.py delete mode 100644 pyxray/libs/xray_config/transparent_rules.py delete mode 100644 pyxray/libs/xray_runtime.py delete mode 100644 pyxray/libs/xray_transparent_runtime.py delete mode 100644 pyxray/web/__init__.py delete mode 100644 pyxray/web/activity_log.py delete mode 100644 pyxray/web/dashboard.py delete mode 100644 pyxray/web/jobs.py delete mode 100644 pyxray/web/nodes.py delete mode 100644 pyxray/web/server.py delete mode 100644 pyxray/web/static/css/app.css delete mode 100644 pyxray/web/static/js/app.js delete mode 100644 pyxray/web/templates/configs/core.html delete mode 100644 pyxray/web/templates/configs/dns.html delete mode 100644 pyxray/web/templates/configs/inbounds.html delete mode 100644 pyxray/web/templates/configs/routing.html delete mode 100644 pyxray/web/templates/configs/transparent.html delete mode 100644 pyxray/web/templates/index.html delete mode 100644 pyxray/web/templates/partials/config_tab.html delete mode 100644 pyxray/web/templates/partials/download_tab.html delete mode 100644 pyxray/web/templates/partials/logs_tab.html delete mode 100644 pyxray/web/templates/partials/nodes_tab.html delete mode 100644 pyxray/web/xray_assets.py delete mode 100644 pyxray/web/xray_config.py delete mode 100644 pyxray/web/xray_service.py delete mode 100644 scripts/build.sh delete mode 100644 tests/libs/test_node_manager.py delete mode 100644 tests/libs/test_nodes.py delete mode 100644 tests/libs/test_tinytun_config.py delete mode 100644 tests/libs/test_transparent_rules.py delete mode 100644 tests/libs/test_xray_assets.py delete mode 100644 tests/libs/test_xray_config.py delete mode 100644 tests/libs/test_xray_runtime.py delete mode 100644 tests/test_cli.py delete mode 100644 tests/web/test_server_startup.py delete mode 100644 tests/web/test_xray_assets_web.py delete mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 46e63f7..0000000 --- a/.dockerignore +++ /dev/null @@ -1,28 +0,0 @@ -.git -.gitignore -.dockerignore - -.venv -venv -env - -__pycache__ -*.py[cod] -*.pyo -*.pyd -.pytest_cache -.ruff_cache -.mypy_cache -.pyre -.coverage -htmlcov - -data -.v2rayA - -*.tmp -*.log - -Dockerfile -docs -tests diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index 7d1bb5d..0000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Docker Build - -on: - push: - branches: - - "**" - tags: - - "v*" - paths: - - ".github/workflows/**" - workflow_dispatch: - -env: - REGISTRY: docker.pchuan.top - IMAGE_NAME: pyxray - APT_MIRROR: https://mirrors.ustc.edu.cn/debian - UV_INDEX_URL: https://pypi.mirrors.ustc.edu.cn/simple/ - -jobs: - docker-build: - runs-on: ubuntu-22.04 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Read project version - id: version - shell: bash - run: | - version="$(awk -F '"' '/^version = / { print $2; exit }' pyproject.toml)" - - if [ -z "$version" ]; then - echo "Failed to read project.version from pyproject.toml." - exit 1 - fi - - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "image=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT" - - - name: Validate git tag version - if: startsWith(github.ref, 'refs/tags/') - shell: bash - env: - PROJECT_VERSION: ${{ steps.version.outputs.version }} - run: | - tag="${GITHUB_REF_NAME}" - expected="v${PROJECT_VERSION}" - - if [ "$tag" != "$expected" ]; then - echo "Git tag '${tag}' does not match pyproject.toml version '${PROJECT_VERSION}'. Expected '${expected}'." - exit 1 - fi - - - name: Build Docker image - shell: bash - env: - IMAGE: ${{ steps.version.outputs.image }} - VERSION: ${{ steps.version.outputs.version }} - run: | - docker build \ - --build-arg "APT_MIRROR=${APT_MIRROR}" \ - --build-arg "UV_INDEX_URL=${UV_INDEX_URL}" \ - -t "${IMAGE}:latest" \ - -t "${IMAGE}:${VERSION}" \ - . - - - name: Login Docker registry - if: startsWith(github.ref, 'refs/tags/') - shell: bash - run: | - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \ - -u "${{ secrets.REGISTRY_USERNAME }}" \ - --password-stdin - - - name: Push Docker image - if: startsWith(github.ref, 'refs/tags/') - shell: bash - env: - IMAGE: ${{ steps.version.outputs.image }} - VERSION: ${{ steps.version.outputs.version }} - run: | - docker push "${IMAGE}:latest" - docker push "${IMAGE}:${VERSION}" diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 54869fa..0000000 --- a/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*.pyo -*.pyd -.pytest_cache/ -.ruff_cache/ -.mypy_cache/ -.pyre/ -.coverage -htmlcov/ - -# Virtual environments -.venv/ -venv/ -env/ - -# Local runtime data -nodes.toml -state.toml -settings.toml -runtime.toml -config.json -xray.log -xray/ -*.tmp - -.v2rayA -data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ca76b2f..0000000 --- a/Dockerfile +++ /dev/null @@ -1,51 +0,0 @@ -FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim - -WORKDIR /app - -ARG APT_MIRROR="https://mirrors.ustc.edu.cn/debian" -ARG UV_INDEX_URL="https://pypi.mirrors.ustc.edu.cn/simple/" - -ENV UV_INDEX_URL="${UV_INDEX_URL}" \ - PYTHONUNBUFFERED=1 \ - PYXRAY_HOST=0.0.0.0 \ - PYXRAY_PORT=8080 \ - PYXRAY_XRAY_DIR=/config/xray - -RUN if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ - sed -i "s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g" /etc/apt/sources.list.d/debian.sources; \ - fi \ - && if [ -f /etc/apt/sources.list ]; then \ - sed -i "s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" /etc/apt/sources.list; \ - fi \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - iproute2 \ - iptables \ - nftables \ - unzip \ - && rm -rf /var/lib/apt/lists/* - -COPY pyproject.toml uv.lock README.md ./ -COPY pyxray ./pyxray - -RUN uv sync --frozen --no-dev \ - && uv pip install --python /app/.venv/bin/python gunicorn==23.0.0 - -RUN printf '%s\n' \ - '#!/bin/sh' \ - 'set -eu' \ - 'mkdir -p "$PYXRAY_XRAY_DIR"' \ - 'exec /app/.venv/bin/gunicorn --bind "$PYXRAY_HOST:$PYXRAY_PORT" --workers 1 --threads 8 --timeout 120 --access-logfile - --error-logfile - "pyxray.web.server:create_app(\"$PYXRAY_XRAY_DIR\")"' \ - > /usr/local/bin/pyxray-entrypoint \ - && chmod 0755 /usr/local/bin/pyxray-entrypoint - -VOLUME ["/config"] - -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -fsS "http://127.0.0.1:${PYXRAY_PORT}/" >/dev/null || exit 1 - -CMD ["/usr/local/bin/pyxray-entrypoint"] diff --git a/README.md b/README.md deleted file mode 100644 index 1e2e501..0000000 --- a/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# pyxray 使用手册 - -`pyxray` 是一个轻量级 Xray 控制面板。它负责下载 Xray 运行资源、导入代理节点、生成 Xray 配置、启动/停止 Xray,并可在 Linux/Docker 环境下为宿主机提供透明代理。 - -当前实现重点: - -| 能力 | 状态 | 说明 | -| --- | --- | --- | -| Xray 资源管理 | 已实现 | 下载/检查 `xray`、`geoip.dat`、`geosite.dat`。 | -| 节点导入 | 已实现 | 支持 `vless`、`vmess`、`trojan`、`trojan-go`、`shadowsocks`。 | -| 配置生成 | 已实现 | 根据选中节点和 `settings.toml` 生成 `config.json`。 | -| Xray 运行控制 | 已实现 | Web 内启动/停止由 pyxray 托管的 Xray 子进程。 | -| 透明代理 | 已实现 | 生成并执行 `redirect` / `tproxy` / `system_proxy` / `tun` 相关配置和脚本。 | -| 订阅 | 未实现 | 当前只支持手动导入节点链接。 | -| 登录认证 | 未实现 | 默认不要直接暴露到不可信网络。 | - -## 运行机制 - -```mermaid -flowchart LR - UI[Web UI] --> API[Flask/Gunicorn API] - API --> Nodes[nodes.toml] - API --> Settings[settings.toml] - API --> Assets[download.toml + xray assets] - API --> Gen[config.json + transparent scripts] - Gen --> Xray[Xray process] - API --> Runtime[TransparentRuntime] - Runtime --> HostNet[iptables/nft/ip rule/resolv.conf] - Xray --> Proxy[Selected outbound node] -``` - -默认数据目录: - -| 文件/目录 | 作用 | -| --- | --- | -| `data/nodes.toml` | 保存节点列表和当前选中节点。 | -| `data/settings.toml` | 保存配置页设置。 | -| `data/download.toml` | 保存下载页设置。 | -| `data/config.json` | 生成给 Xray 使用的配置。 | -| `data/xray/` | 保存 `xray` / `xray.exe`、`geoip.dat`、`geosite.dat`。 | -| `data/transparent/` | 保存透明代理脚本、nftables 配置和 `tinytun.yaml`。 | -| `data/xray.log` | 保存 Xray 输出和 pyxray 运行日志。 | - -## Docker 部署 - -构建镜像: - -```bash -sh scripts/build.sh -``` - -启动服务: - -```bash -docker compose up -d --build -``` - -访问: - -```text -http://:8080 -``` - -当前 `compose.yaml` 使用: - -| 配置 | 作用 | -| --- | --- | -| `network_mode: host` | 让容器直接使用宿主机网络,透明代理规则作用于宿主机网络栈。 | -| `privileged: true` | 允许执行 `iptables`、`nft`、`ip rule`、写 `/proc/sys/net/...`。 | -| `./data:/config` | 持久化 pyxray 数据和 Xray 资源。 | -| `/etc/resolv.conf:/etc/resolv.conf` | 允许 DNS 劫持脚本修改宿主机 DNS。 | -| `/lib/modules:/lib/modules:ro` | 读取宿主机内核模块信息。 | - -## Web 使用流程 - -1. 打开 Web 控制台。 -2. 在“下载”页检查或下载 Xray 资源。 -3. 在“节点”页导入节点链接。 -4. 选择一个当前节点。 -5. 在“配置”页调整入站、路由、DNS、透明代理。 -6. 保存设置。 -7. 点击“启动 Xray”。 -8. 在“日志”页确认 Xray 和透明代理脚本执行结果。 - -## CLI - -启动 Web 控制台: - -```bash -pyxray web --host 127.0.0.1 --port 3309 --xray-dir data/xray -``` - -查看配置文件: - -```bash -pyxray configs --download -pyxray configs --settings -``` - -清理配置/下载资源: - -```bash -pyxray clear --download -pyxray clear --all -``` - -直接下载或补齐 Xray 资源: - -```bash -pyxray download --target all --directory data/xray --force -pyxray download --target geoip --geoip-url https://example.invalid/geoip.dat -``` - -## 透明代理建议 - -| 场景 | 建议 | -| --- | --- | -| 只代理本机 TCP 流量 | `transparent.mode = proxy`,`transparent.type = redirect`。 | -| 国内直连、国外代理 | `transparent.mode = whitelist`,`transparent.type = redirect`。 | -| 需要 UDP/TProxy | 使用 `transparent.type = tproxy`,确认宿主机内核和防火墙支持。 | -| 只想给应用显式设置代理 | 使用 `system_proxy` 或普通 rule HTTP/SOCKS 入站。 | -| Docker 容器流量也要透明代理 | 开启 `docker_transparent` 并确认 `docker_transparent_cidrs` 覆盖实际 Docker 网段。 | - -## 注意事项 - -| 项目 | 说明 | -| --- | --- | -| 端口冲突 | 启动 Xray 前会检查生成配置里的入站端口是否可用。 | -| 透明代理权限 | Docker 透明代理部署需要 `network_mode: host` 和 `privileged: true`。 | -| DNS 劫持 | `redirect + local_dns_listen` 会改写 `/etc/resolv.conf`。 | -| 多 worker | 不要把 Gunicorn 改成多 worker;当前内存任务表和 Xray 子进程状态不能跨进程共享。 | -| Web 暴露 | 当前没有认证,建议只在可信局域网使用。 | -| 自动订阅 | 当前不支持订阅更新,节点需要手动导入。 | - -## 阅读和修改代码 - -| 路径 | 职责 | -| --- | --- | -| `pyxray/cli.py` | CLI 入口,默认启动 Web。 | -| `pyxray/web/server.py` | Flask app 装配。 | -| `pyxray/web/*.py` | Web API:节点、下载、配置生成、服务控制。 | -| `pyxray/web/templates/` | Web UI 模板。 | -| `pyxray/web/static/` | 前端交互逻辑和样式。 | -| `pyxray/libs/nodes/` | 节点链接解析、标准化、持久化。 | -| `pyxray/libs/xray_config/` | Xray JSON、透明代理脚本、TinyTun 配置生成。 | -| `pyxray/libs/xray_runtime.py` | Xray 子进程生命周期和日志转发。 | -| `pyxray/libs/xray_transparent_runtime.py` | 透明代理脚本执行、回滚和本地 CIDR watcher。 | -| `tests/` | 单元测试和 Web API 测试。 | - -详细结构和调用时序见 [docs/infra.md](docs/infra.md)。 - -## 配置文档 - -配置总览见 [docs/config.md](docs/config.md)。 - -按分类阅读: - -| 分类 | 文档 | -| --- | --- | -| 核心 | [docs/config/core.md](docs/config/core.md) | -| 入站 | [docs/config/inbounds.md](docs/config/inbounds.md) | -| 路由 | [docs/config/routing.md](docs/config/routing.md) | -| DNS | [docs/config/dns.md](docs/config/dns.md) | -| 透明代理 | [docs/config/transparent.md](docs/config/transparent.md) | -| 透明代理 iptables 排查 | [docs/transparent-iptables.md](docs/transparent-iptables.md) | -| 出站和自动更新 | [docs/config/outbounds-auto-update.md](docs/config/outbounds-auto-update.md) | -| Xray 资源下载 | [docs/config/assets.md](docs/config/assets.md) | diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index 9e7e040..0000000 --- a/compose.yaml +++ /dev/null @@ -1,16 +0,0 @@ -services: - pyxray: - image: docker.pchuan.top/pyxray:latest - container_name: pyxray - restart: unless-stopped - privileged: true - network_mode: host - environment: - PYXRAY_HOST: 127.0.0.1 - PYXRAY_PORT: 13999 - PYXRAY_XRAY_DIR: /config/xray - volumes: - - ./data:/config - - /lib/modules:/lib/modules:ro - - /etc/resolv.conf:/etc/resolv.conf - stop_grace_period: 15s diff --git a/design.md b/design.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index b399110..0000000 --- a/docs/config.md +++ /dev/null @@ -1,54 +0,0 @@ -# pyxray 配置总览 - -配置来源分两类: - -| 文件 | 数据结构 | 入口 | 作用 | -| --- | --- | --- | --- | -| `settings.toml` | `XrayConfigSettings` | 配置页 | 生成 `config.json`、透明代理脚本和 `tinytun.yaml`。 | -| `download.toml` | `XrayAssetSettings` | 下载页 | 保存 Xray 资源下载参数。 | - -UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列含义: - -| UI | 含义 | -| --- | --- | -| 显示 | Web 配置页可直接修改。 | -| 隐藏 | Web 不显示,但保存时会写入默认值或保留已有值。 | -| 下载页 | 不属于配置页,在下载页显示。 | - -## 分类 - -| 分类 | 文档 | 主要影响 | -| --- | --- | --- | -| 核心 | [config/core.md](config/core.md) | Xray 日志、Mux、TCP Fast Open。 | -| 入站 | [config/inbounds.md](config/inbounds.md) | Mixed/rule/API/自定义入站。 | -| 路由 | [config/routing.md](config/routing.md) | rule 入站和透明代理流量的分流策略。 | -| DNS | [config/dns.md](config/dns.md) | Xray DNS 模块、本地 DNS 入站、DNS 服务器路由。 | -| 透明代理 | [config/transparent.md](config/transparent.md) | transparent inbound、iptables/nft、resolv、TinyTun。 | -| 透明代理 iptables 排查 | [transparent-iptables.md](transparent-iptables.md) | 当前 redirect 规则、宿主机查看方式、链和计数器含义。 | -| 出站和自动更新 | [config/outbounds-auto-update.md](config/outbounds-auto-update.md) | 出站组预留字段、自动更新预留字段。 | -| 资源下载 | [config/assets.md](config/assets.md) | `xray`、`geoip.dat`、`geosite.dat` 下载设置。 | - -## 配置生成结果 - -| 输出 | 触发 | 说明 | -| --- | --- | --- | -| `config.json` | 生成配置/启动 Xray | Xray 主配置。 | -| `transparent/ip-forward-apply.sh` | 生成配置/启动 Xray | 写 `/proc/sys/net/...`。 | -| `transparent/resolv-hijack-setup.sh` | 生成配置/启动 Xray | redirect DNS 劫持时改 `/etc/resolv.conf`。 | -| `transparent/resolv-hijack-cleanup.sh` | 停止/回滚 | 恢复 DNS。 | -| `transparent/transparent-iptables-*.sh` | 生成配置/启动/停止 | iptables 规则安装和清理。 | -| `transparent/transparent-nft-*.sh` | 生成配置/启动/停止 | nftables 规则安装和清理。 | -| `transparent/v2raya.nft` | nft 模式 | nftables 表内容。 | -| `transparent/tinytun.yaml` | `transparent.type = tun` | TinyTun 配置。 | - -## 关键默认值 - -| 设置 | 默认值 | 说明 | -| --- | --- | --- | -| `core.log_level` | `info` | 日常日志等级。 | -| `inbounds.rule_http_port` | `20172` | 规则 Mixed 代理入口。 | -| `routing.mode` | `whitelist` | 国内/私有直连,其它代理。 | -| `transparent.mode` | `close` | 默认不启用透明代理。 | -| `transparent.type` | `redirect` | 推荐先用 redirect。 | -| `dns.query_strategy` | `UseIPv4` | 默认优先 IPv4。 | -| `dns.local_dns_listen` | `true` | redirect 透明代理下生成本地 DNS 入站。 | diff --git a/docs/config/assets.md b/docs/config/assets.md deleted file mode 100644 index df8883d..0000000 --- a/docs/config/assets.md +++ /dev/null @@ -1,34 +0,0 @@ -# Xray 资源下载配置 - -对应 `download.toml` 和 `XrayAssetSettings`,在“下载”页显示。 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `directory` | 下载页 | `data/xray`;Docker 中通常为 `/config/xray` | 路径 | Xray 资源保存目录。 | Docker 部署保持 `/config/xray`;本机运行可用默认值。 | -| `version` | 下载页 | 首次打开时优先使用最新 release;获取失败回退 `v26.5.9` | Xray release tag | 官方 release 版本。 | 需要固定或升级 Xray 版本时修改。 | -| `archive_url` | 下载页 | `""` | URL | 自定义 Xray release zip 地址;为空时用官方地址。 | 官方下载慢或使用镜像时修改。 | -| `geoip_url` | 下载页 | `""` | URL | 自定义 `geoip.dat` 下载地址。 | 需要替换 geoip 数据源时修改。 | -| `geosite_url` | 下载页 | `""` | URL | 自定义 `geosite.dat` 下载地址。 | 需要替换 geosite 数据源时修改。 | -| `proxy_url` | 下载页 | `""` | HTTP/HTTPS 代理 URL | 下载资源时使用的代理。 | 服务器直连 GitHub 慢或失败时修改。 | -| `target` | 下载页 | `all` | `all` / `xray` / `geoip` / `geosite` | 本次下载目标。 | 只更新某个资源时修改。 | -| `force` | 下载页 | `false` | `bool` | 已存在文件是否覆盖。 | 要强制重新下载时开启。 | - -## 必需文件 - -| 文件 | 作用 | -| --- | --- | -| `xray` / `xray.exe` | Xray 可执行文件;Windows 下为 `xray.exe`,其它平台为 `xray`。 | -| `geoip.dat` | IP 地理库,用于 `geoip:*` 规则。 | -| `geosite.dat` | 域名分类库,用于 `geosite:*` 规则。 | - -## 下载行为 - -| 条件 | 行为 | -| --- | --- | -| `target = all` | 确保三个必需文件都存在。 | -| 首次无 `download.toml` | 尝试在 5 秒内获取 Xray-core 最新 release tag;失败则使用内置回退版本。 | -| `force = false` 且文件存在 | 跳过已有文件。 | -| `force = true` | 覆盖目标文件。 | -| `archive_url` 为空 | 按当前平台选择官方 release zip;Windows x64 使用 `Xray-windows-64.zip`,Linux x64 使用 `Xray-linux-64.zip`。 | -| `geoip_url` / `geosite_url` 非空 | 对应 dat 文件使用自定义 URL,优先于 release zip 内置版本。 | -| Docker 部署 | 资源仍由 Web 下载页处理,不在 Dockerfile 中下载。 | diff --git a/docs/config/core.md b/docs/config/core.md deleted file mode 100644 index 3280e23..0000000 --- a/docs/config/core.md +++ /dev/null @@ -1,61 +0,0 @@ -# 核心配置 - -对应 `settings.toml` 的 `[core]`。 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `log_level` | 显示 | `info` | `debug` / `info` / `warning` / `error` / `none` | 写入 Xray `log.loglevel`。`debug` 便于排查,`none` 基本关闭日志。 | 排查启动失败、路由/DNS 行为异常时改 `debug`;日常用 `info`。 | -| `mux_enabled` | 间接显示 | `false` | `bool` | 控制主 `proxy` outbound 是否生成 `mux`。UI 通过 `mux_concurrency = 0` 表示关闭。 | 节点支持且希望复用连接时开启;兼容性异常时关闭。 | -| `mux_concurrency` | 显示 | `8` | UI:`0/1/2/4/8/16/32/64`;模型:`1-1024` | `mux.concurrency`。UI 选择 `0` 时保存为 `mux_enabled = false` 且并发恢复为 `8`。 | 高并发小连接场景可尝试 `8/16`;不确定用 `0`。 | -| `tcp_fast_open` | 隐藏 | `default` | `default` / `yes` / `no` | 非 `default` 时写入 outbound `streamSettings.sockopt.tcpFastOpen`。 | 只有明确知道系统和网络支持 TFO 时修改。 | -| `transparent.output_bypass_rules` | 显示 | `""` | 每行一条 `tcp/udp/all 目标[:端口]` | 在 transparent 系统规则的本机 `OUTPUT` 链前置 RETURN,避免宿主机进程被透明代理截获。 | easytier、其它 host network 服务需要直连固定 peer 时修改。 | -| `ss_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 | -| `trojan_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 | - -## transparent - -核心卡片里的 `transparent` 输入框对应 `[transparent].output_bypass_rules`。 - -格式: - -```text -tcp 117.72.47.28:33010 -all 192.168.0.0/24 -udp 198.51.100.10:3478 -``` - -规则含义: - -| 写法 | 作用 | -| --- | --- | -| `tcp 117.72.47.28:33010` | 本机 TCP 访问该 IP 和端口时直连,不进入 transparent。 | -| `all 192.168.0.0/24` | 本机访问该网段时直连;redirect 下只生成 TCP,tproxy 下生成 TCP 和 UDP。 | -| `udp 198.51.100.10:3478` | tproxy 下本机 UDP 访问该 IP 和端口时直连;redirect 下忽略 UDP。 | - -典型场景: - -```text -tcp 117.72.47.28:33010 -``` - -用于避免 easytier 这类 `network_mode: host` 服务的 peer 连接被 `nat OUTPUT -> TP_OUT -> REDIRECT` 截获。 - -生成顺序: - -```sh -iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN -iptables -t nat -A TP_OUT -j TP_RULE -``` - -该设置只影响宿主机本机 `OUTPUT` 流量,不影响 Docker 容器透明代理的 `PREROUTING` 流量。 - -## 生成影响 - -| 条件 | 生成结果 | -| --- | --- | -| `log_level = debug` | `log.loglevel = debug`,`access = ""`,`error = ""`。 | -| `log_level = warning` | `log.loglevel = warning`,`access = "none"`。 | -| `log_level = none` | `log.loglevel = none`,`access = "none"`,`error = "none"`。 | -| `mux_enabled = true` | 主代理 outbound 增加 `mux.enabled = true` 和 `mux.concurrency`。 | -| `tcp_fast_open != default` | `proxy` / `direct` outbound 增加 `sockopt.tcpFastOpen`。 | -| `transparent.output_bypass_rules` 非空 | 在 `TP_OUT` 跳转 `TP_RULE` 前生成 OUTPUT 绕过 RETURN 规则。 | diff --git a/docs/config/dns.md b/docs/config/dns.md deleted file mode 100644 index 7cb8a44..0000000 --- a/docs/config/dns.md +++ /dev/null @@ -1,46 +0,0 @@ -# DNS 配置 - -对应 `settings.toml` 的 `[dns]` 和 `[[dns.rules]]`。 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `query_strategy` | 显示 | `UseIPv4` | 空 / `UseIP` / `UseIPv4` / `UseIPv6` | 写入 `dns.queryStrategy`。空值表示不写该字段。 | IPv6 可用时可改 `UseIP` / `UseIPv6`;IPv6 不稳定保持 `UseIPv4`。 | -| `disable_fallback` | 显示 | `false` | `bool` | 为 `true` 时写入 `dns.disableFallback`。 | 想严格按 DNS 规则解析时开启;解析容错下降。 | -| `local_dns_listen` | 显示 | `true` | `bool` | 控制是否生成本地 DNS 入站。 | redirect 透明代理下需要接管宿主机 DNS 时开启。 | -| `hosts` | 隐藏 | `{courier.push.apple.com = ["1-courier.push.apple.com"]}` | 字典 | 写入 `dns.hosts`。 | 需要固定域名解析时手改。 | -| `rules` | 显示 | 见下表 | `server|domains|outbound` | 生成 `dns.servers`,并为 DNS 服务器自身生成 routing。 | 调整国内/国外 DNS、DoH、DNS 出口时修改。 | -| `antipollution` | 显示 | `closed` | `closed` / `none` / `dnsforward` / `doh` / `advanced` | 预留字段,当前不直接影响 Xray JSON。 | 当前一般不改。 | -| `special_mode` | 显示 | `none` | `none` / `supervisor` / `fakedns` | 预留字段,当前不直接影响 Xray JSON。 | 当前一般不改。 | - -## 默认 DNS 规则 - -| server | domains | outbound | 作用 | -| --- | --- | --- | --- | -| `localhost` | `geosite:private` | `direct` | 私有域名走本地 DNS。 | -| `223.5.5.5` | `geosite:cn` | `direct` | 中国域名走国内 DNS。 | -| `8.8.8.8` | 空 | `proxy` | 兜底 DNS 走代理。 | - -## DNS 规则字段 - -| 字段 | 默认值 | 可选值 | 作用 | -| --- | --- | --- | --- | -| `server` | 无 | `localhost`、IP、`host:port`、DoH URL | DNS 服务器地址。 | -| `domains` | `""` | 换行/规则字符串 | 非空时只匹配这些域名规则;空表示默认 DNS。 | -| `outbound` | `direct` | `direct` / `proxy` / `block` / 自定义 tag | 连接该 DNS 服务器自身时使用的出口。 | - -## 本地 DNS 入站 - -| 条件 | 生成结果 | -| --- | --- | -| `local_dns_listen = true` 且 `transparent.mode != close` 且 `transparent.type = redirect` | 生成 `dns-in`。 | -| 同时 `inbounds.port_sharing = true` | 额外生成 `dns-in-local` 和 `0.0.0.0:53`。 | -| 其它情况 | 不生成本地 DNS 入站。 | - -## 生成影响 - -| 行为 | 说明 | -| --- | --- | -| 有 `domains` 的 DNS rule | 生成 `{address, domains}` server。 | -| 无 `domains` 的 DNS rule | 插入到 `dns.servers` 前部,作为默认 DNS。 | -| DNS 服务器不是 `localhost` | 生成一条 routing rule,按 `outbound` 连接 DNS 服务器。 | -| 节点服务器是域名 | 额外加入 DNS lookup domains,避免代理节点域名解析走错。 | diff --git a/docs/config/inbounds.md b/docs/config/inbounds.md deleted file mode 100644 index 5ccbaaf..0000000 --- a/docs/config/inbounds.md +++ /dev/null @@ -1,78 +0,0 @@ -# 入站配置 - -对应 `settings.toml` 的 `[inbounds]` 和 `[inbounds.api]`。 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `listen` | 显示 | `127.0.0.1` | `127.0.0.1` / `0.0.0.0` | 普通入站、规则入站、VMess 入站监听地址。 | 只给本机用选 `127.0.0.1`;局域网设备要访问代理端口选 `0.0.0.0`。 | -| `port_sharing` | 隐藏 | `false` | `bool` | 为 `true` 时监听地址强制变成 `0.0.0.0`。 | 当前 UI 用 `listen` 控制,通常不改。 | -| `socks_port` | 隐藏 | `20170` | `0-65535` | 普通 SOCKS 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 SOCKS 入口时手改。 | -| `http_port` | 隐藏 | `20171` | `0-65535` | 普通 HTTP 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 HTTP 入口时手改。 | -| `rule_socks_port` | 隐藏 | `0` | `0-65535` | 旧版规则 SOCKS 入站兼容字段。当前 UI 保存时写 `0`。 | 通常不改。 | -| `rule_http_port` | 显示为 Mixed 端口 | `20172` | `0-65535` | 规则 mixed 入站,同一个端口同时支持 HTTP 和 SOCKS,流量按 `[routing]` 规则分流。 | 浏览器、系统代理、CLI 工具显式代理时使用。 | -| `auth_user` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证用户名。 | 需要给局域网开放代理但不想裸奔时设置。 | -| `auth_password` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证密码。 | 与 `auth_user` 一起设置;两者都为空时使用 `noauth`。 | -| `vmess_port` | 隐藏 | `0` | `0-65535` | 额外 VMess 入站。`0` 不生成。 | 当前很少需要。 | -| `inbound_sniffing` | 显示 | `http,tls,quic` | `disable` / `http,tls` / `http,tls,quic` | 写入每个支持入站的 `sniffing.destOverride`。 | 域名路由不准时保持开启;兼容性异常时降级或关闭。 | -| `route_only` | 显示 | `false` | `bool` | 写入 `sniffing.routeOnly`。 | 只希望嗅探域名用于路由、不改连接目标时开启。 | -| `domains_excluded` | 隐藏 | `""` | 换行分隔域名 | 写入 `sniffing.domainsExcluded`。 | 特定域名被 sniffing 影响时手改。 | -| `api.port` | 隐藏 | `0` | `0-65535` | Xray API 入站端口,`0` 不生成。 | 需要 Xray API 服务时手改。 | -| `api.services` | 隐藏 | `["LoggerService"]` | 字符串数组 | 写入 Xray `api.services`,生成时确保包含 `LoggerService`。 | 需要额外 API service 时手改。 | -| `custom` | 隐藏 | `[]` | `[[inbounds.custom]]` | 额外 socks/http 入站。 | 需要多个固定端口或不同 tag 时手改。 | - -## 自定义入站 - -| 设置 | 默认值 | 可选值 | 作用 | -| --- | --- | --- | --- | -| `tag` | 无 | 非空字符串 | 入站 tag。 | -| `protocol` | 无 | `socks` / `http` | 入站协议。 | -| `port` | 无 | `1-65535` | 监听端口。 | - -## 生成影响 - -| 入站 tag | 来源 | 路由行为 | -| --- | --- | --- | -| `socks` / `http` | `socks_port` / `http_port` | 不进入 `[routing]` 模式规则,最终兜底走 `proxy`。 | -| `rule-mixed` | `rule_http_port` | 同一端口支持 HTTP 和 SOCKS,进入 `[routing]` 模式规则。 | -| `vmess` | `vmess_port` | 额外 VMess 入站。 | -| `api-in` | `api.port` | 路由到 `api-out`。 | - -## Mixed 入站 - -`rule-mixed` 生成示例: - -```json -{ - "tag": "rule-mixed", - "listen": "0.0.0.0", - "port": 20172, - "protocol": "mixed", - "settings": { - "auth": "noauth", - "udp": true, - "allowTransparent": false - } -} -``` - -如果填写用户名和密码: - -```json -"settings": { - "auth": "password", - "udp": true, - "allowTransparent": false, - "accounts": [ - {"user": "alice", "pass": "secret"} - ] -} -``` - -使用方式: - -```sh -curl -x http://10.11.11.100:20172 http://google.com/ -curl --socks5-hostname 10.11.11.100:20172 https://google.com/ -curl -x http://alice:secret@10.11.11.100:20172 http://google.com/ -curl --socks5-hostname alice:secret@10.11.11.100:20172 https://google.com/ -``` diff --git a/docs/config/outbounds-auto-update.md b/docs/config/outbounds-auto-update.md deleted file mode 100644 index 01ce1af..0000000 --- a/docs/config/outbounds-auto-update.md +++ /dev/null @@ -1,22 +0,0 @@ -# 出站组和自动更新配置 - -当前 pyxray 是“单选中节点”模型:选中的节点会生成 tag 为 `proxy` 的主 outbound,另外固定生成 `direct`、`block`、`dns-out`。 - -## `[[outbounds]]` - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `tag` | 隐藏 | `proxy` | 字符串 | 出站组 tag。当前生成逻辑固定使用 `proxy`。 | 当前不用改。 | -| `probe_url` | 隐藏 | `https://www.gstatic.com/generate_204` | URL | 预留给 observatory/balancer。当前不写入 Xray JSON。 | 当前不用改。 | -| `probe_interval` | 隐藏 | `60s` | duration 字符串 | 预留给 observatory/balancer。当前不写入 Xray JSON。 | 当前不用改。 | -| `type` | 隐藏 | `leastping` | 字符串 | 预留策略类型。当前不写入 Xray JSON。 | 当前不用改。 | - -## `[auto_update]` - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `gfwlist_auto_update_mode` | 隐藏 | `none` | `none` / `auto_update` / `auto_update_at_intervals` | GFWList 更新策略预留。当前不执行自动更新。 | 当前不用改。 | -| `gfwlist_auto_update_interval_hour` | 隐藏 | `0` | 整数 | GFWList 定时更新间隔预留。 | 当前不用改。 | -| `subscription_auto_update_mode` | 隐藏 | `none` | `none` / `auto_update` / `auto_update_at_intervals` | 订阅更新策略预留。当前无订阅功能。 | 当前不用改。 | -| `subscription_auto_update_interval_hour` | 隐藏 | `0` | 整数 | 订阅定时更新间隔预留。 | 当前不用改。 | -| `proxy_mode_when_subscribe` | 隐藏 | `direct` | `direct` / `proxy` / `pac` | 订阅更新时连接模式预留。当前无订阅功能。 | 当前不用改。 | diff --git a/docs/config/routing.md b/docs/config/routing.md deleted file mode 100644 index 332ccd0..0000000 --- a/docs/config/routing.md +++ /dev/null @@ -1,43 +0,0 @@ -# 路由配置 - -对应 `settings.toml` 的 `[routing]`。 - -自定义规则详细语法见 [路由自定义规则](../routing-custom-rules.md)。 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `mode` | 显示 | `whitelist` | UI:`whitelist` / `gfwlist` / `proxy` / `direct` / `block`;模型另支持 `custom` / `routingA` | 控制 rule 入站和部分透明代理流量的分流模式。 | 改变整体分流策略时修改。 | -| `default_rule` | 隐藏 | `proxy` | `direct` / `proxy` / `block` | `custom` / `routingA` 等模式的兜底出口。UI 保存时固定为 `proxy`。 | 需要兜底直连或阻断时手改。 | -| `routing_a` | 显示 | `""` | RoutingA 风格文本 | 解析 `domain(...) -> outbound` 和 `ip(...) -> outbound` 规则,优先于内置模式规则。 | 少量自定义前置规则时使用。 | -| `custom_rules` | 隐藏 | `[]` | `[[routing.custom_rules]]` | `mode = custom` 时生成规则。 | 需要结构化 custom TOML 规则时手改。 | - -## 路由模式 - -| 模式 | 行为 | 适用场景 | -| --- | --- | --- | -| `whitelist` | Apple push 直连;`geolocation-!cn`、Google、港澳 IP 代理;`geosite:cn`、私有/中国 IP 直连;兜底按 `default_rule`。 | 国内直连、国外代理。 | -| `gfwlist` | `geolocation-!cn` 和 Telegram IP 代理;其它直连。 | 只代理规则命中的目标。 | -| `proxy` | 全部走 `proxy`。 | 简单全局代理。 | -| `direct` | 全部走 `direct`。 | 临时关闭代理但保留服务。 | -| `block` | 全部走 `block`。 | 测试或阻断入口流量。 | -| `custom` | 使用 `custom_rules`,最后走 `default_rule`。 | 结构化规则。 | -| `routingA` | 使用 `routing_a`,最后走 `default_rule`。 | 兼容 RoutingA 风格配置。 | - -## `routing_a` 语法 - -| 语法 | 示例 | 说明 | -| --- | --- | --- | -| `domain(...) -> outbound` | `domain(geosite:google)->proxy` | 写入 Xray rule 的 `domain`。 | -| `ip(...) -> outbound` | `ip(geoip:cn)->direct` | 写入 Xray rule 的 `ip`。 | -| 注释 | `# comment` | 空行和 `#` 开头行忽略。 | - -`outbound` 可用 `proxy`、`direct`、`block`,也可以是自定义 outbound tag。 - -## `custom_rules` - -| 设置 | UI | 默认值 | 可选值 | 作用 | -| --- | --- | --- | --- | --- | -| `filename` | 隐藏 | `""` | 字符串 | 非空时生成 `ext::`。 | -| `tags` | 隐藏 | `[]` | 字符串数组 | geosite/geoip/tag 列表。 | -| `match_type` | 隐藏 | `domain` | `domain` / `ip` | 决定写入 Xray rule 的 `domain` 还是 `ip`。 | -| `rule_type` | 隐藏 | `proxy` | `direct` / `proxy` / `block` | 命中后的出口。 | diff --git a/docs/config/transparent.md b/docs/config/transparent.md deleted file mode 100644 index 3d30f4f..0000000 --- a/docs/config/transparent.md +++ /dev/null @@ -1,75 +0,0 @@ -# 透明代理配置 - -对应 `settings.toml` 的 `[transparent]`。 - -透明代理由两部分组成: - -| 部分 | 作用 | -| --- | --- | -| Xray inbound | 接收被系统规则转发来的流量。 | -| 系统规则 | `iptables` / `nft` / `ip rule` / `resolv.conf`,把宿主机流量导到 Xray。 | - -## 字段 - -| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 | -| --- | --- | --- | --- | --- | --- | -| `mode` | 显示 | `close` | `close` / `proxy` / `whitelist` / `gfwlist` / `pac` | 是否启用 transparent inbound,以及透明代理流量使用的路由模式。 | 需要宿主机透明代理时改为非 `close`。 | -| `type` | 显示 | `redirect` | `redirect` / `tproxy` / `system_proxy` / `tun` | 透明代理接入方式。 | 先用 `redirect`;需要 UDP 再评估 `tproxy`。 | -| `port` | 显示 | `52345` | `0-65535` | redirect/tproxy/system_proxy/tun 主端口。 | 端口冲突时修改。 | -| `socks_port` | 显示 | `52306` | `0-65535` | `system_proxy` 额外 SOCKS 端口。 | 只在 system proxy 场景修改。 | -| `ipforward` | 显示 | `false` | `bool` | 生成脚本写 `/proc/sys/net/ipv4/ip_forward` 和 IPv6 forwarding。 | 宿主机要作为网关转发其它设备/容器流量时开启。 | -| `docker_transparent` | 显示 | `true` | `bool` | redirect/tproxy 规则只关注指定 Docker CIDR,并不过滤 docker/veth/br-* 接口。 | 要透明代理 Docker 容器流量时保持开启。 | -| `docker_transparent_cidrs` | 显示 | `172.16.0.0/12` | 分号分隔 IPv4 CIDR | Docker 容器源地址网段。 | Docker 网段不是默认范围时修改。 | -| `tproxy_excluded_interfaces` | 显示 | `docker*,veth*,wg*,ppp*,br-*` | 逗号分隔接口模式 | 系统规则排除入口接口。 | tproxy/redirect 误拦截特定接口时修改。 | -| `tproxy_white_country_codes` | 隐藏 | `[]` | 国家/地区代码数组 | 从 `geoip.dat` 解析白名单 CIDR,tproxy 下 RETURN。 | tproxy 下需要国家/地区直连白名单时手改。 | -| `tproxy_white_custom_ips` | 隐藏 | `[]` | CIDR 数组 | tproxy 自定义 RETURN CIDR。 | tproxy 下需要额外直连网段时手改。 | -| `tun_bypass_interfaces` | 隐藏 | `""` | 字符串 | 写入 `tinytun.yaml` 的绕过接口。 | 使用 TinyTun 且要绕过接口时手改。 | -| `tun_auto_route` | 显示 | `true` | `bool` | 写入 `tinytun.yaml` 的 `auto_route`。 | TUN 路由由外部管理时关闭。 | -| `tun_route_shell_type` | 隐藏 | `""` | 字符串 | 预留。 | 当前不用改。 | -| `tun_route_shell_path` | 隐藏 | `""` | 字符串 | 预留。 | 当前不用改。 | -| `tun_setup_script` | 隐藏 | `""` | shell 文本 | `type = tun` 时生成 setup 脚本内容。 | 需要自定义 TUN 初始化时手改。 | -| `tun_teardown_script` | 隐藏 | `""` | shell 文本 | `type = tun` 时生成 cleanup 脚本内容。 | 需要自定义 TUN 清理时手改。 | -| `tun_process_backend` | 隐藏 | `""` | 字符串 | 写入 TinyTun 进程匹配后端。 | 需要进程分流时手改。 | -| `tun_exclude_processes` | 隐藏 | `""` | 换行/逗号分隔字符串 | 写入 TinyTun 排除进程。 | 避免 Xray/pyxray 自身进入 TUN 回环时修改。 | - -## 模式行为 - -| `mode` | 透明代理路由行为 | -| --- | --- | -| `close` | 不生成 transparent inbound,不执行透明代理 setup。 | -| `proxy` | transparent 流量全部走 `proxy`。 | -| `whitelist` | transparent 流量按 whitelist 规则。 | -| `gfwlist` | transparent 流量按 gfwlist 规则。 | -| `pac` | transparent 流量复用 `[routing].mode`。 | - -## 类型行为 - -| `type` | Xray inbound | 系统规则 | 适用 | -| --- | --- | --- | --- | -| `redirect` | `dokodemo-door` + `followRedirect` + `tproxy=redirect` | nat 表 REDIRECT / nft redirect | 推荐默认;主要处理 TCP。 | -| `tproxy` | `dokodemo-door` + `followRedirect` + `tproxy=tproxy` | mangle + fwmark + table 100 / nft tproxy | 需要 UDP/TProxy 时。 | -| `system_proxy` | HTTP + SOCKS | 不改内核规则,只生成占位脚本 | 应用显式配置代理。 | -| `tun` | SOCKS 入站 + `tinytun.yaml` | 使用 `tun_setup_script` / `tun_teardown_script` | 外部 TinyTun 方案。 | - -## Docker 部署注意 - -| 配置 | 说明 | -| --- | --- | -| `network_mode: host` | 规则作用于宿主机网络命名空间。 | -| `privileged: true` | 允许改 iptables/nft/ip rule/procfs。 | -| `/etc/resolv.conf:/etc/resolv.conf` | redirect DNS 劫持时修改宿主机 DNS。 | -| `docker_transparent = true` | 生成 Docker CIDR 相关 PREROUTING 规则。 | - -## 生成文件 - -| 文件 | 用途 | -| --- | --- | -| `ip-forward-apply.sh` | 写 IP forwarding。 | -| `resolv-hijack-setup.sh` | redirect DNS 劫持。 | -| `resolv-hijack-cleanup.sh` | 恢复 DNS。 | -| `transparent-iptables-setup.sh` | 安装 iptables 规则。 | -| `transparent-iptables-cleanup.sh` | 清理 iptables 规则。 | -| `transparent-nft-setup.sh` | 加载 nftables 规则。 | -| `transparent-nft-cleanup.sh` | 清理 nftables 规则。 | -| `v2raya.nft` | nftables 表内容。 | -| `tinytun.yaml` | TinyTun 配置。 | diff --git a/docs/infra.md b/docs/infra.md deleted file mode 100644 index ca9b853..0000000 --- a/docs/infra.md +++ /dev/null @@ -1,220 +0,0 @@ -# 项目结构和调用时序 - -## 目录结构 - -| 路径 | 职责 | -| --- | --- | -| `pyxray/cli.py` | 命令行入口。默认启动 Web。 | -| `pyxray/web/server.py` | 创建 Flask app,注册各 API 和生命周期清理。 | -| `pyxray/web/dashboard.py` | 渲染首页。 | -| `pyxray/web/jobs.py` | 内存任务表,当前用于下载任务轮询。 | -| `pyxray/web/nodes.py` | 节点 API。 | -| `pyxray/web/xray_assets.py` | Xray 资源下载 API。 | -| `pyxray/web/xray_config.py` | 配置保存和生成 API。 | -| `pyxray/web/xray_service.py` | Xray 启停和日志 API。 | -| `pyxray/web/templates/` | Jinja 页面和配置表单。 | -| `pyxray/web/static/` | 前端 JS/CSS。 | -| `pyxray/libs/nodes/` | 节点链接解析、标准化、TOML 存储。 | -| `pyxray/libs/xray_assets.py` | 下载和检查 `xray`、`geoip.dat`、`geosite.dat`。 | -| `pyxray/libs/xray_asset_settings.py` | `download.toml` 读写。 | -| `pyxray/libs/xray_config/` | Xray JSON、透明代理脚本、TinyTun 配置和设置存储。 | -| `pyxray/libs/xray_runtime.py` | Xray 子进程管理、端口检查、日志转发。 | -| `pyxray/libs/xray_transparent_runtime.py` | 透明代理脚本执行、回滚、本地 CIDR watcher。 | -| `tests/` | 单元测试和 Web API 测试。 | -| `docs/` | 使用和配置文档。 | -| `scripts/build.sh` | Docker 镜像构建脚本。 | -| `compose.yaml` | Docker 透明代理部署。 | - -## 数据文件 - -| 文件 | 创建方 | 读写时机 | -| --- | --- | --- | -| `nodes.toml` | `NodeStore` | 导入、选择、删除节点。 | -| `settings.toml` | `XrayConfigSettingsStore` | 保存配置页设置、生成配置、启动 Xray。 | -| `download.toml` | `XrayAssetSettingsStore` | 保存下载页设置、启动下载任务。 | -| `config.json` | `generate_current_xray_config` | 生成配置、启动 Xray 前。 | -| `xray.log` | `XrayServiceManager` / `TransparentRuntime` | 启停 Xray、转发 Xray 输出、透明代理脚本日志。 | -| `transparent/*` | `write_transparent_rule_files` | 生成配置、启动 Xray 前。 | - -## Web App 装配 - -```mermaid -flowchart TD - CLI[pyxray cli] --> RunWeb[run_web] - RunWeb --> CreateApp[create_app] - CreateApp --> Jobs[init_job_store] - CreateApp --> Assets[register_xray_assets] - CreateApp --> Nodes[register_nodes] - CreateApp --> Config[register_xray_config] - CreateApp --> Service[register_xray_service] - CreateApp --> Lifecycle[_bind_xray_lifecycle] - CreateApp --> Dashboard[register_dashboard] -``` - -## 首页渲染 - -```mermaid -sequenceDiagram - participant B as Browser - participant D as dashboard.index - participant A as AssetSettingsStore - participant N as NodeManager - participant C as XrayConfigSettingsStore - participant S as XrayServiceManager - - B->>D: GET / - D->>A: load download.toml - D->>N: list_nodes + selected_id - D->>C: load settings.toml - D->>S: status - D-->>B: render index.html -``` - -## 资源下载 - -```mermaid -sequenceDiagram - participant B as Browser - participant API as /api/xray/assets/ensure - participant Store as XrayAssetSettingsStore - participant Jobs as JobStore - participant Worker as _run_asset_job - participant Assets as ensure_xray_assets - - B->>API: POST form - API->>Store: save download.toml - API->>Jobs: start worker - API-->>B: job_id - Worker->>Assets: check/download/extract - Worker->>Jobs: update steps/status - B->>Jobs: GET job status - Jobs-->>B: progress/result -``` - -## 节点导入和选择 - -```mermaid -sequenceDiagram - participant B as Browser - participant API as nodes API - participant M as NodeManager - participant P as parse_node_link - participant S as NodeStore - - B->>API: POST /api/nodes/import - API->>M: import_links - M->>P: parse + normalize - M->>S: save nodes.toml - API-->>B: import results - B->>API: POST /api/nodes/select - API->>M: select_node - M->>S: save selected_id - API-->>B: selected node -``` - -## 配置生成 - -```mermaid -sequenceDiagram - participant B as Browser - participant API as /api/xray/config/generate - participant N as NodeManager - participant S as SettingsStore - participant G as generate_xray_config - participant T as write_transparent_rule_files - participant U as write_tinytun_config_file - participant FS as data directory - - B->>API: POST generate - API->>N: get_selected_node - API->>S: load settings.toml - API->>G: node + settings - G-->>API: config dict - API->>FS: write config.json - API->>T: write transparent scripts - API->>U: write tinytun.yaml if needed - API-->>B: config + paths -``` - -## 启动 Xray 和透明代理 - -```mermaid -sequenceDiagram - participant B as Browser - participant API as /api/xray/service/start - participant G as generate_current_xray_config - participant X as XrayServiceManager - participant R as TransparentRuntime - participant OS as Host network - - B->>API: POST start - API->>G: regenerate config and scripts - API->>X: status - API->>X: start xray - X->>X: check inbound ports - X->>OS: Popen xray run -config config.json - X->>X: forward stdout/stderr to xray.log - API->>R: setup settings - R->>R: cleanup old rules best-effort - R->>OS: run ip-forward script - R->>OS: run iptables setup, fallback nft - R->>OS: run resolv setup - R->>R: start local CIDR watcher - API-->>B: running status -``` - -## 停止和清理 - -```mermaid -sequenceDiagram - participant B as Browser - participant API as /api/xray/service/stop - participant X as XrayServiceManager - participant R as TransparentRuntime - participant OS as Host network - - B->>API: POST stop - API->>X: stop - X->>R: before_stop cleanup - R->>R: stop local CIDR watcher - R->>OS: run resolv cleanup - R->>OS: run transparent backend cleanup - X->>OS: terminate xray process - API-->>B: stopped status -``` - -## Docker 透明代理部署 - -```mermaid -flowchart TD - Compose["docker compose"] --> Container["pyxray container"] - Container --> HostNet["host network namespace"] - Container --> ConfigVol["data volume mounted to config"] - Container --> Resolv["resolv.conf bind mount"] - Container --> Modules["lib modules read-only mount"] - HostNet --> XrayPorts["Xray listens on host ports"] - HostNet --> Rules["iptables nft ip rule affect host"] - Resolv --> DNS["host DNS hijack when enabled"] -``` - -关键点: - -| Compose 配置 | 原因 | -| --- | --- | -| `network_mode: host` | Xray 端口和透明代理规则直接作用于宿主机。 | -| `privileged: true` | 允许改防火墙、策略路由、procfs。 | -| `./data:/config` | 容器重建后状态不丢。 | -| `/etc/resolv.conf:/etc/resolv.conf` | redirect DNS 劫持修改宿主机 DNS。 | -| `/lib/modules:/lib/modules:ro` | 读取宿主机内核模块信息。 | - -## 修改建议 - -| 目标 | 优先修改位置 | -| --- | --- | -| 增加节点协议 | `pyxray/libs/nodes/parsers/`、`pyxray/libs/xray_config/outbound.py`。 | -| 增加配置字段 | `settings.py`、`store.py`、配置模板、`generator.py`。 | -| 改 Web API | `pyxray/web/*.py`。 | -| 改配置生成 | `pyxray/libs/xray_config/generator.py`。 | -| 改透明代理规则 | `transparent_rules.py`。 | -| 改规则执行/回滚 | `xray_transparent_runtime.py`。 | -| 改 Docker 部署 | `Dockerfile`、`compose.yaml`、`scripts/build.sh`。 | diff --git a/docs/issues/easytier.md b/docs/issues/easytier.md deleted file mode 100644 index 16a8b93..0000000 --- a/docs/issues/easytier.md +++ /dev/null @@ -1,74 +0,0 @@ -# easytier 连接 peer 超时 - -## 问题原因 - -`easytier` 使用 `network_mode: host`,它发起的 peer 连接属于宿主机本机流量,会经过 `nat OUTPUT`。 - -`pyxray` 开启 transparent redirect 后,会把宿主机本机 TCP 流量转到 Xray transparent inbound: - -```sh -iptables -t nat -I OUTPUT -p tcp -j TP_OUT -iptables -t nat -A TP_OUT -j TP_RULE -iptables -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports 52345 -``` - -因此 easytier 访问 peer 时,连接会被改写: - -```mermaid -flowchart LR - E[easytier-core
tcp://117.72.47.28:33010] --> O[nat OUTPUT] - O --> TPO[TP_OUT] - TPO --> TPR[TP_RULE] - TPR --> R[REDIRECT :52345] - R --> X[Xray transparent inbound] -``` - -结果是 easytier 没有直连到自己的 peer,日志表现为: - -```text -connecting to peer dst=tcp://117.72.47.28:33010 -connect to peer error ... Timeout -``` - -## 解决方案 - -在 UI 的“核心 -> transparent”里添加 OUTPUT 绕过规则,让 easytier peer 连接在进入 `TP_RULE` 前直接 `RETURN`。 - -示例: - -```text -tcp 117.72.47.28:33010 -``` - -生成后的关键规则: - -```sh -iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN -iptables -t nat -A TP_OUT -j TP_RULE -``` - -修复后的流量路径: - -```mermaid -flowchart LR - E[easytier-core
tcp://117.72.47.28:33010] --> O[nat OUTPUT] - O --> TPO[TP_OUT] - TPO --> B{match tcp
117.72.47.28:33010} - B -->|yes| D[RETURN
直连 peer] - B -->|no| TPR[TP_RULE] - TPR --> R[REDIRECT :52345] -``` - -规则格式: - -```text -tcp 117.72.47.28:33010 -all 192.168.0.0/24 -udp 198.51.100.10:3478 -``` - -说明: - -- `redirect` 模式只处理 TCP,因此只生成 TCP 绕过规则。 -- `tproxy` 模式支持 TCP 和 UDP。 -- 规则只作用于宿主机本机 `OUTPUT`,不改变 Docker 容器透明代理的 `PREROUTING` 行为。 diff --git a/docs/issues/vless-reality-vision-mux.md b/docs/issues/vless-reality-vision-mux.md deleted file mode 100644 index aa63203..0000000 --- a/docs/issues/vless-reality-vision-mux.md +++ /dev/null @@ -1,89 +0,0 @@ -# VLESS Reality Vision 开启 mux 后连接被关闭 - -## 问题 - -在节点使用 `VLESS + REALITY + xtls-rprx-vision` 时,开启 `mux` 后,透明代理和本地 HTTP/SOCKS 代理都会出现请求失败。 - -现象: - -```text -curl google.com -curl: (52) Empty reply from server -``` - -Xray 日志: - -```text -common/mux: dispatching request to tcp:google.com:80 -proxy/vless/outbound: tunneling request to tcp:v1.mux.cool:9527 -common/mux: failed to read metadata > io: read/write on closed pipe -``` - -关闭 `mux` 后,同一节点恢复正常: - -```text -curl http://google.com/ -> HTTP/1.1 301 -curl https://google.com/ -> HTTP/2 301 -HTTP/SOCKS inbound 测试 -> 正常 -``` - -## 原因 - -当前失败不在 transparent/iptables,而在 Xray outbound 层。 - -`mux` 会把多个 TCP 请求封装进一个 Mux.Cool 连接;官方文档说明它用于减少 TCP 握手延迟,默认关闭,并且不用于提升吞吐。`xtls-rprx-vision` 是 VLESS 的 XTLS flow,官方文档说明它在 `TCP + TLS/REALITY` 下会对 TLS 1.3 数据走底层直拷路径。 - -两者叠加时,业务连接不再按普通 VLESS 请求直接发送,而是先被封装成 `v1.mux.cool` 子连接: - -```mermaid -flowchart LR - A[curl / app] --> I[Xray inbound] - I --> R[routing -> proxy] - R --> M[mux
v1.mux.cool] - M --> V[VLESS
flow=xtls-rprx-vision] - V --> T[REALITY/TCP server] - T --> X[server closes pipe] -``` - -Xray-core 讨论区有同类案例:配置为 `VLESS + REALITY + TCP + xtls-rprx-vision + mux` 时,请求报 `curl: (52) Empty reply from server`,服务端日志出现 `common/mux` 和 `closed pipe` 类错误;去掉 `mux` 后恢复。 - -因此这里的结论是:该节点组合下 `mux` 与 `xtls-rprx-vision` 不兼容或服务端不接受 mux 封装后的请求。 - -## 解决方案 - -在 UI 的“核心设置”里关闭 `mux`,保存并重启 Xray。 - -生成配置中不要出现: - -```json -"mux": { - "enabled": true, - "concurrency": 4 -} -``` - -修复后路径: - -```mermaid -flowchart LR - A[curl / app] --> I[Xray inbound] - I --> R[routing -> proxy] - R --> V[VLESS
flow=xtls-rprx-vision] - V --> T[REALITY/TCP server] - T --> G[google.com / target] -``` - -验证命令: - -```sh -curl -v http://google.com/ -curl -vk https://google.com/ -curl -v -x http://127.0.0.1:20172 http://google.com/ -curl -v --socks5-hostname 127.0.0.1:20170 https://google.com/ -``` - -参考: - -- [Project X: Outbound Proxy (Mux, XUDP)](https://xtls.github.io/en/config/outbound.html) -- [Project X: VLESS (XTLS Vision Seed)](https://xtls.github.io/en/config/inbounds/vless.html) -- [XTLS/Xray-core discussion #5481](https://github.com/XTLS/Xray-core/discussions/5481) diff --git a/docs/routing-custom-rules.md b/docs/routing-custom-rules.md deleted file mode 100644 index 2833900..0000000 --- a/docs/routing-custom-rules.md +++ /dev/null @@ -1,183 +0,0 @@ -# 路由自定义规则 - -本文说明 pyxray 配置页“路由 / 自定义规则”的实际语法,以及它最终生成到 Xray `routing.rules` 的方式。 - -## 适用入口 - -| 入口 | 字段 | UI | 适合场景 | -| --- | --- | --- | --- | -| RoutingA 文本 | `routing.routing_a` | 显示 | 少量域名/IP 前置规则。 | -| 结构化规则 | `routing.custom_rules` | 隐藏 | 手写 `settings.toml`,按 geosite/geoip/ext 列表分流。 | - -pyxray 当前不会让你直接手写完整 Xray `routing.rules` JSON;它只提供上述两种简化输入,然后生成 Xray 规则。 - -## 匹配顺序 - -```mermaid -flowchart TD - Request["连接进入 rule 入站或透明代理入站"] --> Custom["先应用 routing_a 前置规则"] - Custom --> Mode["再应用 routing.mode 内置规则"] - Mode --> Default["最后应用 default_rule 兜底"] - Default --> Outbound["proxy direct block"] -``` - -Xray 原生规则按 `routing.rules` 从上到下匹配,命中第一条后使用该规则的 `outboundTag` 或 `balancerTag`。同一条规则里多个字段同时存在时是 AND 关系;同一字段数组内通常是 OR 关系。 - -## RoutingA 文本语法 - -| 语法 | 示例 | 生成字段 | 说明 | -| --- | --- | --- | --- | -| `domain(...) -> proxy` | `domain(geosite:google)->proxy` | `domain` | 命中域名后走 `proxy`。 | -| `domain(...) -> direct` | `domain(domain:example.com)->direct` | `domain` | 命中域名或子域名后直连。 | -| `domain(...) -> block` | `domain(full:ads.example.com)->block` | `domain` | 命中完整域名后阻断。 | -| `ip(...) -> proxy` | `ip(geoip:telegram)->proxy` | `ip` | 命中 IP 列表后代理。 | -| `ip(...) -> direct` | `ip(geoip:private, geoip:cn)->direct` | `ip` | 命中私有或中国 IP 后直连。 | -| 注释 | `# comment` | 无 | 空行和 `#` 开头行会被忽略。 | - -格式要求: - -| 项 | 要求 | -| --- | --- | -| 匹配器 | 只能是 `domain(...)` 或 `ip(...)`。 | -| 分隔符 | 必须使用 `->`。 | -| 多个值 | 用英文逗号分隔。 | -| 出口 | 通常使用 `proxy`、`direct`、`block`。 | -| 生效范围 | 只作用于 rule 入站和透明代理入站,不影响普通 `socks` / `http` 入站。 | - -示例: - -```toml -[routing] -mode = "whitelist" -default_rule = "proxy" -routing_a = """ -# 公司内网直连 -domain(domain:corp.example.com)->direct -ip(10.0.0.0/8, 192.168.0.0/16)->direct - -# Google 代理 -domain(geosite:google)->proxy - -# 精确阻断广告域名 -domain(full:ads.example.com)->block -""" -``` - -## domain 值 - -| 写法 | 示例 | 匹配语义 | -| --- | --- | --- | -| `domain:` | `domain:example.com` | 匹配 `example.com` 和子域名,例如 `www.example.com`。 | -| `full:` | `full:example.com` | 只完整匹配 `example.com`。 | -| `keyword:` | `keyword:google` | 目标域名包含关键字即匹配。 | -| 无前缀字符串 | `google` | 等价于 `keyword:google`。 | -| `regexp:` | `regexp:\\.example\\.com$` | 使用正则匹配目标域名。 | -| `dotless:` | `dotless:printer` | 匹配不含点的内网短域名。 | -| `geosite:` | `geosite:cn` | 使用 `geosite.dat` 里的标签。 | -| `ext:` | `ext:geosite.dat:cn` | 从资源目录里的外部 geosite 格式文件读取标签。 | - -注意: - -| 项 | 说明 | -| --- | --- | -| 推荐默认 | 常规域名优先用 `domain:example.com`。 | -| 精确匹配 | 只想匹配单个域名时用 `full:`。 | -| 正则转义 | 写进 TOML 字符串时反斜杠要按 TOML 规则转义。 | -| 不支持 | `plain:` 不是当前 Xray 官方 routing 文档列出的 domain 前缀,不要使用。 | - -## ip 值 - -| 写法 | 示例 | 匹配语义 | -| --- | --- | --- | -| 单个 IP | `1.1.1.1` | 匹配目标 IP。 | -| CIDR | `10.0.0.0/8` | 匹配网段。 | -| IPv6 CIDR | `fc00::/7` | 匹配 IPv6 网段。 | -| `geoip:` | `geoip:cn` | 使用 `geoip.dat` 里的国家或分类标签。 | -| `geoip:private` | `geoip:private` | 匹配私有地址。 | -| `ext:` | `ext:geoip.dat:cn` | 从资源目录里的外部 geoip 格式文件读取标签。 | -| `!` 反选 | `!geoip:cn` | 匹配不在该 IP 列表内的目标。 | - -示例: - -```toml -[routing] -mode = "routingA" -default_rule = "proxy" -routing_a = """ -ip(geoip:private, geoip:cn)->direct -ip(geoip:telegram)->proxy -ip(!geoip:cn)->proxy -""" -``` - -## custom_rules 结构化规则 - -`custom_rules` 只有在 `routing.mode = "custom"` 时作为主规则集使用。UI 暂不显示,需要手写 `settings.toml`。 - -| 字段 | 默认值 | 可选值 | 作用 | -| --- | --- | --- | --- | -| `filename` | `""` | 文件名 | 非空时把每个 tag 生成 `ext::`。 | -| `tags` | `[]` | 字符串数组 | 要匹配的 geosite/geoip/ext 标签。 | -| `match_type` | `domain` | `domain` / `ip` | 决定生成 Xray rule 的 `domain` 还是 `ip`。 | -| `rule_type` | `proxy` | `proxy` / `direct` / `block` | 命中后的出口。 | - -示例: - -```toml -[routing] -mode = "custom" -default_rule = "proxy" - -[[routing.custom_rules]] -match_type = "domain" -rule_type = "direct" -tags = ["geosite:private", "geosite:cn"] - -[[routing.custom_rules]] -match_type = "ip" -rule_type = "direct" -tags = ["geoip:private", "geoip:cn"] - -[[routing.custom_rules]] -match_type = "domain" -rule_type = "proxy" -tags = ["geosite:geolocation-!cn"] -``` - -使用外部文件: - -```toml -[[routing.custom_rules]] -filename = "geosite.dat" -match_type = "domain" -rule_type = "proxy" -tags = ["google"] -``` - -上面会生成: - -```json -{ - "domain": ["ext:geosite.dat:google"], - "outboundTag": "proxy" -} -``` - -## 常见问题 - -| 问题 | 原因 | 处理 | -| --- | --- | --- | -| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` 对应的 mixed 端口,或开启透明代理。 | -| 域名规则没命中 | 流量只有 IP,没有域名。 | 开启 sniffing,或改用 `ip(...)` 规则。 | -| IP 规则导致 DNS 查询 | 当前 pyxray 生成 `domainStrategy = "IPOnDemand"`。 | 避免过度使用 IP 规则,或接受 Xray 为路由进行 DNS 解析。 | -| `routing_a` 里的 `default:` 无效 | pyxray 解析器只识别 `domain(...)` 和 `ip(...)`。 | 用 `default_rule` 设置兜底。 | -| `plain:` 无效 | 不是当前 Xray routing 官方 domain 前缀。 | 使用无前缀字符串或 `keyword:`。 | - -## 官方依据 - -| 内容 | 官方链接 | -| --- | --- | -| Xray RoutingObject / RuleObject | https://xtls.github.io/config/routing.html | -| 文档源码 | https://github.com/XTLS/Xray-docs-next/blob/main/docs/config/routing.md | -| Xray-core routing 解析代码 | https://github.com/XTLS/Xray-core/blob/main/infra/conf/router.go | -| 域名/IP 规则解析代码 | https://github.com/XTLS/Xray-core/blob/main/common/geodata/rule_parser.go | diff --git a/docs/transparent-iptables.md b/docs/transparent-iptables.md deleted file mode 100644 index 13cc9e3..0000000 --- a/docs/transparent-iptables.md +++ /dev/null @@ -1,302 +0,0 @@ -# 透明代理 iptables 规则说明 - -本文说明 `transparent.type = "redirect"` 时,pyxray 当前会生成哪些 `iptables` 规则、这些规则是什么意思,以及如何查看宿主机当前是否生效。 - -## 当前配置 - -当前 `data/settings.toml` 中透明代理相关配置类似: - -```toml -[transparent] -mode = "pac" -type = "redirect" -port = 52345 -docker_transparent = true -docker_transparent_cidrs = "172.16.0.0/12" -output_bypass_rules = "all 117.72.47.28" -``` - -含义: - -| 配置 | 含义 | -| --- | --- | -| `mode = "pac"` | 透明代理流量进入 Xray 后,复用 `[routing].mode` 的分流策略。 | -| `type = "redirect"` | 使用 `iptables nat` 表的 `REDIRECT`,主要处理 TCP 流量。 | -| `port = 52345` | 被拦截的 TCP 流量会转发到本机 Xray transparent inbound 端口。 | -| `docker_transparent = true` | 额外处理来自 Docker 网段的容器流量。 | -| `docker_transparent_cidrs = "172.16.0.0/12"` | 只有源地址在该 CIDR 内的 Docker 容器流量会进入 `PREROUTING` 透明代理规则。 | -| `output_bypass_rules = "all 117.72.47.28"` | 宿主机本机访问 `117.72.47.28` 时直接放行,不进入透明代理。redirect 下实际只生成 TCP 绕过规则。 | - -## 生成文件 - -透明代理规则文件生成在: - -```text -data/transparent/ -``` - -常用文件: - -| 文件 | 作用 | -| --- | --- | -| `transparent-iptables-setup.sh` | 安装 iptables 规则。 | -| `transparent-iptables-cleanup.sh` | 清理 iptables 规则。 | -| `transparent-nft-setup.sh` | nftables 后端安装脚本。 | -| `transparent-nft-cleanup.sh` | nftables 后端清理脚本。 | -| `v2raya.nft` | nftables 后端规则表。 | -| `ip-forward-apply.sh` | 写 `/proc/sys/net/ipv4/ip_forward` 和 IPv6 forwarding。 | -| `resolv-hijack-setup.sh` | DNS 劫持时改写 `/etc/resolv.conf`。 | -| `resolv-hijack-cleanup.sh` | 停止或回滚时恢复 `/etc/resolv.conf`。 | - -直接查看生成的 iptables 脚本: - -```bash -sed -n '1,220p' data/transparent/transparent-iptables-setup.sh -sed -n '1,220p' data/transparent/transparent-iptables-cleanup.sh -``` - -如果在 Docker 宿主机上查看挂载目录: - -```bash -sed -n '1,220p' ./data/transparent/transparent-iptables-setup.sh -``` - -## 当前会生成的 redirect 规则 - -当前 `redirect` 模式使用 `nat` 表,并创建 3 条自定义链: - -| 链 | 作用 | -| --- | --- | -| `TP_OUT` | 处理宿主机本机进程发起的 TCP 流量,也就是 `OUTPUT` 流量。 | -| `TP_PRE` | 处理进入宿主机的 TCP 流量,也就是 `PREROUTING` 流量;主要用于 Docker 容器或其它转发流量。 | -| `TP_RULE` | 统一判断哪些目标直连,哪些目标重定向到 Xray。 | - -核心结构: - -```bash -iptables -t nat -N TP_OUT -iptables -t nat -N TP_PRE -iptables -t nat -N TP_RULE - -iptables -t nat -I OUTPUT -p tcp -j TP_OUT -iptables -t nat -I PREROUTING -p tcp -j TP_PRE - -iptables -t nat -A TP_PRE -s 172.16.0.0/12 -j TP_RULE -iptables -t nat -A TP_OUT -j TP_RULE -iptables -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports 52345 -``` - -执行路径: - -| 流量来源 | 路径 | -| --- | --- | -| 宿主机本机进程访问外部 TCP | `nat OUTPUT -> TP_OUT -> TP_RULE -> REDIRECT :52345` | -| Docker 容器访问外部 TCP,源地址匹配 `172.16.0.0/12` | `nat PREROUTING -> TP_PRE -> TP_RULE -> REDIRECT :52345` | -| Docker 容器源地址不匹配 `docker_transparent_cidrs` | 进入 `TP_PRE` 后不会跳到 `TP_RULE`,不会被 pyxray redirect。 | -| 访问保留地址、内网地址、本机接口地址、绕过目标 | 在 `TP_RULE` 内 `RETURN`,不进入 Xray。 | - -## TP_RULE 里的 RETURN 是什么意思 - -`TP_RULE` 前半段是一批 `RETURN` 规则,用来避免把不该代理的流量送进 Xray。 - -常见规则: - -```bash -iptables -t nat -A TP_RULE -d 10.0.0.0/8 -j RETURN -iptables -t nat -A TP_RULE -d 127.0.0.0/8 -j RETURN -iptables -t nat -A TP_RULE -d 172.16.0.0/12 -j RETURN -iptables -t nat -A TP_RULE -d 192.168.0.0/16 -j RETURN -iptables -t nat -A TP_RULE -m mark --mark 0x80/0x80 -j RETURN -iptables -t nat -A TP_RULE -i wg+ -j RETURN -iptables -t nat -A TP_RULE -i ppp+ -j RETURN -``` - -含义: - -| 规则 | 含义 | -| --- | --- | -| `-d 10.0.0.0/8 -j RETURN` | 访问私有网段时直连。 | -| `-d 127.0.0.0/8 -j RETURN` | 访问本机回环地址时直连,避免回环。 | -| `-d 172.16.0.0/12 -j RETURN` | 访问 Docker 或内网私有地址时直连。 | -| `-d 192.168.0.0/16 -j RETURN` | 访问局域网地址时直连。 | -| `-m mark --mark 0x80/0x80 -j RETURN` | 已打过特定标记的流量跳过,避免重复处理。 | -| `-i wg+ -j RETURN` | 从 WireGuard 之类接口进入的流量跳过。 | -| `-i ppp+ -j RETURN` | 从 PPP 之类接口进入的流量跳过。 | - -pyxray 还会把宿主机当前 IPv4 地址所在 CIDR 加入 RETURN: - -```bash -ip -o -4 addr show | awk '{print $4}' -``` - -这部分用于避免访问宿主机本机地址或本地接口地址时被透明代理截获。 - -## output_bypass_rules 生成的规则 - -当前配置: - -```toml -output_bypass_rules = "all 117.72.47.28" -``` - -redirect 模式只处理 TCP,所以会生成: - -```bash -iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 -j RETURN -``` - -它的位置在 `TP_OUT -> TP_RULE` 之前,含义是: - -```text -宿主机本机进程访问 117.72.47.28 的 TCP 流量直接 RETURN,不进入 TP_RULE,也不会 REDIRECT 到 52345。 -``` - -这个规则只影响宿主机本机 `OUTPUT` 流量,不影响 Docker 容器从 `PREROUTING` 进入的流量。 - -## 如何查看当前生效状态 - -宿主机直接查看: - -```bash -sudo iptables -t nat -S -sudo iptables -t nat -L -n -v -sudo iptables-save -t nat -``` - -只看 pyxray 透明代理相关链: - -```bash -sudo iptables -t nat -S TP_OUT -sudo iptables -t nat -S TP_PRE -sudo iptables -t nat -S TP_RULE -``` - -在容器里查看: - -```bash -docker exec -it pyxray iptables -t nat -S -docker exec -it pyxray iptables -t nat -S TP_OUT -docker exec -it pyxray iptables -t nat -S TP_PRE -docker exec -it pyxray iptables -t nat -S TP_RULE -``` - -当前 `compose.yaml` 使用: - -```yaml -network_mode: host -privileged: true -``` - -因此容器里执行的 `iptables` 作用在宿主机网络命名空间。正常情况下,宿主机和容器里看到的是同一套规则。 - -## 看不到规则时怎么判断原因 - -先确认 pyxray 是否还在运行: - -```bash -docker ps | grep pyxray -docker logs pyxray --tail 80 -``` - -查看 pyxray 自己的透明代理执行日志: - -```bash -grep 'pyxray transparent' data/xray.log | tail -80 -``` - -如果看到类似: - -```text -setup transparent iptables: /bin/sh data/transparent/transparent-iptables-setup.sh -``` - -说明启动时使用了 iptables 后端。 - -如果最后看到的是: - -```text -cleanup transparent iptables: /bin/sh data/transparent/transparent-iptables-cleanup.sh -``` - -说明停止或回滚时已经清理过规则,宿主机上可能就看不到 `TP_OUT`、`TP_PRE`、`TP_RULE`。 - -如果容器里能看到、宿主机看不到,检查 `iptables` 前端是否一致: - -```bash -sudo iptables --version -docker exec pyxray iptables --version - -sudo iptables-nft -t nat -S -sudo iptables-legacy -t nat -S -sudo nft list ruleset -``` - -有些系统同时存在 `iptables-nft` 和 `iptables-legacy`。如果宿主机默认命令和容器里的命令使用不同后端,看到的规则可能不一致。 - -## 如何判断流量是否命中规则 - -查看计数器: - -```bash -sudo iptables -t nat -L TP_OUT -n -v -sudo iptables -t nat -L TP_PRE -n -v -sudo iptables -t nat -L TP_RULE -n -v -``` - -关注字段: - -| 字段 | 含义 | -| --- | --- | -| `pkts` | 命中该规则的数据包数量。 | -| `bytes` | 命中该规则的字节数。 | -| `REDIRECT tcp -- anywhere anywhere redir ports 52345` | 命中后会被转发到 Xray transparent inbound。 | -| `RETURN` | 命中后从当前自定义链返回,不继续走 pyxray 后续规则。 | - -测试前可以清零计数器: - -```bash -sudo iptables -t nat -Z TP_OUT -sudo iptables -t nat -Z TP_PRE -sudo iptables -t nat -Z TP_RULE -``` - -然后从宿主机发起一个 TCP 访问,再查看计数器是否增加。 - -## 清理规则 - -正常停止 pyxray 时会执行: - -```bash -data/transparent/transparent-iptables-cleanup.sh -``` - -手动清理: - -```bash -sudo sh data/transparent/transparent-iptables-cleanup.sh -``` - -容器里手动清理: - -```bash -docker exec -it pyxray sh /config/transparent/transparent-iptables-cleanup.sh -``` - -清理脚本会删除: - -```bash -nat OUTPUT -> TP_OUT -nat PREROUTING -> TP_PRE -TP_OUT -TP_PRE -TP_RULE -``` - -## 代码位置 - -| 路径 | 作用 | -| --- | --- | -| `pyxray/libs/xray_config/transparent_rules.py` | 生成 iptables/nftables 脚本。 | -| `pyxray/libs/xray_transparent_runtime.py` | 启动、停止、回滚时执行脚本。 | -| `data/transparent/transparent-iptables-setup.sh` | 当前配置实际生成出的安装脚本。 | -| `data/transparent/transparent-iptables-cleanup.sh` | 当前配置实际生成出的清理脚本。 | diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 6112a27..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[project] -name = "pyxray" -version = "1.0.5" -description = "A lightweight Linux xray control plane." -readme = "README.md" -requires-python = ">=3.14" -dependencies = [ - "flask>=3.1.2", - "tomlkit>=0.13.3", -] - -[project.scripts] -pyxray = "pyxray.cli:main" - -[dependency-groups] -dev = [ - "pytest>=8.4.2", -] - -[build-system] -requires = ["uv_build>=0.9.18,<0.10.0"] -build-backend = "uv_build" - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["."] - -[tool.uv.build-backend] -module-name = "pyxray" -module-root = "" diff --git a/pyxray/__init__.py b/pyxray/__init__.py deleted file mode 100644 index 2dc3754..0000000 --- a/pyxray/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""pyxray package.""" - -from importlib.metadata import PackageNotFoundError, version - -__all__ = ["__version__"] - -try: - __version__ = version("pyxray") -except PackageNotFoundError: - __version__ = "0.0.0" diff --git a/pyxray/cli.py b/pyxray/cli.py deleted file mode 100644 index 07d82cf..0000000 --- a/pyxray/cli.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import argparse -from pathlib import Path - -from pyxray.libs.app_data import ( - CONFIG_FILES, - clear_all_data, - clear_download_data, - download_xray_assets, - read_config_file, - resolve_app_data_paths, -) -from pyxray.libs.xray_assets import ASSET_TARGETS -from pyxray.web.server import run_web - - -DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = 8000 -DEFAULT_XRAY_DIR = "data/xray" - - -def main(argv: list[str] | None = None) -> None: - """pyxray 命令行入口。""" - - parser = argparse.ArgumentParser(prog="pyxray") - subparsers = parser.add_subparsers(dest="command") - _add_web_parser(subparsers) - _add_clear_parser(subparsers) - _add_configs_parser(subparsers) - _add_download_parser(subparsers) - - args = parser.parse_args(argv) - if args.command is None: - run_web(DEFAULT_HOST, DEFAULT_PORT, DEFAULT_XRAY_DIR) - return - args.func(args) - - -def _add_web_parser(subparsers: argparse._SubParsersAction) -> None: - """注册 Web 服务启动参数。""" - - parser = subparsers.add_parser("web", help="启动 Web 控制台") - parser.add_argument("--host", default=DEFAULT_HOST, help=f"监听地址,默认 {DEFAULT_HOST}") - parser.add_argument("--port", default=DEFAULT_PORT, type=int, help=f"监听端口,默认 {DEFAULT_PORT}") - parser.add_argument("--xray-dir", default=DEFAULT_XRAY_DIR, help=f"Xray 资源目录,默认 {DEFAULT_XRAY_DIR}") - parser.set_defaults(command="web") - parser.set_defaults(func=_run_web_command) - - -def _add_clear_parser(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser("clear", help="清除 pyxray 配置/数据文件") - scope = parser.add_mutually_exclusive_group(required=True) - scope.add_argument("--all", action="store_true", help="清除所有 pyxray 配置/数据文件和已知生成产物") - scope.add_argument("--download", action="store_true", help="清除下载设置和已知 Xray 下载产物") - parser.add_argument("--xray-dir", default=DEFAULT_XRAY_DIR, help=f"Xray 资源目录,默认 {DEFAULT_XRAY_DIR}") - parser.set_defaults(command="clear", func=_run_clear_command) - - -def _add_configs_parser(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser("configs", help="显示指定 pyxray 配置文件内容") - group = parser.add_mutually_exclusive_group(required=True) - for option in CONFIG_FILES: - group.add_argument(f"--{option.replace('_', '-')}", action="store_const", const=option, dest="config_name") - parser.add_argument("--xray-dir", default=DEFAULT_XRAY_DIR, help=f"Xray 资源目录,默认 {DEFAULT_XRAY_DIR}") - parser.set_defaults(command="configs", func=_run_configs_command) - - -def _add_download_parser(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser("download", help="下载或补齐 Xray 运行资源") - parser.add_argument("--target", choices=ASSET_TARGETS, default=None, help="下载目标:all、xray、geoip、geosite") - parser.add_argument("--directory", default=None, help=f"Xray 资源目录,默认读取 download.toml 或 {DEFAULT_XRAY_DIR}") - parser.add_argument("--version", default=None, help="Xray-core release 版本,例如 v26.5.9") - parser.add_argument("--force", action="store_true", default=None, help="覆盖并重新下载已存在文件") - parser.add_argument("--archive-url", default=None, help="自定义 Xray release zip URL") - parser.add_argument("--geoip-url", default=None, help="自定义 geoip.dat URL") - parser.add_argument("--geosite-url", default=None, help="自定义 geosite.dat URL") - parser.add_argument("--proxy-url", default=None, help="下载代理 URL") - parser.set_defaults(command="download", func=_run_download_command) - - -def _run_web_command(args: argparse.Namespace) -> None: - run_web(args.host, args.port, args.xray_dir) - - -def _run_clear_command(args: argparse.Namespace) -> None: - paths = resolve_app_data_paths(args.xray_dir) - result = clear_all_data(paths) if args.all else clear_download_data(paths) - for path in result.removed: - print(f"removed {path}") - if not result.removed: - print("nothing removed") - - -def _run_configs_command(args: argparse.Namespace) -> None: - paths = resolve_app_data_paths(args.xray_dir) - try: - print(read_config_file(paths, args.config_name), end="") - except FileNotFoundError as exc: - raise SystemExit(f"config file not found: {Path(exc.filename)}") from exc - - -def _run_download_command(args: argparse.Namespace) -> None: - directory = args.directory or DEFAULT_XRAY_DIR - paths = resolve_app_data_paths(directory) - overrides = { - "directory": args.directory, - "version": args.version, - "archive_url": args.archive_url, - "geoip_url": args.geoip_url, - "geosite_url": args.geosite_url, - "proxy_url": args.proxy_url, - "target": args.target, - "force": args.force, - } - assets = download_xray_assets(paths.download_settings, default_directory=directory, overrides=overrides) - print(f"directory: {assets.directory}") - print(f"downloaded: {', '.join(assets.downloaded) if assets.downloaded else 'none'}") - print(f"skipped: {', '.join(assets.skipped) if assets.skipped else 'none'}") - print(f"ready: {assets.ready}") - - -if __name__ == "__main__": - main() diff --git a/pyxray/libs/__init__.py b/pyxray/libs/__init__.py deleted file mode 100644 index 61f5515..0000000 --- a/pyxray/libs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Small reusable building blocks for pyxray.""" diff --git a/pyxray/libs/app_data.py b/pyxray/libs/app_data.py deleted file mode 100644 index 5be7ec9..0000000 --- a/pyxray/libs/app_data.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -import shutil -from dataclasses import dataclass -from pathlib import Path - -from pyxray.libs.xray_asset_settings import XrayAssetSettings, XrayAssetSettingsStore -from pyxray.libs.xray_assets import ensure_xray_assets - - -CONFIG_FILES = { - "download": "download.toml", - "nodes": "nodes.toml", - "settings": "settings.toml", - "config": "config.json", - "service_state": "service-state.json", - "log": "xray.log", -} -GENERATED_DIRS = ("transparent",) -DOWNLOAD_ASSET_FILES = ("xray", "xray.exe", "geoip.dat", "geosite.dat") - - -@dataclass(frozen=True, slots=True) -class AppDataPaths: - """Filesystem layout shared by the Web app and CLI.""" - - data_dir: Path - xray_dir: Path - - @property - def download_settings(self) -> Path: - return self.data_dir / CONFIG_FILES["download"] - - @property - def nodes(self) -> Path: - return self.data_dir / CONFIG_FILES["nodes"] - - @property - def settings(self) -> Path: - return self.data_dir / CONFIG_FILES["settings"] - - @property - def generated_config(self) -> Path: - return self.data_dir / CONFIG_FILES["config"] - - @property - def service_state(self) -> Path: - return self.data_dir / CONFIG_FILES["service_state"] - - @property - def log(self) -> Path: - return self.data_dir / CONFIG_FILES["log"] - - @property - def transparent_dir(self) -> Path: - return self.data_dir / "transparent" - - def config_path(self, name: str) -> Path: - try: - return self.data_dir / CONFIG_FILES[name] - except KeyError as exc: - raise ValueError(f"unsupported config name: {name}") from exc - - -@dataclass(frozen=True, slots=True) -class ClearResult: - removed: tuple[Path, ...] - missing: tuple[Path, ...] - - -def resolve_app_data_paths(xray_dir: str | Path = "data/xray", *, data_dir: str | Path | None = None) -> AppDataPaths: - """Resolve the pyxray data directory from the Xray asset directory.""" - - resolved_xray_dir = Path(xray_dir) - resolved_data_dir = Path(data_dir) if data_dir is not None else default_data_dir(resolved_xray_dir) - return AppDataPaths(data_dir=resolved_data_dir, xray_dir=resolved_xray_dir) - - -def default_data_dir(xray_dir: str | Path) -> Path: - """Use data/xray -> data, matching the Web app's default layout.""" - - path = Path(xray_dir) - return path.parent if path.name == "xray" else path - - -def clear_download_data(paths: AppDataPaths) -> ClearResult: - """Remove persisted download settings and known downloaded Xray assets only.""" - - return _remove_known_paths([paths.download_settings, *_download_asset_paths(paths.xray_dir)]) - - -def clear_all_data(paths: AppDataPaths) -> ClearResult: - """Remove pyxray-owned config/data files and generated artifacts.""" - - targets = [ - *(paths.data_dir / filename for filename in CONFIG_FILES.values()), - *(paths.data_dir / name for name in GENERATED_DIRS), - *_download_asset_paths(paths.xray_dir), - ] - return _remove_known_paths(targets) - - -def read_config_file(paths: AppDataPaths, name: str) -> str: - path = paths.config_path(name) - if not path.exists(): - raise FileNotFoundError(path) - return path.read_text(encoding="utf-8") - - -def download_xray_assets(settings_path: str | Path, *, default_directory: str | Path, overrides: dict[str, object]): - """Merge CLI options with download.toml, persist them, and ensure assets exist.""" - - store = XrayAssetSettingsStore(settings_path, default_directory=default_directory) - current = store.load() - values = current.to_dict() - for key, value in overrides.items(): - if value is not None: - values[key] = value - settings = XrayAssetSettings.from_dict(values) - store.save(settings) - return ensure_xray_assets( - settings.directory, - version=settings.version, - archive_url=settings.archive_url or None, - geoip_url=settings.geoip_url or None, - geosite_url=settings.geosite_url or None, - proxy_url=settings.proxy_url or None, - target=settings.target, - force=settings.force, - ) - - -def _download_asset_paths(xray_dir: Path) -> tuple[Path, ...]: - return tuple(xray_dir / name for name in DOWNLOAD_ASSET_FILES) - - -def _remove_known_paths(paths: list[Path]) -> ClearResult: - removed: list[Path] = [] - missing: list[Path] = [] - for path in dict.fromkeys(paths): - if not path.exists() and not path.is_symlink(): - missing.append(path) - continue - if path.is_dir() and not path.is_symlink(): - shutil.rmtree(path) - else: - path.unlink() - removed.append(path) - return ClearResult(removed=tuple(removed), missing=tuple(missing)) diff --git a/pyxray/libs/nodes/__init__.py b/pyxray/libs/nodes/__init__.py deleted file mode 100644 index 92b6f9a..0000000 --- a/pyxray/libs/nodes/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from pyxray.libs.nodes.errors import InvalidNodeLinkError, NodeLinkError, UnsupportedNodeLinkError -from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links -from pyxray.libs.nodes.manager import AddNodeResult, NodeManager -from pyxray.libs.nodes.model import Node, ParsedNode -from pyxray.libs.nodes.parser import parse_node_link -from pyxray.libs.nodes.store import NodeStore, NodeStoreData - -__all__ = [ - "AddNodeResult", - "ImportNodeResult", - "InvalidNodeLinkError", - "Node", - "NodeLinkError", - "NodeManager", - "NodeStore", - "NodeStoreData", - "ParsedNode", - "UnsupportedNodeLinkError", - "import_node_links", - "parse_node_link", -] diff --git a/pyxray/libs/nodes/common.py b/pyxray/libs/nodes/common.py deleted file mode 100644 index 273fa0c..0000000 --- a/pyxray/libs/nodes/common.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -import base64 -from urllib.parse import unquote - - -def first_value(params: dict[str, list[str]], key: str, default: str = "") -> str: - """从 query 参数里取第一个值。""" - values = params.get(key) - if not values: - return default - return values[0] - - -def decode_base64_text(value: str) -> str: - """兼容 URL-safe 和标准 Base64 的文本解码。""" - padded = value + "=" * (-len(value) % 4) - try: - return base64.urlsafe_b64decode(padded.encode()).decode() - except Exception: - return base64.b64decode(padded.encode()).decode() - - -def display_name(fragment: str, fallback: str) -> str: - """从链接 fragment 取节点名;没有名称时使用服务器地址。""" - name = unquote(fragment).strip() - return name or fallback diff --git a/pyxray/libs/nodes/errors.py b/pyxray/libs/nodes/errors.py deleted file mode 100644 index ad94e69..0000000 --- a/pyxray/libs/nodes/errors.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - - -class NodeLinkError(Exception): - """节点链接处理错误基类。""" - - -class UnsupportedNodeLinkError(NodeLinkError): - """链接协议当前不支持。""" - - -class InvalidNodeLinkError(NodeLinkError): - """链接协议支持,但内容格式无效。""" diff --git a/pyxray/libs/nodes/importer.py b/pyxray/libs/nodes/importer.py deleted file mode 100644 index 0064553..0000000 --- a/pyxray/libs/nodes/importer.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from pyxray.libs.nodes.errors import NodeLinkError -from pyxray.libs.nodes.model import Node -from pyxray.libs.nodes.parser import parse_node_link - - -@dataclass(frozen=True, slots=True) -class ImportNodeResult: - """批量导入单条链接的处理结果。""" - - link: str - node: Node | None = None - error: str | None = None - - @property - def ok(self) -> bool: - """当前链接是否成功解析。""" - return self.node is not None - - -def import_node_links(text: str) -> list[ImportNodeResult]: - """从多行文本里解析节点链接,不处理订阅分组和远程拉取。""" - results: list[ImportNodeResult] = [] - for link in extract_node_links(text): - try: - results.append(ImportNodeResult(link=link, node=parse_node_link(link))) - except NodeLinkError as exc: - results.append(ImportNodeResult(link=link, error=str(exc))) - return results - - -def extract_node_links(text: str) -> list[str]: - """从用户输入中提取候选节点链接。""" - links: list[str] = [] - for line in text.replace("\r\n", "\n").split("\n"): - clean = line.strip() - if not clean or clean.startswith("#"): - continue - links.append(clean) - return links diff --git a/pyxray/libs/nodes/manager.py b/pyxray/libs/nodes/manager.py deleted file mode 100644 index 9ec652b..0000000 --- a/pyxray/libs/nodes/manager.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links -from pyxray.libs.nodes.model import Node, utc_now -from pyxray.libs.nodes.parser import parse_node_link -from pyxray.libs.nodes.store import NodeStore, NodeStoreData - - -@dataclass(slots=True) -class AddNodeResult: - """添加节点后的结果。""" - - node: Node - created: bool - - -class NodeManager: - """管理节点列表和当前选中节点。""" - - def __init__(self, store: NodeStore) -> None: - self.store = store - - def list_nodes(self) -> list[Node]: - """返回当前保存的全部节点。""" - return self.store.load().nodes - - def get_node(self, node_id: str) -> Node | None: - """按节点 ID 查找节点。""" - for node in self.store.load().nodes: - if node.id == node_id: - return node - return None - - def add_link(self, link: str) -> AddNodeResult: - """解析并添加单条节点链接。""" - return self.add_node(parse_node_link(link)) - - def add_node(self, node: Node) -> AddNodeResult: - """添加或更新节点;ID 已存在时保留原创建时间。""" - data = self.store.load() - nodes: list[Node] = [] - created = True - now = utc_now() - - for current in data.nodes: - if current.id != node.id: - nodes.append(current) - continue - node.created_at = current.created_at - node.updated_at = now - nodes.append(node) - created = False - - if created: - node.created_at = now - node.updated_at = now - nodes.append(node) - - self.store.save(NodeStoreData(nodes=nodes, selected_id=data.selected_id)) - return AddNodeResult(node=node, created=created) - - def import_links(self, text: str) -> list[ImportNodeResult]: - """批量导入节点链接;解析失败的链接会保留错误结果。""" - results = import_node_links(text) - for result in results: - if result.node is not None: - self.add_node(result.node) - return results - - def remove_node(self, node_id: str) -> bool: - """删除节点;如果删除的是当前选中节点,则同时清空选择。""" - data = self.store.load() - nodes = [node for node in data.nodes if node.id != node_id] - removed = len(nodes) != len(data.nodes) - if not removed: - return False - - selected_id = "" if data.selected_id == node_id else data.selected_id - self.store.save(NodeStoreData(nodes=nodes, selected_id=selected_id)) - return True - - def select_node(self, node_id: str) -> Node: - """选择一个已存在节点。""" - data = self.store.load() - for node in data.nodes: - if node.id == node_id: - self.store.save(NodeStoreData(nodes=data.nodes, selected_id=node_id)) - return node - raise ValueError(f"node does not exist: {node_id}") - - def clear_selection(self) -> None: - """清空当前选中节点。""" - data = self.store.load() - self.store.save(NodeStoreData(nodes=data.nodes, selected_id="")) - - def get_selected_node(self) -> Node | None: - """返回当前选中节点;没有选择或节点已不存在时返回 None。""" - data = self.store.load() - if not data.selected_id: - return None - for node in data.nodes: - if node.id == data.selected_id: - return node - return None - - def selected_id(self) -> str: - """返回当前选中节点 ID。""" - return self.store.load().selected_id diff --git a/pyxray/libs/nodes/model.py b/pyxray/libs/nodes/model.py deleted file mode 100644 index 8103b04..0000000 --- a/pyxray/libs/nodes/model.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from typing import Any - - -def utc_now() -> str: - """返回标准 UTC 时间字符串。""" - return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -@dataclass(slots=True) -class ParsedNode: - """协议解析后的节点数据,尚未补默认值和生成指纹。""" - - protocol: str - name: str - server: str - port: int - raw_link: str - settings: dict[str, Any] = field(default_factory=dict) - transport: dict[str, Any] = field(default_factory=dict) - security: dict[str, Any] = field(default_factory=dict) - - -@dataclass(slots=True) -class Node: - """标准化后的节点数据。""" - - id: str - name: str - protocol: str - server: str - port: int - raw_link: str - canonical_link: str - fingerprint: str - settings: dict[str, Any] = field(default_factory=dict) - transport: dict[str, Any] = field(default_factory=dict) - security: dict[str, Any] = field(default_factory=dict) - created_at: str = field(default_factory=utc_now) - updated_at: str = field(default_factory=utc_now) - - def to_dict(self) -> dict[str, Any]: - """转换成普通字典,便于后续 API 或持久化使用。""" - return { - "id": self.id, - "name": self.name, - "protocol": self.protocol, - "server": self.server, - "port": self.port, - "raw_link": self.raw_link, - "canonical_link": self.canonical_link, - "fingerprint": self.fingerprint, - "settings": dict(self.settings), - "transport": dict(self.transport), - "security": dict(self.security), - "created_at": self.created_at, - "updated_at": self.updated_at, - } diff --git a/pyxray/libs/nodes/normalize.py b/pyxray/libs/nodes/normalize.py deleted file mode 100644 index 98481ac..0000000 --- a/pyxray/libs/nodes/normalize.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import base64 -import hashlib -import json -from urllib.parse import quote, urlencode - -from pyxray.libs.nodes.model import Node, ParsedNode - - -TRANSPORT_ALIASES = { - "websocket": "ws", - "h2": "http", - "kcp": "mkcp", -} - - -def normalize_node(parsed: ParsedNode) -> Node: - """把协议解析结果转换成统一节点结构。""" - protocol = parsed.protocol.lower() - transport = normalize_transport(parsed.transport) - security = normalize_security(parsed.security) - node = Node( - id="", - name=parsed.name or parsed.server, - protocol=protocol, - server=parsed.server, - port=parsed.port, - raw_link=parsed.raw_link, - canonical_link="", - fingerprint="", - settings=clean_dict(parsed.settings), - transport=transport, - security=security, - ) - node.canonical_link = build_canonical_link(node) - node.fingerprint = node_fingerprint(node) - node.id = node_id(node.protocol, node.fingerprint) - return node - - -def normalize_transport(transport: dict) -> dict: - """统一传输层字段和默认值。""" - network = str(transport.get("network") or "tcp").lower() - network = TRANSPORT_ALIASES.get(network, network) - normalized = {"network": network, **{k: v for k, v in transport.items() if k != "network"}} - normalized.setdefault("header_type", "none") - if network == "xhttp" and not normalized.get("xhttp_mode"): - normalized["xhttp_mode"] = "auto" - return clean_dict(normalized, keep_empty={"host", "path"}) - - -def normalize_security(security: dict) -> dict: - """统一安全层字段和默认值。""" - security_type = str(security.get("type") or "none").lower() - normalized = {"type": security_type, **{k: v for k, v in security.items() if k != "type"}} - return clean_dict(normalized) - - -def node_fingerprint(node: Node) -> str: - """根据真实连接参数生成稳定指纹;节点名称不参与计算。""" - payload = { - "protocol": node.protocol, - "server": node.server, - "port": node.port, - "settings": node.settings, - "transport": node.transport, - "security": node.security, - } - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() - return hashlib.sha256(encoded).hexdigest() - - -def node_id(protocol: str, fingerprint: str) -> str: - """由协议和指纹生成短节点 ID。""" - return f"{protocol}_{fingerprint[:12]}" - - -def build_canonical_link(node: Node) -> str: - """生成稳定分享链接,用于展示或去重排查。""" - if node.protocol == "vless": - return canonical_vless(node) - if node.protocol == "vmess": - return canonical_vmess(node) - if node.protocol == "trojan": - return canonical_trojan(node) - if node.protocol == "trojan-go": - return canonical_trojan_go(node) - if node.protocol == "shadowsocks": - return canonical_shadowsocks(node) - return node.raw_link - - -def canonical_vless(node: Node) -> str: - query = { - "type": node.transport.get("network"), - "security": node.security.get("type"), - "encryption": node.settings.get("encryption", "none"), - "flow": node.settings.get("flow"), - "headerType": node.transport.get("header_type"), - "host": node.transport.get("host"), - "path": node.transport.get("path"), - "serviceName": node.transport.get("service_name"), - "seed": node.transport.get("seed"), - "sni": node.security.get("server_name"), - "alpn": node.security.get("alpn"), - "fp": node.security.get("fingerprint"), - "pbk": node.security.get("public_key"), - "sid": node.security.get("short_id"), - "spx": node.security.get("spider_x"), - "allowInsecure": bool_query(node.security.get("allow_insecure")), - "key": node.transport.get("key"), - "quicSecurity": node.transport.get("quic_security"), - "xhttpMode": node.transport.get("xhttp_mode"), - "maxEarlyData": node.transport.get("max_early_data"), - "earlyDataHeaderName": node.transport.get("early_data_header_name"), - "multiMode": node.transport.get("multi_mode"), - "idleTimeout": node.transport.get("idle_timeout"), - "healthCheckTimeout": node.transport.get("health_check_timeout"), - "permitWithoutStream": node.transport.get("permit_without_stream"), - "initialWindowsSize": node.transport.get("initial_windows_size"), - } - return url_with_query("vless", node.settings["uuid"], node, query) - - -def canonical_vmess(node: Node) -> str: - payload = { - "v": "2", - "ps": node.name, - "add": node.server, - "port": str(node.port), - "id": node.settings["uuid"], - "aid": str(node.settings.get("alter_id", 0)), - "scy": node.settings.get("security", "auto"), - "net": node.transport.get("network"), - "type": node.transport.get("header_type", "none"), - "host": node.transport.get("host", ""), - "path": node.transport.get("path", ""), - "tls": node.security.get("type", "none"), - "sni": node.security.get("server_name", ""), - "alpn": node.security.get("alpn", ""), - "fp": node.security.get("fingerprint", ""), - } - encoded = base64.urlsafe_b64encode(json.dumps(clean_dict(payload), separators=(",", ":")).encode()).decode() - return "vmess://" + encoded.rstrip("=") - - -def canonical_trojan(node: Node) -> str: - query = { - "type": node.transport.get("network"), - "host": node.transport.get("host"), - "path": node.transport.get("path"), - "serviceName": node.transport.get("service_name"), - "sni": node.security.get("server_name"), - "alpn": node.security.get("alpn"), - "allowInsecure": bool_query(node.security.get("allow_insecure")), - } - return url_with_query("trojan", node.settings["password"], node, query) - - -def canonical_trojan_go(node: Node) -> str: - query = { - "type": node.transport.get("network"), - "host": node.transport.get("host"), - "path": node.transport.get("path"), - "serviceName": node.transport.get("service_name"), - "sni": node.security.get("server_name"), - "encryption": node.settings.get("encryption"), - } - return url_with_query("trojan-go", node.settings["password"], node, query) - - -def canonical_shadowsocks(node: Node) -> str: - user = f"{node.settings['method']}:{node.settings['password']}" - encoded_user = base64.urlsafe_b64encode(user.encode()).decode().rstrip("=") - return url_with_query("ss", encoded_user, node, {"plugin": node.settings.get("plugin")}, quote_username=False) - - -def url_with_query(scheme: str, username: str, node: Node, query: dict, *, quote_username: bool = True) -> str: - clean_query = {key: value for key, value in query.items() if value not in (None, "", "none")} - query_text = urlencode(clean_query) - user = quote(str(username), safe="") if quote_username else str(username) - url = f"{scheme}://{user}@{node.server}:{node.port}" - if query_text: - url += "?" + query_text - if node.name: - url += "#" + quote(node.name) - return url - - -def bool_query(value: object) -> str: - """把布尔值转换成分享链接里的 query 表达。""" - if value is True: - return "true" - return "" - - -def clean_dict(values: dict, *, keep_empty: set[str] | None = None) -> dict: - """移除空值,减少标准节点中的噪声字段。""" - keep_empty = keep_empty or set() - return { - key: value - for key, value in values.items() - if value is not None and (value != "" or key in keep_empty) - } diff --git a/pyxray/libs/nodes/parser.py b/pyxray/libs/nodes/parser.py deleted file mode 100644 index 2a4fa57..0000000 --- a/pyxray/libs/nodes/parser.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from urllib.parse import urlparse - -from pyxray.libs.nodes.errors import UnsupportedNodeLinkError -from pyxray.libs.nodes.model import Node, ParsedNode -from pyxray.libs.nodes.normalize import normalize_node -from pyxray.libs.nodes.parsers import parse_shadowsocks, parse_trojan, parse_vless, parse_vmess - - -def parse_node_link(link: str) -> Node: - """解析单个节点链接,并返回标准化后的节点。""" - return normalize_node(parse_raw_node_link(link)) - - -def parse_raw_node_link(link: str) -> ParsedNode: - """解析单个节点链接,不补默认值,不生成指纹。""" - clean = link.strip() - scheme = urlparse(clean).scheme.lower() - if scheme == "vless": - return parse_vless(clean) - if scheme == "vmess": - return parse_vmess(clean) - if scheme in {"trojan", "trojan-go"}: - return parse_trojan(clean) - if scheme in {"ss", "shadowsocks"}: - return parse_shadowsocks(clean) - raise UnsupportedNodeLinkError(f"unsupported node link scheme: {scheme or ''}") diff --git a/pyxray/libs/nodes/parsers/__init__.py b/pyxray/libs/nodes/parsers/__init__.py deleted file mode 100644 index cc603c6..0000000 --- a/pyxray/libs/nodes/parsers/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from pyxray.libs.nodes.parsers.shadowsocks import parse_shadowsocks -from pyxray.libs.nodes.parsers.trojan import parse_trojan -from pyxray.libs.nodes.parsers.vless import parse_vless -from pyxray.libs.nodes.parsers.vmess import parse_vmess - -__all__ = [ - "parse_shadowsocks", - "parse_trojan", - "parse_vless", - "parse_vmess", -] diff --git a/pyxray/libs/nodes/parsers/shadowsocks.py b/pyxray/libs/nodes/parsers/shadowsocks.py deleted file mode 100644 index fdad136..0000000 --- a/pyxray/libs/nodes/parsers/shadowsocks.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from urllib.parse import parse_qs, urlparse - -from pyxray.libs.nodes.common import decode_base64_text, display_name, first_value -from pyxray.libs.nodes.errors import InvalidNodeLinkError -from pyxray.libs.nodes.model import ParsedNode - - -def parse_shadowsocks(link: str) -> ParsedNode: - """解析 Shadowsocks SIP002 分享链接。""" - parsed = normalize_shadowsocks_url(link) - user = parsed.username or "" - if ":" not in user: - try: - user = decode_base64_text(user) - except Exception as exc: - raise InvalidNodeLinkError("invalid shadowsocks userinfo") from exc - if ":" not in user or not parsed.hostname or not parsed.port: - raise InvalidNodeLinkError("shadowsocks link must include method, password, host and port") - - method, password = user.split(":", 1) - params = parse_qs(parsed.query, keep_blank_values=True) - plugin = first_value(params, "plugin") - return ParsedNode( - protocol="shadowsocks", - name=display_name(parsed.fragment, parsed.hostname), - server=parsed.hostname, - port=parsed.port, - raw_link=link, - settings={ - "method": method.lower(), - "password": password, - "plugin": plugin, - "plugin_options": parse_sip003_plugin(plugin), - }, - transport={"network": "tcp"}, - security={"type": "none"}, - ) - - -def normalize_shadowsocks_url(link: str): - """兼容整段 base64 的旧 SS 链接格式。""" - parsed = urlparse(link) - if parsed.hostname: - return parsed - - body = link[5:] - fragment = "" - if "#" in body: - body, fragment = body.split("#", 1) - try: - decoded = decode_base64_text(body) - except Exception as exc: - raise InvalidNodeLinkError("invalid shadowsocks base64 payload") from exc - normalized = "ss://" + decoded - if fragment: - normalized += "#" + fragment - return urlparse(normalized) - - -def parse_sip003_plugin(plugin: str) -> dict: - """解析 SIP003 plugin 参数,保留原始值并提供结构化字段。""" - if not plugin: - return {} - name, _, opts_text = plugin.partition(";") - if name in {"obfs-local", "simpleobfs"}: - name = "simple-obfs" - options = { - "name": name, - "raw": plugin, - "tls": "", - "obfs": "", - "host": "", - "path": "", - "impl": "", - } - for item in opts_text.split(";"): - if not item: - continue - key, _, value = item.partition("=") - if key == "tls": - options["tls"] = "tls" - elif key in {"obfs", "mode"}: - options["obfs"] = value - elif key in {"obfs-host", "host"}: - options["host"] = value - elif key in {"obfs-path", "obfs-uri", "path"}: - options["path"] = value if value.startswith("/") else f"/{value}" - elif key == "impl": - options["impl"] = value - return options diff --git a/pyxray/libs/nodes/parsers/trojan.py b/pyxray/libs/nodes/parsers/trojan.py deleted file mode 100644 index ceb6111..0000000 --- a/pyxray/libs/nodes/parsers/trojan.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from urllib.parse import parse_qs, urlparse - -from pyxray.libs.nodes.common import display_name, first_value -from pyxray.libs.nodes.errors import InvalidNodeLinkError -from pyxray.libs.nodes.model import ParsedNode - - -def parse_trojan(link: str) -> ParsedNode: - """解析 Trojan 和 Trojan-Go 分享链接。""" - parsed = urlparse(link) - if not parsed.username or not parsed.hostname or not parsed.port: - raise InvalidNodeLinkError("trojan link must include password, host and port") - - params = parse_qs(parsed.query, keep_blank_values=True) - network = first_value(params, "type", "tcp") or "tcp" - server_name = first_value(params, "peer") or first_value(params, "sni") or parsed.hostname - is_trojan_go = parsed.scheme.lower() == "trojan-go" - return ParsedNode( - protocol="trojan-go" if is_trojan_go else "trojan", - name=display_name(parsed.fragment, parsed.hostname), - server=parsed.hostname, - port=parsed.port, - raw_link=link, - settings={ - "password": parsed.username, - "encryption": first_value(params, "encryption") if is_trojan_go else "", - }, - transport={ - "network": network, - "host": first_value(params, "host"), - "path": first_value(params, "path"), - "service_name": first_value(params, "serviceName"), - }, - security={ - "type": "tls", - "server_name": server_name, - "alpn": first_value(params, "alpn"), - "allow_insecure": False if is_trojan_go else first_value(params, "allowInsecure").lower() in {"1", "true"}, - }, - ) diff --git a/pyxray/libs/nodes/parsers/vless.py b/pyxray/libs/nodes/parsers/vless.py deleted file mode 100644 index e259093..0000000 --- a/pyxray/libs/nodes/parsers/vless.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from urllib.parse import parse_qs, urlparse - -from pyxray.libs.nodes.common import display_name, first_value -from pyxray.libs.nodes.errors import InvalidNodeLinkError -from pyxray.libs.nodes.model import ParsedNode - - -def parse_vless(link: str) -> ParsedNode: - """解析 VLESS 分享链接。""" - parsed = urlparse(link) - if not parsed.username or not parsed.hostname or not parsed.port: - raise InvalidNodeLinkError("vless link must include uuid, host and port") - - params = parse_qs(parsed.query, keep_blank_values=True) - network = first_value(params, "type", "tcp") or "tcp" - security_type = first_value(params, "security", "none") or "none" - transport = { - "network": network, - "header_type": first_value(params, "headerType", "none") or "none", - "host": first_value(params, "host"), - "path": first_value(params, "path"), - "service_name": first_value(params, "serviceName"), - "seed": first_value(params, "seed"), - "key": first_value(params, "key"), - "quic_security": first_value(params, "quicSecurity"), - "xhttp_mode": first_value(params, "xhttpMode"), - "max_early_data": first_value(params, "maxEarlyData"), - "early_data_header_name": first_value(params, "earlyDataHeaderName"), - "multi_mode": first_value(params, "multiMode"), - "idle_timeout": first_value(params, "idleTimeout"), - "health_check_timeout": first_value(params, "healthCheckTimeout"), - "permit_without_stream": first_value(params, "permitWithoutStream"), - "initial_windows_size": first_value(params, "initialWindowsSize"), - } - security = { - "type": security_type, - "server_name": first_value(params, "sni"), - "fingerprint": first_value(params, "fp"), - "alpn": first_value(params, "alpn"), - "allow_insecure": first_value(params, "allowInsecure").lower() in {"1", "true"}, - "public_key": first_value(params, "pbk"), - "short_id": first_value(params, "sid"), - "spider_x": first_value(params, "spx"), - } - return ParsedNode( - protocol="vless", - name=display_name(parsed.fragment, parsed.hostname), - server=parsed.hostname, - port=parsed.port, - raw_link=link, - settings={ - "uuid": parsed.username, - "flow": first_value(params, "flow"), - "encryption": first_value(params, "encryption", "none") or "none", - }, - transport=transport, - security=security, - ) diff --git a/pyxray/libs/nodes/parsers/vmess.py b/pyxray/libs/nodes/parsers/vmess.py deleted file mode 100644 index 1d26d32..0000000 --- a/pyxray/libs/nodes/parsers/vmess.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import json -import re -from urllib.parse import parse_qs, urlparse - -from pyxray.libs.nodes.common import decode_base64_text, first_value -from pyxray.libs.nodes.errors import InvalidNodeLinkError -from pyxray.libs.nodes.model import ParsedNode - - -def parse_vmess(link: str) -> ParsedNode: - """解析 VMess Base64 JSON 分享链接。""" - try: - data = json.loads(decode_base64_text(link[8:])) - except Exception as exc: - return parse_legacy_vmess(link, exc) - - server = str(data.get("add") or "") - port = int(data.get("port") or 0) - uuid = str(data.get("id") or "") - if not server or not port or not uuid: - raise InvalidNodeLinkError("vmess link must include id, add and port") - - host = str(data.get("host") or "") - path = str(data.get("path") or "") - if host.startswith("/") and not path: - path = host - host = "" - - return ParsedNode( - protocol="vmess", - name=str(data.get("ps") or server), - server=server, - port=port, - raw_link=link, - settings={ - "uuid": uuid, - "alter_id": int(data.get("aid") or 0), - "security": str(data.get("scy") or "auto"), - }, - transport={ - "network": str(data.get("net") or "tcp"), - "header_type": str(data.get("type") or "none"), - "host": host, - "path": path, - }, - security={ - "type": str(data.get("tls") or "none"), - "server_name": str(data.get("sni") or ""), - "alpn": str(data.get("alpn") or ""), - "fingerprint": str(data.get("fp") or data.get("fingerprint") or ""), - "allow_insecure": _bool(data.get("allowInsecure")), - }, - ) - - -def parse_legacy_vmess(link: str, original_error: Exception) -> ParsedNode: - """兼容旧式 VMess 链接。""" - parsed = urlparse(link) - encoded = link[8:].split("?", 1)[0] - try: - decoded = decode_base64_text(encoded) - except Exception as exc: - raise InvalidNodeLinkError("unsupported vmess link format") from original_error or exc - - match = re.match(r".*:(.+)@(.+):(\d+)$", decoded) - if match is None: - raise InvalidNodeLinkError("unsupported vmess link format") from original_error - - params = parse_qs(parsed.query, keep_blank_values=True) - name = first_value(params, "remarks") or first_value(params, "remark") - network = first_value(params, "obfs") - host = first_value(params, "obfsParam") - path = first_value(params, "path") - if network in {"kcp", "mkcp"} and host: - try: - path = str(json.loads(host).get("seed") or "") - host = "" - except Exception: - pass - if network == "websocket": - network = "ws" - alter_id = first_value(params, "alterId") or first_value(params, "aid") or "0" - vmess_security = first_value(params, "scy") or first_value(params, "security") or "auto" - tls = "tls" if first_value(params, "tls") == "1" else "none" - return ParsedNode( - protocol="vmess", - name=name or match.group(2), - server=match.group(2), - port=int(match.group(3)), - raw_link=link, - settings={ - "uuid": match.group(1), - "alter_id": int(alter_id), - "security": vmess_security, - }, - transport={ - "network": network or "tcp", - "header_type": "none", - "host": host, - "path": path, - }, - security={ - "type": tls, - "server_name": first_value(params, "sni"), - "allow_insecure": False, - }, - ) - - -def _bool(value: object) -> bool: - """兼容字符串和布尔值形式的开关参数。""" - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in {"1", "true"} - return False diff --git a/pyxray/libs/nodes/store.py b/pyxray/libs/nodes/store.py deleted file mode 100644 index a60848d..0000000 --- a/pyxray/libs/nodes/store.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import tomlkit - -from pyxray.libs.nodes.model import Node - - -@dataclass(slots=True) -class NodeStoreData: - """节点 TOML 文件中的完整状态。""" - - nodes: list[Node] = field(default_factory=list) - selected_id: str = "" - - -class NodeStore: - """负责节点 TOML 文件读写,不处理节点解析和业务选择逻辑。""" - - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - - def load(self) -> NodeStoreData: - """读取节点文件;文件不存在时返回空状态。""" - if not self.path.exists(): - return NodeStoreData() - - raw = tomlkit.parse(self.path.read_text(encoding="utf-8")) - nodes = [_node_from_dict(item) for item in raw.get("nodes", [])] - selected_id = str(raw.get("selected_id") or "") - return NodeStoreData(nodes=nodes, selected_id=selected_id) - - def save(self, data: NodeStoreData) -> None: - """原子写入节点文件。""" - self.path.parent.mkdir(parents=True, exist_ok=True) - content = dump_nodes_toml(data) - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=self.path.parent, - prefix=f".{self.path.name}.", - suffix=".tmp", - delete=False, - ) as temp: - temp.write(content) - temp_path = Path(temp.name) - os.replace(temp_path, self.path) - - -def dump_nodes_toml(data: NodeStoreData) -> str: - """把节点状态输出为稳定 TOML 文本。""" - document = tomlkit.document() - document["selected_id"] = data.selected_id - - nodes = tomlkit.aot() - for node in data.nodes: - table = tomlkit.table() - for key, value in node.to_dict().items(): - table[key] = _toml_table(value) if isinstance(value, dict) else value - nodes.append(table) - document["nodes"] = nodes - return tomlkit.dumps(document) - - -def _node_from_dict(values: dict[str, Any]) -> Node: - return Node( - id=str(values["id"]), - name=str(values["name"]), - protocol=str(values["protocol"]), - server=str(values["server"]), - port=int(values["port"]), - raw_link=str(values["raw_link"]), - canonical_link=str(values["canonical_link"]), - fingerprint=str(values["fingerprint"]), - settings=dict(values.get("settings", {})), - transport=dict(values.get("transport", {})), - security=dict(values.get("security", {})), - created_at=str(values["created_at"]), - updated_at=str(values["updated_at"]), - ) - - -def _toml_table(values: dict[str, Any]): - table = tomlkit.table() - for key, value in values.items(): - table[key] = _toml_table(value) if isinstance(value, dict) else value - return table diff --git a/pyxray/libs/xray_asset_settings.py b/pyxray/libs/xray_asset_settings.py deleted file mode 100644 index 82344f2..0000000 --- a/pyxray/libs/xray_asset_settings.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Any - -import tomlkit - -from pyxray.libs.xray_assets import default_xray_version - - -@dataclass(slots=True) -class XrayAssetSettings: - """Xray 资源下载页面的可持久化设置。""" - - directory: str = "data/xray" - version: str = "" - archive_url: str = "" - geoip_url: str = "" - geosite_url: str = "" - proxy_url: str = "" - target: str = "all" - force: bool = False - - def to_dict(self) -> dict[str, Any]: - return asdict(self) - - @classmethod - def from_dict(cls, values: dict[str, Any]) -> XrayAssetSettings: - defaults = cls() - return cls( - directory=str(values.get("directory", defaults.directory)), - version=str(values.get("version", defaults.version)), - archive_url=str(values.get("archive_url", defaults.archive_url)), - geoip_url=str(values.get("geoip_url", defaults.geoip_url)), - geosite_url=str(values.get("geosite_url", defaults.geosite_url)), - proxy_url=str(values.get("proxy_url", defaults.proxy_url)), - target=str(values.get("target", defaults.target)), - force=bool(values.get("force", defaults.force)), - ) - - -class XrayAssetSettingsStore: - """负责 download.toml 的读写。""" - - def __init__(self, path: str | Path, *, default_directory: str | Path = "data/xray") -> None: - self.path = Path(path) - self.default_directory = str(default_directory) - - def load(self) -> XrayAssetSettings: - if not self.path.exists(): - return XrayAssetSettings(directory=self.default_directory, version=default_xray_version()) - values = dict(tomlkit.parse(self.path.read_text(encoding="utf-8"))) - if "directory" not in values: - values["directory"] = self.default_directory - return XrayAssetSettings.from_dict(values) - - def save(self, settings: XrayAssetSettings) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - document = tomlkit.document() - for key, value in settings.to_dict().items(): - document[key] = value - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=self.path.parent, - prefix=f".{self.path.name}.", - suffix=".tmp", - delete=False, - ) as temp: - temp.write(tomlkit.dumps(document)) - temp_path = Path(temp.name) - os.replace(temp_path, self.path) diff --git a/pyxray/libs/xray_assets.py b/pyxray/libs/xray_assets.py deleted file mode 100644 index bbfc219..0000000 --- a/pyxray/libs/xray_assets.py +++ /dev/null @@ -1,334 +0,0 @@ -from __future__ import annotations - -import os -import platform -import stat -import json -import urllib.parse -import urllib.request -import zipfile -from dataclasses import dataclass -from io import BytesIO -from pathlib import Path -from typing import Callable - - -OFFICIAL_RELEASE_BASE = "https://github.com/XTLS/Xray-core/releases/download" -OFFICIAL_LATEST_RELEASE_API = "https://api.github.com/repos/XTLS/Xray-core/releases/latest" -DEFAULT_VERSION = "v26.5.9" -DEFAULT_ARCHIVE_NAME = "Xray-linux-64.zip" -REQUIRED_DATA_FILES = ("geoip.dat", "geosite.dat") -ASSET_TARGETS = ("all", "xray", "geoip", "geosite") - -Downloader = Callable[[str], bytes] -DownloadProgress = Callable[[str, int, int | None], None] -VersionFetcher = Callable[[str, float], str] - -_DEFAULT_VERSION_CACHE: str | None = None - - -@dataclass(frozen=True, slots=True) -class XrayAssets: - """Xray 运行所需文件的本地路径和本次下载结果。""" - - directory: Path - xray: Path - geoip: Path - geosite: Path - downloaded: tuple[str, ...] - skipped: tuple[str, ...] = () - - @property - def ready(self) -> bool: - """三个核心文件都存在时返回 True。""" - - return self.xray.exists() and self.geoip.exists() and self.geosite.exists() - - -@dataclass(frozen=True, slots=True) -class XrayAssetStatus: - """Xray 资源文件的本地检查结果。""" - - directory: Path - files: dict[str, bool] - - @property - def ready(self) -> bool: - """所有必需文件都存在时返回 True。""" - - return all(self.files.values()) - - @property - def missing(self) -> tuple[str, ...]: - """返回缺失的文件名。""" - - return tuple(name for name, exists in self.files.items() if not exists) - - -def xray_executable_name(os_name: str | None = None) -> str: - """返回当前平台的 Xray 可执行文件名。""" - - return "xray.exe" if (os_name or os.name) == "nt" else "xray" - - -def required_files(os_name: str | None = None) -> tuple[str, ...]: - """返回当前平台运行 Xray 所需的核心文件。""" - - return (xray_executable_name(os_name), *REQUIRED_DATA_FILES) - - -def default_archive_name(os_name: str | None = None, machine: str | None = None) -> str: - """返回当前平台默认使用的 Xray-core release zip 文件名。""" - - resolved_os = os_name or os.name - resolved_machine = (machine or platform.machine()).lower() - is_arm64 = resolved_machine in {"arm64", "aarch64"} - if resolved_os == "nt": - return "Xray-windows-arm64-v8a.zip" if is_arm64 else "Xray-windows-64.zip" - return "Xray-linux-arm64-v8a.zip" if is_arm64 else DEFAULT_ARCHIVE_NAME - - -def official_archive_url(version: str = DEFAULT_VERSION, archive_name: str | None = None) -> str: - """返回官方 Xray-core release zip 下载地址。""" - - return f"{OFFICIAL_RELEASE_BASE}/{version}/{archive_name or default_archive_name()}" - - -def latest_xray_version(*, timeout: float = 5.0, fetcher: VersionFetcher | None = None) -> str: - """从 GitHub release API 获取最新 Xray-core 版本。""" - - payload = (fetcher or _fetch_url_text)(OFFICIAL_LATEST_RELEASE_API, timeout) - values = json.loads(payload) - tag = str(values.get("tag_name") or "").strip() - if not tag: - raise ValueError("latest Xray release response has no tag_name") - return tag - - -def default_xray_version(*, timeout: float = 5.0) -> str: - """返回默认 Xray 版本;优先远程最新版本,失败时回退到内置版本。""" - - global _DEFAULT_VERSION_CACHE - if _DEFAULT_VERSION_CACHE: - return _DEFAULT_VERSION_CACHE - try: - _DEFAULT_VERSION_CACHE = latest_xray_version(timeout=timeout) - except Exception: # noqa: BLE001 - _DEFAULT_VERSION_CACHE = DEFAULT_VERSION - return _DEFAULT_VERSION_CACHE - - -def check_xray_assets(directory: str | Path) -> XrayAssetStatus: - """检查目录中的 Xray 必需文件。""" - - directory = Path(directory) - return XrayAssetStatus( - directory=directory, - files={name: (directory / name).exists() for name in required_files()}, - ) - - -def ensure_xray_assets( - directory: str | Path, - *, - version: str = DEFAULT_VERSION, - archive_url: str | None = None, - geoip_url: str | None = None, - geosite_url: str | None = None, - proxy_url: str | None = None, - target: str = "all", - force: bool = False, - downloader: Downloader | None = None, -) -> XrayAssets: - """确保指定目录中存在 xray、geoip.dat 和 geosite.dat。 - - 默认从 Xray-core 官方 release zip 下载并解压三个文件。传入 - geoip_url 或 geosite_url 时,对应 dat 文件会从指定地址单独下载并 - 覆盖 zip 中的版本。 - - Args: - directory: 文件保存目录。 - version: 官方 release 版本,例如 ``v26.5.9``。 - archive_url: 自定义 xray release zip 地址;为空时使用官方地址。 - geoip_url: 自定义 geoip.dat 下载地址;为空时使用 zip 内文件。 - geosite_url: 自定义 geosite.dat 下载地址;为空时使用 zip 内文件。 - proxy_url: 自定义 HTTP/HTTPS 代理地址;为空时使用系统代理设置。 - target: 下载目标,可选 ``all``、``xray``、``geoip``、``geosite``。 - force: 为 True 时重新下载并覆盖已有文件。 - downloader: 下载函数,测试时可注入 fake downloader。 - - Raises: - FileNotFoundError: 下载/解压后仍缺少必要文件。 - zipfile.BadZipFile: 下载内容不是有效 zip。 - urllib.error.URLError: 默认下载器访问 URL 失败。 - """ - - directory = Path(directory) - directory.mkdir(parents=True, exist_ok=True) - downloader = downloader or (lambda url: download_bytes(url, proxy_url=proxy_url)) - if target not in ASSET_TARGETS: - raise ValueError(f"unsupported xray asset target: {target}") - - xray = directory / xray_executable_name() - geoip = directory / "geoip.dat" - geosite = directory / "geosite.dat" - downloaded: list[str] = [] - skipped: list[str] = [] - - requested = _requested_files(target) - xray_name = xray_executable_name() - archive_names = { - name - for name in requested - if name == xray_name - or (name == "geoip.dat" and geoip_url is None) - or (name == "geosite.dat" and geosite_url is None) - } - archive_names = {name for name in archive_names if force or not (directory / name).exists()} - skipped.extend(name for name in requested if not force and (directory / name).exists()) - - # xray 本体只能来自 release zip;未指定独立地址的 dat 也从 zip 获取。 - if archive_names: - archive = downloader(archive_url or official_archive_url(version)) - _extract_from_zip(archive, directory, archive_names) - downloaded.append("archive") - - # 用户指定 dat 地址时,它们优先级高于 release zip 内置文件。 - if "geoip.dat" in requested and geoip_url is not None and (force or not geoip.exists()): - _write_file(geoip, downloader(geoip_url)) - downloaded.append("geoip.dat") - - if "geosite.dat" in requested and geosite_url is not None and (force or not geosite.exists()): - _write_file(geosite, downloader(geosite_url)) - downloaded.append("geosite.dat") - - for name in requested: - if not (directory / name).exists(): - raise FileNotFoundError(f"{name} was not found in {directory}") - - if xray.exists(): - _make_executable(xray) - return XrayAssets( - directory=directory, - xray=xray, - geoip=geoip, - geosite=geosite, - downloaded=tuple(downloaded), - skipped=tuple(dict.fromkeys(skipped)), - ) - - -def download_bytes(url: str, *, proxy_url: str | None = None) -> bytes: - """下载 URL 内容并返回 bytes。 - - proxy_url 为空时使用 urllib 默认 opener,因此会自动读取系统代理环境。 - """ - - with _open_url(url, proxy_url) as response: - return response.read() - - -def _fetch_url_text(url: str, timeout: float) -> str: - with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - return response.read().decode("utf-8") - - -def download_bytes_stream( - url: str, - progress: DownloadProgress, - *, - proxy_url: str | None = None, - chunk_size: int = 1024 * 256, -) -> bytes: - """按块下载 URL 内容,并通过 progress 回调报告真实下载进度。""" - - chunks: list[bytes] = [] - received = 0 - with _open_url(url, proxy_url) as response: - length = response.headers.get("Content-Length") - total = int(length) if length and length.isdigit() else None - progress(url, received, total) - while True: - chunk = response.read(chunk_size) - if not chunk: - break - chunks.append(chunk) - received += len(chunk) - progress(url, received, total) - return b"".join(chunks) - - -def _open_url(url: str, proxy_url: str | None): - """打开 URL;指定代理时使用该代理,否则走系统默认代理配置。""" - - if proxy_url: - opener = _proxy_opener(proxy_url) - return opener.open(url) - return urllib.request.urlopen(url) # noqa: S310 - - -def _proxy_opener(proxy_url: str): - parsed = urllib.parse.urlsplit(proxy_url) - if not parsed.username: - return urllib.request.build_opener(urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})) - - clean_proxy_url = _proxy_url_without_auth(parsed) - password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm() - password_manager.add_password( - None, - clean_proxy_url, - urllib.parse.unquote(parsed.username), - urllib.parse.unquote(parsed.password or ""), - ) - return urllib.request.build_opener( - urllib.request.ProxyHandler({"http": clean_proxy_url, "https": clean_proxy_url}), - urllib.request.ProxyBasicAuthHandler(password_manager), - urllib.request.ProxyDigestAuthHandler(password_manager), - ) - - -def _proxy_url_without_auth(parsed: urllib.parse.SplitResult) -> str: - host = parsed.hostname or "" - if ":" in host and not host.startswith("["): - host = f"[{host}]" - netloc = f"{host}:{parsed.port}" if parsed.port else host - return urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) - - -def _extract_from_zip(data: bytes, directory: Path, names: set[str]) -> None: - """从 zip 中按文件名提取需要的文件,忽略目录层级。""" - - with zipfile.ZipFile(BytesIO(data)) as archive: - entries = {Path(item.filename).name: item for item in archive.infolist()} - for name in names: - if name in entries: - _write_file(directory / name, archive.read(entries[name])) - - -def _requested_files(target: str) -> tuple[str, ...]: - """把用户选择的下载目标转换为实际文件名。""" - - if target == "all": - return required_files() - if target == "xray": - return (xray_executable_name(),) - if target == "geoip": - return ("geoip.dat",) - if target == "geosite": - return ("geosite.dat",) - raise ValueError(f"unsupported xray asset target: {target}") - - -def _write_file(path: Path, data: bytes) -> None: - """先写临时文件再替换,避免半写入文件被误用。""" - - tmp = path.with_name(f".{path.name}.tmp") - tmp.write_bytes(data) - os.replace(tmp, path) - - -def _make_executable(path: Path) -> None: - """确保 xray 二进制具备基础执行权限。""" - - mode = path.stat().st_mode - path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) diff --git a/pyxray/libs/xray_config/__init__.py b/pyxray/libs/xray_config/__init__.py deleted file mode 100644 index 7cf2a2b..0000000 --- a/pyxray/libs/xray_config/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from pyxray.libs.xray_config.generator import generate_xray_config -from pyxray.libs.xray_config.settings import XrayConfigSettings -from pyxray.libs.xray_config.store import XrayConfigSettingsStore -from pyxray.libs.xray_config.tinytun_config import generate_tinytun_config, write_tinytun_config_file -from pyxray.libs.xray_config.transparent_rules import ( - TransparentRuleFiles, - TransparentRuleSet, - generate_transparent_rules, - write_transparent_rule_files, -) - -__all__ = [ - "TransparentRuleFiles", - "TransparentRuleSet", - "XrayConfigSettings", - "XrayConfigSettingsStore", - "generate_tinytun_config", - "generate_transparent_rules", - "generate_xray_config", - "write_tinytun_config_file", - "write_transparent_rule_files", -] diff --git a/pyxray/libs/xray_config/generator.py b/pyxray/libs/xray_config/generator.py deleted file mode 100644 index a9cefef..0000000 --- a/pyxray/libs/xray_config/generator.py +++ /dev/null @@ -1,463 +0,0 @@ -from __future__ import annotations - -import ipaddress -import uuid -from typing import Any - -from pyxray.libs.nodes.model import Node -from pyxray.libs.xray_config.outbound import block_outbound, direct_outbound, dns_outbound, node_to_outbound -from pyxray.libs.xray_config.settings import DnsRuleSettings, XrayConfigSettings, validate_settings - - -def generate_xray_config(node: Node, settings: XrayConfigSettings | None = None) -> dict[str, Any]: - """生成 Xray JSON 配置。""" - settings = settings or XrayConfigSettings() - validate_settings(settings) - config: dict[str, Any] = { - "inbounds": _build_inbounds(settings), - "outbounds": [ - node_to_outbound(node, settings, tag="proxy"), - direct_outbound(settings), - block_outbound(), - dns_outbound(), - ], - "routing": { - "domainStrategy": "IPOnDemand", - "domainMatcher": "mph", - "rules": [], - }, - "dns": _build_dns(settings, node), - } - log = _build_log(settings) - if log: - config["log"] = log - if _fakedns_enabled(settings): - config["fakedns"] = [{"ipPool": "198.18.0.0/15", "poolSize": 65535}] - config["routing"]["rules"].extend(_build_dns_routing(settings)) - config["routing"]["rules"].append({"type": "field", "inboundTag": _dns_inbound_tags(settings), "outboundTag": "dns-out"}) - config["routing"]["rules"].extend(_build_rule_port_routing(settings)) - config["routing"]["rules"].extend(_build_transparent_routing(settings)) - config["routing"]["rules"].extend(_build_selected_node_whitelist(node)) - config["routing"]["rules"].append({"type": "field", "port": "0-65535", "outboundTag": "proxy"}) - api = _build_api(settings) - if api: - config["api"] = api["api"] - config["inbounds"].append(api["inbound"]) - config["routing"]["rules"].append(api["routing"]) - return config - - -def _build_log(settings: XrayConfigSettings) -> dict[str, str] | None: - log_level = settings.core.log_level.lower() - if log_level in {"trace", "debug"}: - return {"loglevel": "debug", "access": "", "error": ""} - if log_level == "info": - return {"loglevel": "info", "access": "", "error": ""} - if log_level in {"warn", "warning"}: - return {"loglevel": "warning", "access": "none", "error": ""} - if log_level == "error": - return {"loglevel": "error", "access": "none", "error": ""} - if log_level == "none": - return {"loglevel": "none", "access": "none", "error": "none"} - return None - - -def _build_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: - listen = "0.0.0.0" if settings.inbounds.port_sharing else settings.inbounds.listen - inbounds = [ - _socks_inbound(settings.inbounds.socks_port, listen, "socks", settings), - _http_inbound(settings.inbounds.http_port, listen, "http", settings), - _mixed_inbound(settings.inbounds.rule_http_port, listen, "rule-mixed", settings), - _vmess_inbound(settings.inbounds.vmess_port, listen, settings), - ] - for custom in settings.inbounds.custom: - if custom.protocol == "socks": - inbounds.append(_socks_inbound(custom.port, listen, custom.tag, settings)) - else: - inbounds.append(_http_inbound(custom.port, listen, custom.tag, settings)) - inbounds.extend(_transparent_inbounds(settings)) - if _should_local_dns_listen(settings): - inbounds.extend(_dns_inbounds(settings)) - return [item for item in inbounds if item and item["port"] > 0] - - -def _socks_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]: - return _with_sniffing( - {"port": port, "listen": listen, "protocol": "socks", "settings": _inbound_proxy_settings(settings), "tag": tag}, - settings, - ) - - -def _http_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]: - return _with_sniffing({"port": port, "listen": listen, "protocol": "http", "tag": tag}, settings) - - -def _mixed_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]: - return _with_sniffing( - { - "port": port, - "listen": listen, - "protocol": "mixed", - "settings": _inbound_proxy_settings(settings), - "tag": tag, - }, - settings, - ) - - -def _vmess_inbound(port: int, listen: str, settings: XrayConfigSettings) -> dict[str, Any]: - if port <= 0: - return {"port": 0} - client_id = str(uuid.uuid5(uuid.NAMESPACE_URL, "pyxray-vmess-inbound")) - return _with_sniffing( - {"port": port, "listen": listen, "protocol": "vmess", "settings": {"clients": [{"id": client_id}]}, "tag": "vmess"}, - settings, - ) - - -def _inbound_proxy_settings(settings: XrayConfigSettings) -> dict[str, Any]: - proxy_settings: dict[str, Any] = {"auth": "noauth", "udp": True, "allowTransparent": False} - if settings.inbounds.auth_user and settings.inbounds.auth_password: - proxy_settings["auth"] = "password" - proxy_settings["accounts"] = [{"user": settings.inbounds.auth_user, "pass": settings.inbounds.auth_password}] - return proxy_settings - - -def _transparent_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: - if settings.transparent.mode == "close": - return [] - if settings.transparent.type in {"redirect", "tproxy"}: - tproxy = settings.transparent.type - listen = "0.0.0.0" if settings.transparent.docker_transparent and settings.transparent.type == "redirect" else "127.0.0.1" - return [ - _with_sniffing( - { - "listen": listen, - "port": settings.transparent.port, - "protocol": "dokodemo-door", - "settings": {"network": "tcp,udp", "followRedirect": True}, - "streamSettings": {"sockopt": {"tproxy": tproxy}}, - "tag": "transparent", - }, - settings, - ) - ] - if settings.transparent.type == "system_proxy": - return [ - {"port": settings.transparent.port, "protocol": "http", "listen": "127.0.0.1", "tag": "transparent"}, - { - "port": settings.transparent.socks_port, - "protocol": "socks", - "listen": "127.0.0.1", - "settings": {"udp": True}, - "tag": "transparent-socks", - }, - ] - return [{"port": settings.transparent.port, "protocol": "socks", "listen": "127.0.0.1", "tag": "transparent"}] - - -def _with_sniffing(inbound: dict[str, Any], settings: XrayConfigSettings) -> dict[str, Any]: - if settings.inbounds.inbound_sniffing == "disable": - return inbound - dest_override = settings.inbounds.inbound_sniffing.split(",") - if _fakedns_enabled(settings) and "fakedns" not in dest_override: - dest_override.append("fakedns") - inbound["sniffing"] = { - "enabled": True, - "destOverride": dest_override, - "domainsExcluded": _split_lines(settings.inbounds.domains_excluded), - "routeOnly": settings.inbounds.route_only, - } - return inbound - - -def _should_local_dns_listen(settings: XrayConfigSettings) -> bool: - # v2rayA 只在 redirect 透明代理开启时监听本机 53 端口。 - # 其它模式仍可使用 Xray DNS 模块,但不额外暴露本地 DNS 入口。 - return ( - settings.dns.local_dns_listen - and settings.transparent.mode != "close" - and settings.transparent.type == "redirect" - ) - - -def _dns_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]: - """生成本地 DNS 入站。 - - v2rayA 对本机 DNS 劫持使用固定地址 `127.2.0.17:53`。 - 当开启局域网共享时,额外保留 `0.0.0.0:53` 给局域网设备使用。 - """ - - inbound = { - "port": 53, - "protocol": "dokodemo-door", - "listen": "127.2.0.17", - "settings": {"network": "udp", "address": "2.0.1.7", "port": 53}, - "tag": "dns-in", - } - if not settings.inbounds.port_sharing: - return [inbound] - lan_inbound = dict(inbound) - lan_inbound["listen"] = "0.0.0.0" - local_inbound = dict(inbound) - local_inbound["tag"] = "dns-in-local" - return [lan_inbound, local_inbound] - - -def _dns_inbound_tags(settings: XrayConfigSettings) -> list[str]: - if _should_local_dns_listen(settings) and settings.inbounds.port_sharing: - return ["dns-in", "dns-in-local"] - return ["dns-in"] - - -def _build_dns(settings: XrayConfigSettings, node: Node) -> dict[str, Any]: - servers: list[Any] = [] - fakedns_domains = _fakedns_domains(settings) - if fakedns_domains: - servers.append({"address": "fakedns", "domains": fakedns_domains}) - routing_domains = _domains_to_lookup(settings, node) - for rule in settings.dns.rules: - domains = _split_lines(rule.domains) - parsed = _dns_server(rule, domains) - if domains: - servers.append(parsed) - else: - servers.insert(0, parsed) - if routing_domains: - servers.append({"address": "8.8.8.8", "domains": routing_domains}) - servers.append({"address": "119.29.29.29", "domains": routing_domains}) - dns: dict[str, Any] = { - "tag": "dns", - "hosts": settings.dns.hosts, - "servers": servers or ["localhost"], - } - if settings.dns.query_strategy: - dns["queryStrategy"] = settings.dns.query_strategy - if settings.dns.disable_fallback: - dns["disableFallback"] = True - return dns - - -def _fakedns_enabled(settings: XrayConfigSettings) -> bool: - return settings.dns.special_mode == "fakedns" - - -def _fakedns_domains(settings: XrayConfigSettings) -> list[str]: - if not _fakedns_enabled(settings): - return [] - return _split_lines(settings.dns.fakedns_domains) or ["geosite:geolocation-!cn"] - - -def _dns_server(rule: DnsRuleSettings, domains: list[str]) -> Any: - address, port = _parse_dns_addr(rule.server) - server_address = rule.server if "://" in rule.server else address - if not domains and port in (0, 53): - return server_address - server: dict[str, Any] = {"address": server_address} - if port not in (0, 53): - server["port"] = port - if domains: - server["domains"] = domains - return server - - -def _build_dns_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: - rules: list[dict[str, Any]] = [] - for rule in settings.dns.rules: - if rule.server == "localhost": - continue - host, port = _parse_dns_addr(rule.server) - routing: dict[str, Any] = {"type": "field", "outboundTag": rule.outbound, "port": str(port or 53)} - if _is_ip(host): - routing["ip"] = [host] - else: - routing["domain"] = [host] - rules.append(routing) - return rules - - -def _build_rule_port_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: - tags = ["rule-mixed"] if settings.inbounds.rule_http_port > 0 else [] - if not tags: - return [] - mode = settings.routing.mode - return _routing_rules_by_mode( - mode, - tags, - default=settings.routing.default_rule, - custom_rules=settings.routing.custom_rules, - routing_a=settings.routing.routing_a, - ) - - -def _build_transparent_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: - if settings.transparent.mode == "close": - return [] - tags = ["transparent"] - if settings.transparent.type == "system_proxy": - tags.append("transparent-socks") - if settings.transparent.mode == "pac": - return _routing_rules_by_mode( - settings.routing.mode, - tags, - default=settings.routing.default_rule, - custom_rules=settings.routing.custom_rules, - routing_a=settings.routing.routing_a, - ) - return _routing_rules_by_mode( - settings.transparent.mode, - tags, - default=settings.routing.default_rule, - custom_rules=settings.routing.custom_rules, - routing_a=settings.routing.routing_a, - ) - - -def _routing_rules_by_mode( - mode: str, - inbounds: list[str], - *, - default: str, - custom_rules: list[Any], - routing_a: str, -) -> list[dict[str, Any]]: - rules = _parse_routing_a(inbounds, routing_a) - if mode == "whitelist": - rules.extend([ - {"type": "field", "inboundTag": inbounds, "domain": ["domain:push-apple.com.akadns.net", "domain:push.apple.com"], "outboundTag": "direct"}, - {"type": "field", "inboundTag": inbounds, "domain": ["geosite:geolocation-!cn"], "outboundTag": "proxy"}, - {"type": "field", "inboundTag": inbounds, "domain": ["geosite:google"], "outboundTag": "proxy"}, - {"type": "field", "inboundTag": inbounds, "domain": ["geosite:cn"], "outboundTag": "direct"}, - {"type": "field", "inboundTag": inbounds, "ip": ["geoip:hk", "geoip:mo"], "outboundTag": "proxy"}, - {"type": "field", "inboundTag": inbounds, "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"}, - ]) - rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) - return rules - if mode == "gfwlist": - rules.extend([ - {"type": "field", "inboundTag": inbounds, "domain": ["geosite:geolocation-!cn"], "outboundTag": "proxy"}, - { - "type": "field", - "inboundTag": inbounds, - "ip": [ - "91.105.192.0/23", - "91.108.4.0/22", - "91.108.8.0/21", - "91.108.16.0/21", - "91.108.56.0/22", - "95.161.64.0/20", - "149.154.160.0/20", - "185.76.151.0/24", - "2001:67c:4e8::/48", - "2001:b28:f23c::/47", - "2001:b28:f23f::/48", - "2a0a:f280:203::/48", - ], - "outboundTag": "proxy", - }, - {"type": "field", "inboundTag": inbounds, "outboundTag": "direct"}, - ]) - return rules - if mode == "custom": - for item in custom_rules: - target = {"direct": "direct", "proxy": "proxy", "block": "block"}[item.rule_type] - values = [f"ext:{item.filename}:{tag}" if item.filename else tag for tag in item.tags] - rule: dict[str, Any] = {"type": "field", "inboundTag": inbounds, "outboundTag": target} - rule["domain" if item.match_type == "domain" else "ip"] = values - rules.append(rule) - rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) - return rules - if mode == "routingA": - rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) - return rules - rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": mode if mode in {"proxy", "direct", "block"} else default}) - return rules - - -def _parse_routing_a(inbounds: list[str], text: str = "") -> list[dict[str, Any]]: - rules: list[dict[str, Any]] = [] - for raw in text.splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "->" not in line or "(" not in line or ")" not in line: - continue - matcher, outbound = [item.strip() for item in line.split("->", 1)] - name, _, rest = matcher.partition("(") - values = [item.strip() for item in rest.rsplit(")", 1)[0].split(",") if item.strip()] - if name not in {"domain", "ip"} or not values: - continue - rule: dict[str, Any] = {"type": "field", "inboundTag": inbounds, "outboundTag": outbound} - rule["domain" if name == "domain" else "ip"] = values - rules.append(rule) - return rules - - -def _default_routing_a() -> str: - return """default: proxy -domain(domain:mail.qq.com)->direct -domain(geosite:google-scholar)->proxy -domain(geosite:category-scholar-!cn, geosite:category-scholar-cn)->direct -domain(geosite:geolocation-!cn, geosite:google)->proxy -domain(geosite:cn)->direct -ip(geoip:hk,geoip:mo)->proxy -ip(geoip:private, geoip:cn)->direct""" - - -def _build_selected_node_whitelist(node: Node) -> list[dict[str, Any]]: - key = "ip" if _is_ip(node.server) else "domain" - return [{"type": "field", key: [node.server], "port": str(node.port), "outboundTag": "direct"}] - - -def _build_api(settings: XrayConfigSettings) -> dict[str, Any] | None: - port = settings.inbounds.api.port - if port <= 0: - return None - services = list(dict.fromkeys([*settings.inbounds.api.services, "LoggerService"])) - return { - "api": {"tag": "api-out", "services": services}, - "inbound": { - "port": port, - "protocol": "dokodemo-door", - "listen": "127.0.0.1", - "settings": {"address": "127.0.0.1"}, - "tag": "api-in", - }, - "routing": {"type": "field", "inboundTag": ["api-in"], "outboundTag": "api-out"}, - } - - -def _domains_to_lookup(settings: XrayConfigSettings, node: Node) -> list[str]: - domains = [] if _is_ip(node.server) else [node.server] - for rule in settings.dns.rules: - host, _ = _parse_dns_addr(rule.server) - if host not in {"", "localhost"} and not _is_ip(host): - domains.append(host) - return list(dict.fromkeys(domains)) - - -def _parse_dns_addr(value: str) -> tuple[str, int]: - if "://" in value: - host = value.split("://", 1)[1].split("/", 1)[0] - else: - host = value - if host.startswith("[") and "]" in host: - raw_host, _, raw_port = host[1:].partition("]:") - return raw_host, int(raw_port or 53) - if host.count(":") == 1: - raw_host, raw_port = host.rsplit(":", 1) - if raw_port.isdigit(): - return raw_host, int(raw_port) - return host, 53 - - -def _split_lines(value: str) -> list[str]: - return [line.strip() for line in value.splitlines() if line.strip()] - - -def _is_ip(value: str) -> bool: - try: - ipaddress.ip_address(value) - return True - except ValueError: - return False diff --git a/pyxray/libs/xray_config/geoip_dat.py b/pyxray/libs/xray_config/geoip_dat.py deleted file mode 100644 index 1ad5a23..0000000 --- a/pyxray/libs/xray_config/geoip_dat.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import ipaddress -from pathlib import Path - - -def parse_geoip_country(path: str | Path, country_code: str) -> list[str]: - """Read Xray geoip.dat and return CIDRs for a country code. - - The file is a small protobuf schema used by v2rayA: - GeoIPList.entry(1) -> GeoIP.country_code(1), GeoIP.cidr(2) -> CIDR.ip(1), CIDR.prefix(2). - A minimal reader is enough here and avoids adding a protobuf runtime dependency. - """ - - data = Path(path).read_bytes() - for _, value in _fields(data): - country = "" - cidrs: list[str] = [] - for field, geo_value in _fields(value): - if field == 1: - country = geo_value.decode("ascii", errors="ignore").lower() - elif field == 2: - parsed = _parse_cidr(geo_value) - if parsed is not None: - cidrs.append(parsed) - if country == country_code.lower(): - return cidrs - return [] - - -def _parse_cidr(data: bytes) -> str | None: - raw_ip = b"" - prefix = 0 - for field, value in _fields(data): - if field == 1: - raw_ip = value - elif field == 2: - prefix = int(value) - if len(raw_ip) not in {4, 16}: - return None - network = ipaddress.ip_network((ipaddress.ip_address(raw_ip), prefix), strict=False) - if network.version == 4 and str(network) == "198.18.0.0/15": - return None - return str(network) - - -def _fields(data: bytes): - offset = 0 - while offset < len(data): - key, offset = _read_varint(data, offset) - field = key >> 3 - wire = key & 0x07 - if wire == 0: - value, offset = _read_varint(data, offset) - yield field, value - elif wire == 2: - length, offset = _read_varint(data, offset) - yield field, data[offset : offset + length] - offset += length - else: - raise ValueError(f"unsupported protobuf wire type: {wire}") - - -def _read_varint(data: bytes, offset: int) -> tuple[int, int]: - shift = 0 - value = 0 - while True: - byte = data[offset] - offset += 1 - value |= (byte & 0x7F) << shift - if not byte & 0x80: - return value, offset - shift += 7 diff --git a/pyxray/libs/xray_config/outbound.py b/pyxray/libs/xray_config/outbound.py deleted file mode 100644 index e80cf5e..0000000 --- a/pyxray/libs/xray_config/outbound.py +++ /dev/null @@ -1,225 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pyxray.libs.nodes.model import Node -from pyxray.libs.xray_config.settings import XrayConfigSettings - - -def node_to_outbound(node: Node, settings: XrayConfigSettings, tag: str = "proxy") -> dict[str, Any]: - """把标准节点转换为 Xray outbound。""" - builders = { - "vless": _vless_outbound, - "vmess": _vmess_outbound, - "trojan": _trojan_outbound, - "trojan-go": _trojan_outbound, - "shadowsocks": _shadowsocks_outbound, - } - try: - outbound = builders[node.protocol](node, tag) - except KeyError as exc: - raise ValueError(f"unsupported outbound protocol: {node.protocol}") from exc - _apply_mux(outbound, settings) - _apply_tcp_fast_open(outbound, settings) - _apply_transparent_mark(outbound, settings) - return outbound - - -def direct_outbound(settings: XrayConfigSettings | None = None) -> dict[str, Any]: - """生成 v2rayA 使用的 direct outbound。""" - outbound: dict[str, Any] = {"tag": "direct", "protocol": "freedom", "settings": {"domainStrategy": "UseIP"}} - if settings is not None: - _apply_tcp_fast_open(outbound, settings) - _apply_transparent_mark(outbound, settings) - return outbound - - -def block_outbound() -> dict[str, Any]: - """生成 v2rayA 使用的 block outbound。""" - return {"tag": "block", "protocol": "blackhole"} - - -def dns_outbound() -> dict[str, Any]: - """生成 DNS outbound。""" - return {"tag": "dns-out", "protocol": "dns"} - - -def _vless_outbound(node: Node, tag: str) -> dict[str, Any]: - user = { - "id": node.settings["uuid"], - "encryption": node.settings.get("encryption", "none"), - "flow": node.settings.get("flow", ""), - } - outbound = { - "tag": tag, - "protocol": "vless", - "settings": {"vnext": [{"address": node.server, "port": node.port, "users": [_clean(user)]}]}, - } - _set_stream_settings(outbound, node) - return outbound - - -def _vmess_outbound(node: Node, tag: str) -> dict[str, Any]: - user = { - "id": node.settings["uuid"], - "alterId": int(node.settings.get("alter_id", 0)), - "security": node.settings.get("security", "auto"), - } - outbound = { - "tag": tag, - "protocol": "vmess", - "settings": {"vnext": [{"address": node.server, "port": node.port, "users": [_clean(user)]}]}, - } - _set_stream_settings(outbound, node) - return outbound - - -def _trojan_outbound(node: Node, tag: str) -> dict[str, Any]: - outbound = { - "tag": tag, - "protocol": "trojan", - "settings": {"servers": [{"address": node.server, "port": node.port, "password": node.settings["password"]}]}, - } - _set_stream_settings(outbound, node) - return outbound - - -def _shadowsocks_outbound(node: Node, tag: str) -> dict[str, Any]: - server = { - "address": node.server, - "port": node.port, - "method": node.settings["method"], - "password": node.settings["password"], - } - return {"tag": tag, "protocol": "shadowsocks", "settings": {"servers": [server]}} - - -def _set_stream_settings(outbound: dict[str, Any], node: Node) -> None: - stream = _stream_settings(node) - if stream: - outbound["streamSettings"] = stream - - -def _stream_settings(node: Node) -> dict[str, Any]: - network = node.transport.get("network", "tcp") - security_type = node.security.get("type", "none") - stream: dict[str, Any] = {"network": network} - if security_type not in {"", "none"}: - stream["security"] = "tls" if security_type == "xtls" else security_type - if security_type in {"tls", "xtls"}: - stream["tlsSettings"] = _tls_settings(node) - elif security_type == "reality": - stream["realitySettings"] = _reality_settings(node) - if network == "tcp": - stream["tcpSettings"] = {"header": {"type": node.transport.get("header_type", "none")}} - elif network == "ws": - stream["wsSettings"] = _clean( - { - "path": node.transport.get("path", ""), - "headers": {"Host": node.transport.get("host", "")}, - "maxEarlyData": _int_or_none(node.transport.get("max_early_data")), - "earlyDataHeaderName": node.transport.get("early_data_header_name"), - } - ) - elif network == "http": - stream["httpSettings"] = _clean({"path": node.transport.get("path", ""), "host": _split_csv(node.transport.get("host"))}) - elif network == "grpc": - stream["grpcSettings"] = _clean( - { - "serviceName": node.transport.get("service_name", ""), - "multiMode": _bool(node.transport.get("multi_mode")), - "idle_timeout": _int_or_none(node.transport.get("idle_timeout")), - "health_check_timeout": _int_or_none(node.transport.get("health_check_timeout")), - "permit_without_stream": _bool(node.transport.get("permit_without_stream")), - "initial_windows_size": _int_or_none(node.transport.get("initial_windows_size")), - } - ) - elif network == "mkcp": - stream["kcpSettings"] = _clean({"header": {"type": node.transport.get("header_type", "none")}, "seed": node.transport.get("seed")}) - elif network == "quic": - stream["quicSettings"] = _clean( - { - "header": {"type": node.transport.get("header_type", "none")}, - "security": node.transport.get("quic_security", "none"), - "key": node.transport.get("key"), - } - ) - elif network == "xhttp": - stream["xhttpSettings"] = _clean( - {"path": node.transport.get("path", ""), "host": node.transport.get("host"), "mode": node.transport.get("xhttp_mode", "auto")} - ) - return _clean(stream) - - -def _tls_settings(node: Node) -> dict[str, Any]: - return _clean( - { - "allowInsecure": bool(node.security.get("allow_insecure", False)), - "serverName": node.security.get("server_name"), - "alpn": _split_csv(node.security.get("alpn")), - "fingerprint": node.security.get("fingerprint"), - } - ) - - -def _reality_settings(node: Node) -> dict[str, Any]: - return _clean( - { - "serverName": node.security.get("server_name"), - "fingerprint": node.security.get("fingerprint"), - "publicKey": node.security.get("public_key"), - "shortId": node.security.get("short_id"), - "spiderX": node.security.get("spider_x"), - } - ) - - -def _apply_mux(outbound: dict[str, Any], settings: XrayConfigSettings) -> None: - if settings.core.mux_enabled: - outbound["mux"] = {"enabled": True, "concurrency": settings.core.mux_concurrency} - - -def _apply_tcp_fast_open(outbound: dict[str, Any], settings: XrayConfigSettings) -> None: - if settings.core.tcp_fast_open == "default" or outbound["protocol"] in {"blackhole", "dns"}: - return - stream = outbound.setdefault("streamSettings", {}) - sockopt = stream.setdefault("sockopt", {}) - sockopt["tcpFastOpen"] = settings.core.tcp_fast_open == "yes" - - -def _apply_transparent_mark(outbound: dict[str, Any], settings: XrayConfigSettings) -> None: - if settings.transparent.mode == "close" or settings.transparent.type not in {"redirect", "tproxy"}: - return - stream = outbound.setdefault("streamSettings", {}) - sockopt = stream.setdefault("sockopt", {}) - sockopt["mark"] = 128 - - -def _split_csv(value: object) -> list[str]: - if not value: - return [] - if isinstance(value, list): - return [str(item) for item in value if str(item)] - return [item.strip() for item in str(value).split(",") if item.strip()] - - -def _int_or_none(value: object) -> int | None: - if value in (None, ""): - return None - return int(value) - - -def _bool(value: object) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.lower() in {"1", "true", "yes"} - return bool(value) - - -def _clean(value: dict[str, Any]) -> dict[str, Any]: - return { - key: item - for key, item in value.items() - if item is not None and item != "" and item != [] and (not isinstance(item, dict) or item) - } diff --git a/pyxray/libs/xray_config/settings.py b/pyxray/libs/xray_config/settings.py deleted file mode 100644 index 1363771..0000000 --- a/pyxray/libs/xray_config/settings.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -from dataclasses import asdict, dataclass, field, fields, is_dataclass -from typing import Any, get_args, get_origin, get_type_hints - - -@dataclass(slots=True) -class CoreSettings: - """Xray 核心运行参数。""" - - log_level: str = "info" - tcp_fast_open: str = "default" - mux_enabled: bool = False - mux_concurrency: int = 8 - ss_backend: str = "" - trojan_backend: str = "" - - -@dataclass(slots=True) -class ApiSettings: - """Xray API 入站设置。""" - - port: int = 0 - services: list[str] = field(default_factory=lambda: ["LoggerService"]) - - -@dataclass(slots=True) -class CustomInboundSettings: - """用户自定义 socks/http 入站。""" - - tag: str - protocol: str - port: int - - -@dataclass(slots=True) -class InboundSettings: - """本地代理入站设置。""" - - listen: str = "127.0.0.1" - port_sharing: bool = False - socks_port: int = 20170 - http_port: int = 20171 - rule_socks_port: int = 0 - rule_http_port: int = 20172 - auth_user: str = "" - auth_password: str = "" - vmess_port: int = 0 - inbound_sniffing: str = "http,tls,quic" - route_only: bool = False - domains_excluded: str = "" - api: ApiSettings = field(default_factory=ApiSettings) - custom: list[CustomInboundSettings] = field(default_factory=list) - - -@dataclass(slots=True) -class CustomRoutingRuleSettings: - """自定义路由规则。""" - - filename: str = "" - tags: list[str] = field(default_factory=list) - match_type: str = "domain" - rule_type: str = "proxy" - - -@dataclass(slots=True) -class RoutingSettings: - """规则端口和全局路由设置。""" - - mode: str = "whitelist" - default_rule: str = "proxy" - custom_rules: list[CustomRoutingRuleSettings] = field(default_factory=list) - routing_a: str = "" - - -@dataclass(slots=True) -class TransparentSettings: - """透明代理设置;部分字段属于后续系统规则控制。""" - - mode: str = "close" - type: str = "redirect" - port: int = 52345 - socks_port: int = 52306 - ipforward: bool = False - docker_transparent: bool = True - docker_transparent_cidrs: str = "172.16.0.0/12" - tproxy_excluded_interfaces: str = "docker*,veth*,wg*,ppp*,br-*" - output_bypass_rules: str = "" - tproxy_white_country_codes: list[str] = field(default_factory=list) - tproxy_white_custom_ips: list[str] = field(default_factory=list) - tun_bypass_interfaces: str = "" - tun_auto_route: bool = True - tun_route_shell_type: str = "" - tun_route_shell_path: str = "" - tun_setup_script: str = "" - tun_teardown_script: str = "" - tun_process_backend: str = "" - tun_exclude_processes: str = "" - - -@dataclass(slots=True) -class DnsRuleSettings: - """DNS 服务器规则。""" - - server: str - domains: str = "" - outbound: str = "direct" - - -@dataclass(slots=True) -class DnsSettings: - """Xray DNS 模块设置。""" - - query_strategy: str = "UseIPv4" - disable_fallback: bool = False - local_dns_listen: bool = True - hosts: dict[str, list[str]] = field( - default_factory=lambda: {"courier.push.apple.com": ["1-courier.push.apple.com"]} - ) - rules: list[DnsRuleSettings] = field( - default_factory=lambda: [ - DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"), - DnsRuleSettings(server="223.5.5.5", domains="geosite:cn", outbound="direct"), - DnsRuleSettings(server="8.8.8.8", domains="", outbound="proxy"), - ] - ) - antipollution: str = "closed" - special_mode: str = "none" - fakedns_domains: str = "geosite:geolocation-!cn" - - -@dataclass(slots=True) -class OutboundSetting: - """出站组观测设置。""" - - tag: str = "proxy" - probe_url: str = "https://www.gstatic.com/generate_204" - probe_interval: str = "60s" - type: str = "leastping" - - -@dataclass(slots=True) -class AutoUpdateSettings: - """更新策略;不直接进入 Xray JSON。""" - - gfwlist_auto_update_mode: str = "none" - gfwlist_auto_update_interval_hour: int = 0 - subscription_auto_update_mode: str = "none" - subscription_auto_update_interval_hour: int = 0 - proxy_mode_when_subscribe: str = "direct" - - -@dataclass(slots=True) -class XrayConfigSettings: - """生成 Xray 配置所需的完整设置。""" - - core: CoreSettings = field(default_factory=CoreSettings) - inbounds: InboundSettings = field(default_factory=InboundSettings) - routing: RoutingSettings = field(default_factory=RoutingSettings) - transparent: TransparentSettings = field(default_factory=TransparentSettings) - dns: DnsSettings = field(default_factory=DnsSettings) - outbounds: list[OutboundSetting] = field(default_factory=lambda: [OutboundSetting()]) - auto_update: AutoUpdateSettings = field(default_factory=AutoUpdateSettings) - - def to_dict(self) -> dict[str, Any]: - """转换成可写入 TOML 的普通字典。""" - return asdict(self) - - @classmethod - def from_dict(cls, values: dict[str, Any]) -> XrayConfigSettings: - """从 TOML 字典恢复设置,并补齐新增默认字段。""" - return _dataclass_from_dict(cls, values) - - -def _dataclass_from_dict(cls: type, values: dict[str, Any]): - kwargs: dict[str, Any] = {} - hints = get_type_hints(cls) - for item in fields(cls): - raw = values.get(item.name) - if raw is None: - continue - kwargs[item.name] = _coerce_value(hints[item.name], raw) - return cls(**kwargs) - - -def _coerce_value(annotation: Any, value: Any) -> Any: - origin = get_origin(annotation) - if origin is list: - inner = get_args(annotation)[0] - return [_coerce_value(inner, item) for item in value] - if origin is dict: - key_type, value_type = get_args(annotation) - return {_coerce_value(key_type, key): _coerce_value(value_type, item) for key, item in dict(value).items()} - if isinstance(annotation, type) and is_dataclass(annotation): - return _dataclass_from_dict(annotation, dict(value)) - return value - - -def validate_settings(settings: XrayConfigSettings) -> None: - """校验设置值,提前暴露配置错误。""" - if settings.core.log_level in {"trace", "warn"}: - settings.core.log_level = {"trace": "debug", "warn": "warning"}[settings.core.log_level] - _validate_choice(settings.core.log_level, {"debug", "info", "warning", "error", "none"}, "core.log_level") - _validate_choice(settings.core.tcp_fast_open, {"default", "yes", "no"}, "core.tcp_fast_open") - if not 1 <= settings.core.mux_concurrency <= 1024: - raise ValueError("core.mux_concurrency must be between 1 and 1024") - _validate_choice(settings.inbounds.inbound_sniffing, {"disable", "http,tls", "http,tls,quic"}, "inbounds.inbound_sniffing") - if bool(settings.inbounds.auth_user) != bool(settings.inbounds.auth_password): - raise ValueError("inbounds.auth_user and inbounds.auth_password must be set together") - _validate_choice(settings.routing.mode, {"whitelist", "gfwlist", "custom", "routingA", "proxy", "direct", "block"}, "routing.mode") - _validate_choice(settings.routing.default_rule, {"direct", "proxy", "block"}, "routing.default_rule") - _validate_choice(settings.transparent.mode, {"close", "proxy", "whitelist", "gfwlist", "pac"}, "transparent.mode") - _validate_choice(settings.transparent.type, {"redirect", "tproxy", "system_proxy", "tun"}, "transparent.type") - _validate_choice(settings.dns.antipollution, {"closed", "none", "dnsforward", "doh", "advanced"}, "dns.antipollution") - _validate_choice(settings.dns.special_mode, {"none", "supervisor", "fakedns"}, "dns.special_mode") - for inbound in settings.inbounds.custom: - _validate_choice(inbound.protocol, {"socks", "http"}, f"custom inbound {inbound.tag}.protocol") - if not inbound.tag: - raise ValueError("custom inbound tag must not be empty") - _validate_port(inbound.port, f"custom inbound {inbound.tag}.port", allow_zero=False) - for name, port in { - "inbounds.socks_port": settings.inbounds.socks_port, - "inbounds.http_port": settings.inbounds.http_port, - "inbounds.rule_socks_port": settings.inbounds.rule_socks_port, - "inbounds.rule_http_port": settings.inbounds.rule_http_port, - "inbounds.vmess_port": settings.inbounds.vmess_port, - "inbounds.api.port": settings.inbounds.api.port, - "transparent.port": settings.transparent.port, - "transparent.socks_port": settings.transparent.socks_port, - }.items(): - _validate_port(port, name) - - -def _validate_choice(value: str, choices: set[str], name: str) -> None: - if value not in choices: - raise ValueError(f"{name} must be one of: {', '.join(sorted(choices))}") - - -def _validate_port(value: int, name: str, *, allow_zero: bool = True) -> None: - minimum = 0 if allow_zero else 1 - if not minimum <= value <= 65535: - raise ValueError(f"{name} must be between {minimum} and 65535") diff --git a/pyxray/libs/xray_config/store.py b/pyxray/libs/xray_config/store.py deleted file mode 100644 index f3774b3..0000000 --- a/pyxray/libs/xray_config/store.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from pathlib import Path -from typing import Any - -import tomlkit - -from pyxray.libs.xray_config.settings import XrayConfigSettings, validate_settings - - -class XrayConfigSettingsStore: - """负责 settings.toml 的读写。""" - - def __init__(self, path: str | Path) -> None: - self.path = Path(path) - - def load(self) -> XrayConfigSettings: - """读取设置;文件不存在时返回默认值。""" - if not self.path.exists(): - return XrayConfigSettings() - raw = tomlkit.parse(self.path.read_text(encoding="utf-8")) - settings = XrayConfigSettings.from_dict(dict(raw)) - validate_settings(settings) - return settings - - def save(self, settings: XrayConfigSettings) -> None: - """原子写入设置。""" - validate_settings(settings) - self.path.parent.mkdir(parents=True, exist_ok=True) - content = dump_settings_toml(settings) - with tempfile.NamedTemporaryFile( - "w", - encoding="utf-8", - dir=self.path.parent, - prefix=f".{self.path.name}.", - suffix=".tmp", - delete=False, - ) as temp: - temp.write(content) - temp_path = Path(temp.name) - os.replace(temp_path, self.path) - - -def dump_settings_toml(settings: XrayConfigSettings) -> str: - """输出稳定的 settings.toml。""" - document = tomlkit.document() - values = settings.to_dict() - for key in ("core", "inbounds", "routing", "transparent", "dns", "auto_update"): - document[key] = _toml_value(values[key]) - document["outbounds"] = _toml_value(values["outbounds"]) - return tomlkit.dumps(document) - - -def _toml_value(value: Any): - if isinstance(value, list): - if not value or not isinstance(value[0], dict): - return value - array = tomlkit.aot() - for item in value: - array.append(_toml_table(item)) - return array - if isinstance(value, dict): - return _toml_table(value) - return value - - -def _toml_table(values: dict[str, Any]): - table = tomlkit.table() - for key, value in values.items(): - table[key] = _toml_value(value) - return table diff --git a/pyxray/libs/xray_config/tinytun_config.py b/pyxray/libs/xray_config/tinytun_config.py deleted file mode 100644 index 6b198a3..0000000 --- a/pyxray/libs/xray_config/tinytun_config.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import ipaddress -from pathlib import Path -from typing import Any - -from pyxray.libs.nodes.model import Node -from pyxray.libs.xray_config.settings import DnsRuleSettings, XrayConfigSettings - - -TUN_IPV4 = "198.18.0.1" -TUN_NETMASK = "255.255.255.255" -TUN_IPV6 = "fd00::1" -TUN_IPV6_PREFIX = 128 -TUN_SOCKS_PORT = 52345 - -DEFAULT_SKIP_NETWORKS = [ - "127.0.0.0/8", - "169.254.0.0/16", - "::1/128", - "fc00::/7", - "fe80::/10", -] - - -def generate_tinytun_config( - node: Node, - settings: XrayConfigSettings | None = None, - *, - geosite_file: str = "geosite.dat", -) -> dict[str, Any]: - """生成 TinyTun YAML 对应的普通字典。 - - 结构交叉参考 `.v2rayA/service/core/v2ray/tinytun_enabled.go`。 - TinyTun 负责 TUN 数据面和 DNS 路由;Xray 只需要提供本地 SOCKS5 入口。 - """ - - settings = settings or XrayConfigSettings() - return { - "log": {"loglevel": settings.core.log_level, "hide_timestamp": False}, - "tun": { - "name": "tun0", - "ip": TUN_IPV4, - "netmask": TUN_NETMASK, - "ipv6_mode": "auto", - "ipv6": TUN_IPV6, - "ipv6_prefix": TUN_IPV6_PREFIX, - "auto_route": settings.transparent.tun_auto_route, - "mtu": 1500, - }, - "socks5": {"name": "proxy", "address": f"127.0.0.1:{TUN_SOCKS_PORT}"}, - "dns": _tinytun_dns(settings, geosite_file=geosite_file), - "filtering": { - "skip_ips": _skip_ips(node), - "skip_networks": _skip_networks(settings), - "block_ports": [22, 23, 25, 110, 143], - "allow_ports": [80, 443, 53], - "exclude_processes": _exclude_processes(settings), - }, - "route": {"auto_detect_interface": True}, - } - - -def write_tinytun_config_file( - node: Node, - settings: XrayConfigSettings, - output_dir: str | Path, - *, - geosite_file: str = "geosite.dat", -) -> Path | None: - """当透明代理类型为 TUN 时写出 `tinytun.yaml`。""" - - output = Path(output_dir) - output.mkdir(parents=True, exist_ok=True) - path = output / "tinytun.yaml" - if settings.transparent.mode == "close" or settings.transparent.type != "tun": - if path.exists(): - path.unlink() - return None - path.write_text(_dump_yaml(generate_tinytun_config(node, settings, geosite_file=geosite_file)), encoding="utf-8") - path.chmod(0o600) - return path - - -def _tinytun_dns(settings: XrayConfigSettings, *, geosite_file: str) -> dict[str, Any]: - direct_servers: list[str] = [] - proxy_servers: list[str] = [] - routing_rules: list[str] = [] - fallback_group = "proxy" - - for rule in settings.dns.rules: - upstream = _dns_upstream(rule) - server = _normalize_dns_server(rule.server) - if upstream == "direct" and server: - direct_servers.append(server) - elif upstream == "proxy" and server: - proxy_servers.append(server) - - domains = [line.strip() for line in rule.domains.splitlines() if line.strip()] - action = "reject" if upstream == "block" else upstream - for domain in domains: - converted = _domain_pattern_to_tinytun_rule(domain, geosite_file, action) - if converted: - routing_rules.append(converted) - if not domains: - if upstream in {"direct", "proxy"}: - fallback_group = upstream - - if not direct_servers: - direct_servers = ["223.5.5.5:53", "114.114.114.114:53"] - if not proxy_servers: - proxy_servers = ["8.8.8.8:53", "1.1.1.1:53"] - - return { - "groups": [ - {"name": "direct", "servers": _dedupe(direct_servers), "strategy": "concurrent", "upstream": "direct", "protocol": "udp"}, - {"name": "proxy", "servers": _dedupe(proxy_servers), "strategy": "concurrent", "upstream": "proxy", "protocol": "udp"}, - ], - "listen_port": 53, - "timeout_ms": 5000, - "hijack": {"enabled": False, "mark": 1, "table_id": 100, "capture_tcp": True}, - "routing": { - "rules": _dedupe(routing_rules), - "fallback_group": fallback_group, - "geosite_file": geosite_file, - "enable_cache": True, - "cache_capacity": 4096, - }, - } - - -def _dns_upstream(rule: DnsRuleSettings) -> str: - if rule.outbound == "direct": - return "direct" - if rule.outbound == "block": - return "block" - return "proxy" - - -def _normalize_dns_server(server: str) -> str: - if server in {"", "localhost", "fakedns"} or "://" in server: - return "" - if server.startswith("[") and "]" in server: - host, _, raw_port = server[1:].partition("]:") - return f"{host}:{raw_port or '53'}" if _is_ip(host) else "" - if server.count(":") == 1: - host, port = server.rsplit(":", 1) - return f"{host}:{port}" if _is_ip(host) and port.isdigit() else "" - return f"{server}:53" if _is_ip(server) else "" - - -def _domain_pattern_to_tinytun_rule(pattern: str, geosite_file: str, action: str) -> str: - if pattern.startswith("geosite:"): - condition = pattern if geosite_file else "" - elif pattern.startswith("full:"): - condition = "domain:" + pattern.removeprefix("full:") - elif pattern.startswith("domain:"): - condition = "suffix:" + pattern.removeprefix("domain:") - elif pattern.startswith("suffix:"): - condition = "suffix:" + pattern.removeprefix("suffix:") - elif pattern.startswith("keyword:"): - condition = "keyword:" + pattern.removeprefix("keyword:") - elif pattern.startswith("regexp:"): - condition = "regex:" + pattern.removeprefix("regexp:") - elif _is_domain_like(pattern): - condition = "suffix:" + pattern - else: - condition = "" - return f"match({condition}),{action}" if condition else "" - - -def _skip_ips(node: Node) -> list[str]: - values = ["127.0.0.1", "::1", TUN_IPV4] - if _is_ip(node.server): - values.append(node.server) - return _dedupe(values) - - -def _skip_networks(settings: XrayConfigSettings) -> list[str]: - configured = [item.strip() for item in settings.transparent.tun_bypass_interfaces.replace("\n", ",").split(",") if item.strip()] - return _dedupe([*DEFAULT_SKIP_NETWORKS, *configured]) - - -def _exclude_processes(settings: XrayConfigSettings) -> list[str]: - raw = settings.transparent.tun_exclude_processes - values = [item.strip() for item in raw.replace(";", ",").replace("\n", ",").split(",") if item.strip()] - return _dedupe([Path(item).name for item in values if Path(item).name]) - - -def _dump_yaml(value: Any, indent: int = 0) -> str: - lines = _yaml_lines(value, indent) - return "\n".join(lines) + "\n" - - -def _yaml_lines(value: Any, indent: int = 0) -> list[str]: - pad = " " * indent - if isinstance(value, dict): - lines: list[str] = [] - for key, item in value.items(): - if item is None or item == []: - continue - if isinstance(item, dict): - lines.append(f"{pad}{key}:") - lines.extend(_yaml_lines(item, indent + 2)) - elif isinstance(item, list): - lines.append(f"{pad}{key}:") - lines.extend(_yaml_lines(item, indent + 2)) - else: - lines.append(f"{pad}{key}: {_yaml_scalar(item)}") - return lines - if isinstance(value, list): - lines = [] - for item in value: - if isinstance(item, dict): - lines.append(f"{pad}-") - lines.extend(_yaml_lines(item, indent + 2)) - else: - lines.append(f"{pad}- {_yaml_scalar(item)}") - return lines - return [f"{pad}{_yaml_scalar(value)}"] - - -def _yaml_scalar(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - text = str(value) - if text == "" or any(char in text for char in [": ", "#", "{", "}", "[", "]", ",", "&", "*", "!", "|", ">", "'", '"']): - return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' - return text - - -def _is_ip(value: str) -> bool: - try: - ipaddress.ip_address(value) - return True - except ValueError: - return False - - -def _is_domain_like(value: str) -> bool: - return bool(value and "/" not in value and not _is_ip(value)) - - -def _dedupe(values: list[str]) -> list[str]: - result: list[str] = [] - seen: set[str] = set() - for value in values: - if value and value not in seen: - seen.add(value) - result.append(value) - return result diff --git a/pyxray/libs/xray_config/transparent_rules.py b/pyxray/libs/xray_config/transparent_rules.py deleted file mode 100644 index c241bb5..0000000 --- a/pyxray/libs/xray_config/transparent_rules.py +++ /dev/null @@ -1,685 +0,0 @@ -from __future__ import annotations - -import ipaddress -from dataclasses import dataclass -from pathlib import Path - -from pyxray.libs.xray_config.geoip_dat import parse_geoip_country -from pyxray.libs.xray_config.settings import XrayConfigSettings - - -@dataclass(slots=True) -class TransparentRuleSet: - """透明代理系统规则脚本。 - - `setup` 用于安装规则,`cleanup` 用于卸载规则。 - `nftables` 在 backend 为 nft 时保存 nft 表内容,调用方可以写入文件后执行 `nft -f`。 - """ - - backend: str - mode: str - setup: str - cleanup: str - nftables: str = "" - - -@dataclass(slots=True) -class TransparentRuleFiles: - """透明代理规则落盘后的文件路径。""" - - ip_forward: Path - resolv_setup: Path - resolv_cleanup: Path - iptables_setup: Path - iptables_cleanup: Path - nft_setup: Path - nft_cleanup: Path - nftables: Path | None - - -IPV4_RESERVED_CIDRS = [ - "0.0.0.0/32", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/24", - "192.0.2.0/24", - "192.88.99.0/24", - "192.168.0.0/16", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4", -] - -IPV6_RESERVED_CIDRS = [ - "::/128", - "::1/128", - "64:ff9b::/96", - "100::/64", - "2001::/32", - "2001:20::/28", - "2001:db8::/32", - "2002::/16", - "fe80::/10", - "ff00::/8", -] - - -def generate_transparent_rules( - settings: XrayConfigSettings | None = None, - *, - backend: str = "iptables", - ipv6: bool = False, - nftables_path: str = "/etc/pyxray/v2raya.nft", - geoip_file: str | Path | None = None, -) -> TransparentRuleSet: - """生成透明代理系统侧规则。 - - 规则结构交叉参考 `.v2rayA/service/core/iptables`: - - redirect 使用 nat 表 TP_OUT / TP_PRE / TP_RULE,TCP REDIRECT 到透明代理端口。 - - tproxy 使用 mangle 表、fwmark `0x40/0xc0`、table `100` 和 TPROXY 目标。 - - system_proxy 不改内核转发规则,只提供桌面系统代理设置入口的占位脚本。 - - 该函数只生成脚本,不执行命令,方便 Web UI 展示、写文件和测试。 - """ - - settings = settings or XrayConfigSettings() - if settings.transparent.mode == "close": - return TransparentRuleSet(backend=backend, mode="close", setup="", cleanup="") - if backend not in {"iptables", "nft"}: - raise ValueError("backend must be one of: iptables, nft") - if settings.transparent.type == "redirect": - return _redirect_rules(settings, backend=backend, ipv6=ipv6, nftables_path=nftables_path) - if settings.transparent.type == "tproxy": - return _tproxy_rules(settings, backend=backend, ipv6=ipv6, nftables_path=nftables_path, geoip_file=geoip_file) - if settings.transparent.type == "system_proxy": - return _system_proxy_rules(settings) - if settings.transparent.type == "tun": - return TransparentRuleSet( - backend=backend, - mode="tun", - setup=settings.transparent.tun_setup_script, - cleanup=settings.transparent.tun_teardown_script, - ) - raise ValueError(f"unsupported transparent.type: {settings.transparent.type}") - - -def write_transparent_rule_files( - settings: XrayConfigSettings, - output_dir: str | Path, - *, - ipv6: bool = False, - geoip_file: str | Path | None = None, -) -> TransparentRuleFiles: - """把透明代理系统规则写成文件。 - - pyxray 当前不自动执行系统命令,因此这里同时输出 iptables 和 nftables 两套脚本。 - 调用方可以根据宿主机环境选择执行哪一套;这也便于用户审计实际会修改哪些系统规则。 - """ - - output = Path(output_dir) - output.mkdir(parents=True, exist_ok=True) - nftables_path = output / "v2raya.nft" - iptables_rules = generate_transparent_rules(settings, backend="iptables", ipv6=ipv6, geoip_file=geoip_file) - nft_rules = generate_transparent_rules(settings, backend="nft", ipv6=ipv6, nftables_path=str(nftables_path), geoip_file=geoip_file) - - iptables_setup = output / "transparent-iptables-setup.sh" - iptables_cleanup = output / "transparent-iptables-cleanup.sh" - nft_setup = output / "transparent-nft-setup.sh" - nft_cleanup = output / "transparent-nft-cleanup.sh" - ip_forward = output / "ip-forward-apply.sh" - resolv_setup = output / "resolv-hijack-setup.sh" - resolv_cleanup = output / "resolv-hijack-cleanup.sh" - - _write_script(iptables_setup, iptables_rules.setup) - _write_script(iptables_cleanup, iptables_rules.cleanup) - _write_script(nft_setup, nft_rules.setup) - _write_script(nft_cleanup, nft_rules.cleanup) - _write_script(ip_forward, _ip_forward_script(settings)) - _write_script(resolv_setup, _resolv_hijack_script(settings)) - _write_script(resolv_cleanup, _resolv_restore_script(settings)) - if nft_rules.nftables: - nftables_path.write_text(nft_rules.nftables + "\n", encoding="utf-8") - resolved_nftables: Path | None = nftables_path - else: - if nftables_path.exists(): - nftables_path.unlink() - resolved_nftables = None - return TransparentRuleFiles( - ip_forward=ip_forward, - resolv_setup=resolv_setup, - resolv_cleanup=resolv_cleanup, - iptables_setup=iptables_setup, - iptables_cleanup=iptables_cleanup, - nft_setup=nft_setup, - nft_cleanup=nft_cleanup, - nftables=resolved_nftables, - ) - - -def _redirect_rules(settings: XrayConfigSettings, *, backend: str, ipv6: bool, nftables_path: str) -> TransparentRuleSet: - if backend == "nft": - table = _redirect_nft_table(settings, ipv6=ipv6) - return TransparentRuleSet( - backend="nft", - mode="redirect", - setup=f"nft -f {nftables_path}", - cleanup="nft delete table inet v2raya", - nftables=table, - ) - - lines = [ - "iptables -w 2 -t nat -N TP_OUT", - "iptables -w 2 -t nat -N TP_PRE", - "iptables -w 2 -t nat -N TP_RULE", - *[f"iptables -w 2 -t nat -A TP_RULE -d {cidr} -j RETURN" for cidr in _ipv4_reserved_cidrs(settings)], - _local_ipv4_return_script("iptables -w 2 -t nat -A TP_RULE"), - "iptables -w 2 -t nat -A TP_RULE -m mark --mark 0x80/0x80 -j RETURN", - *[f"iptables -w 2 -t nat -A TP_RULE -i {_iptables_interface(value)} -j RETURN" for value in _excluded_interfaces(settings)], - f"iptables -w 2 -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports {settings.transparent.port}", - "iptables -w 2 -t nat -I PREROUTING -p tcp -j TP_PRE", - "iptables -w 2 -t nat -I OUTPUT -p tcp -j TP_OUT", - *_iptables_output_bypass_rules(settings, table="nat", mode="redirect"), - *_redirect_prerouting_jumps(settings), - "iptables -w 2 -t nat -A TP_OUT -j TP_RULE", - ] - cleanup = [ - "iptables -w 2 -t nat -F TP_OUT", - "iptables -w 2 -t nat -D OUTPUT -p tcp -j TP_OUT", - "iptables -w 2 -t nat -X TP_OUT", - "iptables -w 2 -t nat -F TP_PRE", - "iptables -w 2 -t nat -D PREROUTING -p tcp -j TP_PRE", - "iptables -w 2 -t nat -X TP_PRE", - "iptables -w 2 -t nat -F TP_RULE", - "iptables -w 2 -t nat -X TP_RULE", - ] - if ipv6: - lines.extend(_redirect_ip6tables_setup(settings)) - cleanup.extend(_redirect_ip6tables_cleanup()) - return TransparentRuleSet(backend="iptables", mode="redirect", setup=_script(lines), cleanup=_best_effort_script(cleanup)) - - -def _tproxy_rules( - settings: XrayConfigSettings, - *, - backend: str, - ipv6: bool, - nftables_path: str, - geoip_file: str | Path | None, -) -> TransparentRuleSet: - tproxy_white_cidrs = _tproxy_white_cidrs(settings, geoip_file) - if backend == "nft": - table = _tproxy_nft_table(settings, ipv6=ipv6, tproxy_white_cidrs=tproxy_white_cidrs) - setup = [ - "ip rule add fwmark 0x40/0xc0 table 100", - "ip route add local 0.0.0.0/0 dev lo table 100", - ] - cleanup = [ - "ip rule del fwmark 0x40/0xc0 table 100", - "ip route del local 0.0.0.0/0 dev lo table 100", - ] - if ipv6: - setup.extend(["ip -6 rule add fwmark 0x40/0xc0 table 100", "ip -6 route add local ::/0 dev lo table 100"]) - cleanup.extend(["ip -6 rule del fwmark 0x40/0xc0 table 100", "ip -6 route del local ::/0 dev lo table 100"]) - setup.append(f"nft -f {nftables_path}") - cleanup.append("nft delete table inet v2raya") - return TransparentRuleSet(backend="nft", mode="tproxy", setup=_script(setup), cleanup=_script(cleanup), nftables=table) - - lines = [ - "ip rule add fwmark 0x40/0xc0 table 100", - "ip route add local 0.0.0.0/0 dev lo table 100", - "iptables -w 2 -t mangle -N TP_OUT", - "iptables -w 2 -t mangle -N TP_PRE", - "iptables -w 2 -t mangle -N TP_RULE", - "iptables -w 2 -t mangle -N TP_MARK", - "iptables -w 2 -t mangle -I OUTPUT -j TP_OUT", - "iptables -w 2 -t mangle -I PREROUTING -j TP_PRE", - "iptables -w 2 -t mangle -A TP_OUT -m mark --mark 0x80/0x80 -j RETURN", - *_iptables_output_bypass_rules(settings, table="mangle", mode="tproxy"), - "iptables -w 2 -t mangle -A TP_OUT -p tcp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TP_RULE", - "iptables -w 2 -t mangle -A TP_OUT -p udp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TP_RULE", - "iptables -w 2 -t mangle -A TP_PRE -i lo -m mark ! --mark 0x40/0xc0 -j RETURN", - *_tproxy_prerouting_jumps(settings), - f"iptables -w 2 -t mangle -A TP_PRE -p tcp -m mark --mark 0x40/0xc0 -j TPROXY --on-port {settings.transparent.port} --on-ip 127.0.0.1", - f"iptables -w 2 -t mangle -A TP_PRE -p udp -m mark --mark 0x40/0xc0 -j TPROXY --on-port {settings.transparent.port} --on-ip 127.0.0.1", - "iptables -w 2 -t mangle -A TP_RULE -j CONNMARK --restore-mark", - "iptables -w 2 -t mangle -A TP_RULE -m mark --mark 0x40/0xc0 -j RETURN", - *[f"iptables -w 2 -t mangle -A TP_RULE -i {_iptables_interface(value)} -j RETURN" for value in _excluded_interfaces(settings)], - "iptables -w 2 -t mangle -A TP_RULE -p udp --dport 53 -j TP_MARK", - "iptables -w 2 -t mangle -A TP_RULE -p tcp --dport 53 -j TP_MARK", - "iptables -w 2 -t mangle -A TP_RULE -m mark --mark 0x40/0xc0 -j RETURN", - _local_ipv4_return_script("iptables -w 2 -t mangle -A TP_RULE"), - *[f"iptables -w 2 -t mangle -A TP_RULE -d {cidr} -j RETURN" for cidr in _ipv4_reserved_cidrs(settings)], - *[f"iptables -w 2 -t mangle -A TP_RULE -d {cidr} -j RETURN" for cidr in tproxy_white_cidrs], - "iptables -w 2 -t mangle -A TP_RULE -j TP_MARK", - "iptables -w 2 -t mangle -A TP_MARK -p tcp -m tcp --syn -j MARK --set-xmark 0x40/0x40", - "iptables -w 2 -t mangle -A TP_MARK -p udp -m conntrack --ctstate NEW -j MARK --set-xmark 0x40/0x40", - "iptables -w 2 -t mangle -A TP_MARK -j CONNMARK --save-mark", - ] - cleanup = [ - "ip rule del fwmark 0x40/0xc0 table 100", - "ip route del local 0.0.0.0/0 dev lo table 100", - "iptables -w 2 -t mangle -F TP_OUT", - "iptables -w 2 -t mangle -D OUTPUT -j TP_OUT", - "iptables -w 2 -t mangle -X TP_OUT", - "iptables -w 2 -t mangle -F TP_PRE", - "iptables -w 2 -t mangle -D PREROUTING -j TP_PRE", - "iptables -w 2 -t mangle -X TP_PRE", - "iptables -w 2 -t mangle -F TP_RULE", - "iptables -w 2 -t mangle -X TP_RULE", - "iptables -w 2 -t mangle -F TP_MARK", - "iptables -w 2 -t mangle -X TP_MARK", - ] - if ipv6: - lines.extend(_tproxy_ip6tables_setup(settings)) - cleanup.extend(_tproxy_ip6tables_cleanup()) - return TransparentRuleSet(backend="iptables", mode="tproxy", setup=_script(lines), cleanup=_best_effort_script(cleanup)) - - -def _system_proxy_rules(settings: XrayConfigSettings) -> TransparentRuleSet: - return TransparentRuleSet( - backend="system", - mode="system_proxy", - setup="\n".join([ - f"# HTTP proxy: 127.0.0.1:{settings.transparent.port}", - f"# SOCKS proxy: 127.0.0.1:{settings.transparent.socks_port}", - ]), - cleanup="# clear desktop system proxy settings", - ) - - -def _ip_forward_script(settings: XrayConfigSettings) -> str: - """生成 IP Forward 设置脚本。 - - `.v2rayA/service/core/ipforward` 的 Linux 实现直接写 procfs: - `/proc/sys/net/ipv4/ip_forward` 和 `/proc/sys/net/ipv6/conf/all/forwarding`。 - 这里保持相同语义,只把操作写成可审计脚本,不主动执行。 - """ - - value = "1" if settings.transparent.ipforward or settings.transparent.docker_transparent else "0" - return "\n".join([ - f"printf '%s' {value} > /proc/sys/net/ipv4/ip_forward", - f"printf '%s' {value} > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true", - ]) - - -def _resolv_hijack_script(settings: XrayConfigSettings) -> str: - """生成 `/etc/resolv.conf` DNS 劫持脚本。 - - v2rayA 在 redirect 透明代理和本地 DNS 监听可用时,将 resolv.conf 写为: - `nameserver 127.2.0.17` + `nameserver 119.29.29.29`。 - pyxray 只生成脚本,不定时守护重写。 - """ - - if not _should_hijack_resolv(settings): - return "" - return "cat > /etc/resolv.conf <<'EOF'\n# v2rayA DNS hijack\nnameserver 127.2.0.17\nnameserver 119.29.29.29\nEOF" - - -def _resolv_restore_script(settings: XrayConfigSettings) -> str: - if not _should_hijack_resolv(settings): - return "" - return "cat > /etc/resolv.conf <<'EOF'\n# v2rayA DNS hijack\nnameserver 223.6.6.6\nnameserver 119.29.29.29\nEOF" - - -def _should_hijack_resolv(settings: XrayConfigSettings) -> bool: - return ( - settings.dns.local_dns_listen - and settings.transparent.mode != "close" - and settings.transparent.type == "redirect" - ) - - -def _redirect_nft_table(settings: XrayConfigSettings, *, ipv6: bool) -> str: - nfproto = "meta nfproto { ipv4, ipv6 }" if ipv6 else "meta nfproto ipv4" - interface_returns = "\n".join(f' iifname "{value}" return' for value in _excluded_interfaces(settings)) - whitelist6 = _nft_set("whitelist6", "ipv6_addr", IPV6_RESERVED_CIDRS) if ipv6 else "" - return f"""table inet v2raya {{ -{_nft_set("whitelist", "ipv4_addr", _ipv4_reserved_cidrs(settings))} -{whitelist6} - set interface {{ - type ipv4_addr - flags interval - auto-merge - }} - - set interface6 {{ - type ipv6_addr - flags interval - auto-merge - }} - - chain tp_rule {{ - ip daddr @whitelist return - ip daddr @interface return - {'ip6 daddr @whitelist6 return' if ipv6 else ''} - {'ip6 daddr @interface6 return' if ipv6 else ''} - meta mark & 0x80 == 0x80 return -{interface_returns} - meta l4proto tcp redirect to :{settings.transparent.port} - }} - - chain tp_pre {{ - type nat hook prerouting priority dstnat - 5 -{_redirect_nft_prerouting_jumps(settings, nfproto)} - }} - - chain tp_out {{ - type nat hook output priority -105 -{_nft_output_bypass_rules(settings, mode="redirect")} - {nfproto} meta l4proto tcp jump tp_rule - }} -}}""" - - -def _tproxy_nft_table(settings: XrayConfigSettings, *, ipv6: bool, tproxy_white_cidrs: list[str]) -> str: - nfproto = "meta nfproto { ipv4, ipv6 }" if ipv6 else "meta nfproto ipv4" - interface_returns = "\n".join(f' iifname "{value}" return' for value in _excluded_interfaces(settings)) - whitelist_returns = "\n".join(f" ip daddr {cidr} return" for cidr in tproxy_white_cidrs) - whitelist6 = _nft_set("whitelist6", "ipv6_addr", IPV6_RESERVED_CIDRS) if ipv6 else "" - return f"""table inet v2raya {{ -{_nft_set("whitelist", "ipv4_addr", _ipv4_reserved_cidrs(settings))} -{whitelist6} - set interface {{ - type ipv4_addr - flags interval - auto-merge - }} - - set interface6 {{ - type ipv6_addr - flags interval - auto-merge - }} - - chain tp_out {{ - meta mark & 0x80 == 0x80 return -{_nft_output_bypass_rules(settings, mode="tproxy")} - meta l4proto {{ tcp, udp }} fib saddr type local fib daddr type != local jump tp_rule - }} - - chain tp_pre {{ - iifname "lo" mark & 0xc0 != 0x40 return -{_tproxy_nft_prerouting_jumps(settings, nfproto)} - meta l4proto {{ tcp, udp }} mark & 0xc0 == 0x40 tproxy ip to 127.0.0.1:{settings.transparent.port} - {'meta l4proto { tcp, udp } mark & 0xc0 == 0x40 tproxy ip6 to [::1]:' + str(settings.transparent.port) if ipv6 else ''} - }} - - chain output {{ - type route hook output priority mangle - 5; policy accept; - {nfproto} jump tp_out - }} - - chain prerouting {{ - type filter hook prerouting priority mangle - 5; policy accept; - {nfproto} jump tp_pre - }} - - chain tp_rule {{ - meta mark set ct mark - meta mark & 0xc0 == 0x40 return -{interface_returns} - meta l4proto {{ tcp, udp }} th dport 53 jump tp_mark - meta mark & 0xc0 == 0x40 return - ip daddr @whitelist return - ip daddr @interface return -{whitelist_returns} - {'ip6 daddr @interface6 return' if ipv6 else ''} - {'ip6 daddr @whitelist6 return' if ipv6 else ''} - jump tp_mark - }} - - chain tp_mark {{ - tcp flags & (fin | syn | rst | ack) == syn meta mark set mark | 0x40 - meta l4proto udp ct state new meta mark set mark | 0x40 - ct mark set mark - }} -}}""" - - -def _redirect_ip6tables_setup(settings: XrayConfigSettings) -> list[str]: - return [ - "ip6tables -w 2 -t nat -N TP_OUT", - "ip6tables -w 2 -t nat -N TP_PRE", - "ip6tables -w 2 -t nat -N TP_RULE", - *[f"ip6tables -w 2 -t nat -A TP_RULE -d {cidr} -j RETURN" for cidr in IPV6_RESERVED_CIDRS], - "ip6tables -w 2 -t nat -A TP_RULE -m mark --mark 0x80/0x80 -j RETURN", - *[f"ip6tables -w 2 -t nat -A TP_RULE -i {_iptables_interface(value)} -j RETURN" for value in _excluded_interfaces(settings)], - f"ip6tables -w 2 -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports {settings.transparent.port}", - "ip6tables -w 2 -t nat -I PREROUTING -p tcp -j TP_PRE", - "ip6tables -w 2 -t nat -I OUTPUT -p tcp -j TP_OUT", - "ip6tables -w 2 -t nat -A TP_PRE -j TP_RULE", - "ip6tables -w 2 -t nat -A TP_OUT -j TP_RULE", - ] - - -def _redirect_ip6tables_cleanup() -> list[str]: - return [ - "ip6tables -w 2 -t nat -F TP_OUT", - "ip6tables -w 2 -t nat -D OUTPUT -p tcp -j TP_OUT", - "ip6tables -w 2 -t nat -X TP_OUT", - "ip6tables -w 2 -t nat -F TP_PRE", - "ip6tables -w 2 -t nat -D PREROUTING -p tcp -j TP_PRE", - "ip6tables -w 2 -t nat -X TP_PRE", - "ip6tables -w 2 -t nat -F TP_RULE", - "ip6tables -w 2 -t nat -X TP_RULE", - ] - - -def _tproxy_ip6tables_setup(settings: XrayConfigSettings) -> list[str]: - return [ - "ip -6 rule add fwmark 0x40/0xc0 table 100", - "ip -6 route add local ::/0 dev lo table 100", - "ip6tables -w 2 -t mangle -N TP_OUT", - "ip6tables -w 2 -t mangle -N TP_PRE", - "ip6tables -w 2 -t mangle -N TP_RULE", - "ip6tables -w 2 -t mangle -N TP_MARK", - "ip6tables -w 2 -t mangle -I OUTPUT -j TP_OUT", - "ip6tables -w 2 -t mangle -I PREROUTING -j TP_PRE", - "ip6tables -w 2 -t mangle -A TP_OUT -m mark --mark 0x80/0x80 -j RETURN", - f"ip6tables -w 2 -t mangle -A TP_PRE -p tcp -m mark --mark 0x40/0xc0 -j TPROXY --on-port {settings.transparent.port} --on-ip ::1", - f"ip6tables -w 2 -t mangle -A TP_PRE -p udp -m mark --mark 0x40/0xc0 -j TPROXY --on-port {settings.transparent.port} --on-ip ::1", - "ip6tables -w 2 -t mangle -A TP_RULE -j CONNMARK --restore-mark", - "ip6tables -w 2 -t mangle -A TP_RULE -j TP_MARK", - "ip6tables -w 2 -t mangle -A TP_MARK -j CONNMARK --save-mark", - ] - - -def _tproxy_ip6tables_cleanup() -> list[str]: - return [ - "ip -6 rule del fwmark 0x40/0xc0 table 100", - "ip -6 route del local ::/0 dev lo table 100", - "ip6tables -w 2 -t mangle -F TP_OUT", - "ip6tables -w 2 -t mangle -D OUTPUT -j TP_OUT", - "ip6tables -w 2 -t mangle -X TP_OUT", - "ip6tables -w 2 -t mangle -F TP_PRE", - "ip6tables -w 2 -t mangle -D PREROUTING -j TP_PRE", - "ip6tables -w 2 -t mangle -X TP_PRE", - "ip6tables -w 2 -t mangle -F TP_RULE", - "ip6tables -w 2 -t mangle -X TP_RULE", - "ip6tables -w 2 -t mangle -F TP_MARK", - "ip6tables -w 2 -t mangle -X TP_MARK", - ] - - -def _excluded_interfaces(settings: XrayConfigSettings) -> list[str]: - interfaces = [item.strip() for item in settings.transparent.tproxy_excluded_interfaces.split(",") if item.strip()] - if not settings.transparent.docker_transparent: - return interfaces - return [item for item in interfaces if not _is_docker_interface_pattern(item)] - - -def _ipv4_reserved_cidrs(settings: XrayConfigSettings) -> list[str]: - return IPV4_RESERVED_CIDRS.copy() - - -def _docker_transparent_cidrs(settings: XrayConfigSettings) -> list[ipaddress.IPv4Network]: - cidrs: list[ipaddress.IPv4Network] = [] - for raw in settings.transparent.docker_transparent_cidrs.split(";"): - value = raw.strip() - if not value: - continue - network = ipaddress.ip_network(value, strict=False) - if isinstance(network, ipaddress.IPv4Network): - cidrs.append(network) - return cidrs - - -def _docker_transparent_cidr_strings(settings: XrayConfigSettings) -> list[str]: - return [str(cidr) for cidr in _docker_transparent_cidrs(settings)] - - -def _tproxy_white_cidrs(settings: XrayConfigSettings, geoip_file: str | Path | None) -> list[str]: - cidrs = list(settings.transparent.tproxy_white_custom_ips) - if geoip_file is not None and Path(geoip_file).exists(): - for country_code in settings.transparent.tproxy_white_country_codes: - cidrs.extend(parse_geoip_country(geoip_file, country_code)) - return list(dict.fromkeys(cidr for cidr in cidrs if cidr)) - - -def _redirect_prerouting_jumps(settings: XrayConfigSettings) -> list[str]: - cidrs = _docker_transparent_cidr_strings(settings) - if not settings.transparent.docker_transparent or not cidrs: - return ["iptables -w 2 -t nat -A TP_PRE -j TP_RULE"] - return [f"iptables -w 2 -t nat -A TP_PRE -s {cidr} -j TP_RULE" for cidr in cidrs] - - -def _tproxy_prerouting_jumps(settings: XrayConfigSettings) -> list[str]: - cidrs = _docker_transparent_cidr_strings(settings) - if not settings.transparent.docker_transparent or not cidrs: - return [ - "iptables -w 2 -t mangle -A TP_PRE -p tcp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TP_RULE", - "iptables -w 2 -t mangle -A TP_PRE -p udp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TP_RULE", - ] - return [ - f"iptables -w 2 -t mangle -A TP_PRE -s {cidr} -p tcp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TP_RULE" - for cidr in cidrs - ] + [ - f"iptables -w 2 -t mangle -A TP_PRE -s {cidr} -p udp -m addrtype ! --src-type LOCAL ! --dst-type LOCAL -j TP_RULE" - for cidr in cidrs - ] - - -def _redirect_nft_prerouting_jumps(settings: XrayConfigSettings, nfproto: str) -> str: - cidrs = _docker_transparent_cidr_strings(settings) - if not settings.transparent.docker_transparent or not cidrs: - return f" {nfproto} meta l4proto tcp jump tp_rule" - return "\n".join(f" {nfproto} ip saddr {cidr} meta l4proto tcp jump tp_rule" for cidr in cidrs) - - -def _tproxy_nft_prerouting_jumps(settings: XrayConfigSettings, nfproto: str) -> str: - cidrs = _docker_transparent_cidr_strings(settings) - if not settings.transparent.docker_transparent or not cidrs: - return f" {nfproto} meta l4proto {{ tcp, udp }} fib saddr type != local fib daddr type != local jump tp_rule" - return "\n".join( - f" {nfproto} ip saddr {cidr} meta l4proto {{ tcp, udp }} fib saddr type != local fib daddr type != local jump tp_rule" - for cidr in cidrs - ) - - -def _is_docker_interface_pattern(value: str) -> bool: - normalized = value.strip().lower().replace("+", "*") - return normalized.startswith(("docker", "veth", "br-")) - - -def _iptables_interface(value: str) -> str: - return value.replace("*", "+") - - -def _output_bypass_entries(settings: XrayConfigSettings) -> list[tuple[str, str, int | None]]: - entries: list[tuple[str, str, int | None]] = [] - for raw in settings.transparent.output_bypass_rules.replace(",", "\n").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - parts = line.split() - if len(parts) != 2: - continue - protocol = parts[0].lower() - if protocol not in {"tcp", "udp", "all"}: - continue - target, port = _split_bypass_target(parts[1]) - try: - ipaddress.ip_network(target, strict=False) - except ValueError: - continue - entries.append((protocol, target, port)) - return entries - - -def _split_bypass_target(value: str) -> tuple[str, int | None]: - if ":" not in value: - return value, None - host, raw_port = value.rsplit(":", 1) - if not host or not raw_port.isdigit(): - return value, None - port = int(raw_port) - if not 1 <= port <= 65535: - return value, None - return host, port - - -def _iptables_output_bypass_rules(settings: XrayConfigSettings, *, table: str, mode: str) -> list[str]: - lines: list[str] = [] - allowed_protocols = {"tcp"} if mode == "redirect" else {"tcp", "udp"} - for protocol, target, port in _output_bypass_entries(settings): - protocols = sorted(allowed_protocols) if protocol == "all" else [protocol] - for item in protocols: - if item not in allowed_protocols: - continue - port_match = f" --dport {port}" if port is not None else "" - lines.append(f"iptables -w 2 -t {table} -A TP_OUT -p {item} -d {target}{port_match} -j RETURN") - return lines - - -def _nft_output_bypass_rules(settings: XrayConfigSettings, *, mode: str) -> str: - lines: list[str] = [] - allowed_protocols = {"tcp"} if mode == "redirect" else {"tcp", "udp"} - for protocol, target, port in _output_bypass_entries(settings): - protocols = sorted(allowed_protocols) if protocol == "all" else [protocol] - for item in protocols: - if item not in allowed_protocols: - continue - port_match = f" th dport {port}" if port is not None else "" - lines.append(f" ip daddr {target} meta l4proto {item}{port_match} return") - return "\n".join(lines) - - -def _nft_set(name: str, kind: str, values: list[str]) -> str: - elements = ",\n ".join(values) - return f""" set {name} {{ - type {kind} - flags interval - auto-merge - elements = {{ - {elements} - }} - }} -""" - - -def _script(lines: list[str]) -> str: - return "\n".join(line for line in lines if line.strip()) - - -def _best_effort_script(lines: list[str]) -> str: - return "\n".join(f"{line} 2>/dev/null || true" for line in lines if line.strip()) - - -def _local_ipv4_return_script(prefix: str) -> str: - return "\n".join([ - "if command -v ip >/dev/null 2>&1; then", - " ip -o -4 addr show | awk '{print $4}' | while read -r cidr; do", - " [ -n \"$cidr\" ] || continue", - f" {prefix} -d \"$cidr\" -j RETURN", - " done", - "fi", - ]) - - -def _write_script(path: Path, content: str) -> None: - path.write_text("#!/bin/sh\nset -eu\n" + (content.strip() + "\n" if content.strip() else ""), encoding="utf-8") - path.chmod(0o755) diff --git a/pyxray/libs/xray_runtime.py b/pyxray/libs/xray_runtime.py deleted file mode 100644 index eeeb4f4..0000000 --- a/pyxray/libs/xray_runtime.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import json -import os -import re -import signal -import socket -import subprocess -import threading -import time -from collections.abc import Callable -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -class XrayServiceManager: - """管理当前 Web 进程启动的 Xray 子进程。 - - 这里不伪装成 systemd 服务,只管理 pyxray 自己启动的进程。 - 进程 stdout/stderr 统一写入日志文件,供 Web UI 轮询读取。 - """ - - def __init__( - self, - *, - xray_dir: str | Path, - config_path: str | Path, - log_path: str | Path, - preferred_xray_dir: Callable[[], str | Path | None] | None = None, - before_stop: Callable[[], None] | None = None, - ) -> None: - self.default_xray_dir = Path(xray_dir) - self.config_path = Path(config_path) - self.log_path = Path(log_path) - self.preferred_xray_dir = preferred_xray_dir - self.before_stop = before_stop - self.process: subprocess.Popen[bytes] | None = None - - def status(self) -> dict[str, Any]: - process = self._running_process() - return { - "running": process is not None, - "pid": process.pid if process is not None else None, - "xray": str(self.xray_path), - "xray_dir": str(self.xray_dir), - "fallback_xray_dir": str(self.default_xray_dir), - "config": str(self.config_path), - "log": str(self.log_path), - } - - def start(self) -> dict[str, Any]: - process = self._running_process() - if process is not None: - return self.status() - xray_path = self.xray_path - if not xray_path.exists(): - raise FileNotFoundError(f"Xray binary not found: {xray_path}") - if not self.config_path.exists(): - raise FileNotFoundError(f"Xray config not found: {self.config_path}") - resolved_xray_path = xray_path.resolve() - resolved_config_path = self.config_path.resolve() - - self.log_path.parent.mkdir(parents=True, exist_ok=True) - self._check_inbound_ports_available() - self._append_marker("start", xray_path=resolved_xray_path) - kwargs: dict[str, Any] = {} - if os.name == "posix": - kwargs["start_new_session"] = True - self.process = subprocess.Popen( - [str(resolved_xray_path), "run", "-config", str(resolved_config_path)], - cwd=str(resolved_xray_path.parent), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - **kwargs, - ) - self._start_log_forwarder(self.process) - time.sleep(0.2) - if self.process.poll() is not None: - code = self.process.returncode - self.process = None - time.sleep(0.1) - detail = read_log_tail(self.log_path, max_bytes=8192).strip() - raise RuntimeError(f"Xray exited immediately with code {code}: {detail}") - return self.status() - - def stop(self) -> dict[str, Any]: - process = self._running_process() - if process is None: - return self.status() - self._append_marker("stop") - self._run_before_stop() - self._terminate_process(process) - return self.status() - - def shutdown(self) -> None: - """Python 进程退出前清理由本管理器启动的 Xray。""" - - process = self._running_process() - if process is None: - return - self._append_marker("shutdown") - self._run_before_stop() - self._terminate_process(process) - - def log_message(self, message: str) -> None: - """Append a pyxray service message to the service log.""" - - self._append_message(message) - - def _run_before_stop(self) -> None: - if self.before_stop is None: - return - try: - self.before_stop() - except Exception as exc: # noqa: BLE001 - self._append_message(f"transparent cleanup failed: {exc}") - - def _terminate_process(self, process: subprocess.Popen[bytes]) -> None: - if os.name == "posix": - os.killpg(process.pid, signal.SIGTERM) - else: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - if os.name == "posix": - os.killpg(process.pid, signal.SIGKILL) - else: - process.kill() - process.wait(timeout=5) - self.process = None - - @property - def xray_path(self) -> Path: - return self.xray_dir / ("xray.exe" if os.name == "nt" else "xray") - - @property - def xray_dir(self) -> Path: - preferred = Path(self.preferred_xray_dir()) if self.preferred_xray_dir is not None else self.default_xray_dir - if (preferred / ("xray.exe" if os.name == "nt" else "xray")).exists(): - return preferred - return self.default_xray_dir - - def _running_process(self) -> subprocess.Popen[bytes] | None: - if self.process is None: - return None - if self.process.poll() is not None: - self.process = None - return None - return self.process - - def _append_marker(self, action: str, *, xray_path: Path | None = None, detail: str = "") -> None: - resolved_detail = f" {xray_path}" if xray_path is not None else "" - resolved_detail += f" {detail}" if detail else "" - self._append_message(f"{action} xray{resolved_detail}") - - def _append_message(self, message: str) -> None: - self.log_path.parent.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(timezone.utc).isoformat() - with self.log_path.open("ab") as log: - log.write(f"\n[{timestamp}] pyxray {message}\n".encode()) - - def _check_inbound_ports_available(self) -> None: - try: - config = json.loads(self.config_path.read_text(encoding="utf-8")) - except Exception as exc: # noqa: BLE001 - self._append_message(f"port check skipped: failed to read config: {exc}") - return - errors: list[str] = [] - for inbound in config.get("inbounds", []): - errors.extend(_inbound_port_errors(inbound)) - if errors: - message = "inbound port conflict: " + "; ".join(errors) - self._append_message(message) - raise RuntimeError(message) - - def _start_log_forwarder(self, process: subprocess.Popen[bytes]) -> None: - """持续转写 Xray 输出,确保启动失败原因进入日志。""" - - def forward() -> None: - assert process.stdout is not None - with self.log_path.open("ab") as log: - for line in iter(process.stdout.readline, b""): - log.write(line) - log.flush() - process.poll() - timestamp = datetime.now(timezone.utc).isoformat() - log.write(f"\n[{timestamp}] pyxray xray exited code {process.returncode}\n".encode()) - log.flush() - - threading.Thread(target=forward, daemon=True).start() - - -def read_log_tail(path: str | Path, *, max_bytes: int = 65536) -> str: - """读取日志尾部,避免日志过大时一次性加载。""" - - resolved = Path(path) - if not resolved.exists(): - return "" - with resolved.open("rb") as file: - file.seek(0, os.SEEK_END) - size = file.tell() - file.seek(max(0, size - max_bytes)) - content = file.read().decode("utf-8", errors="replace") - return _tail_lines(content, 1000) - - -def read_log_since(path: str | Path, offset: int) -> tuple[str, int]: - resolved = Path(path) - if not resolved.exists(): - return "", 0 - size = resolved.stat().st_size - if offset < 0 or offset > size: - offset = size - with resolved.open("rb") as file: - file.seek(offset) - content = file.read().decode("utf-8", errors="replace") - return _tail_lines(content, 1000), size - - -def compact_xray_log(content: str) -> str: - """Convert verbose Xray routing logs into concise route decisions.""" - - entries: list[str] = [] - for line in content.splitlines(): - entry = _compact_log_line(line) - if entry: - entries.append(entry) - return "\n".join(entries) - - -def log_file_size(path: str | Path) -> int: - resolved = Path(path) - if not resolved.exists(): - return 0 - return resolved.stat().st_size - - -def _tail_lines(content: str, line_count: int) -> str: - lines = content.splitlines() - return "\n".join(lines[-line_count:]) - - -def _compact_log_line(line: str) -> str | None: - match = _DETOUR_RE.search(line) - if match: - target = _compact_target(match.group("target")) - return f"{_log_time(line)} {target} -> {match.group('outbound')}".strip() - if _IMPORTANT_LOG_RE.search(line): - return line.strip() - return None - - -def _compact_target(target: str) -> str: - if target.startswith(("tcp:", "udp:")): - return target.split(":", 1)[1] - return target - - -def _log_time(line: str) -> str: - match = _LOG_TIME_RE.match(line) - return match.group(1) if match else "" - - -_LOG_TIME_RE = re.compile(r"^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})") -_DETOUR_RE = re.compile(r"taking detour \[(?P[^\]]+)\] for \[(?P[^\]]+)\]") -_IMPORTANT_LOG_RE = re.compile(r"\[(Warning|Error)\]|\b(failed|error|timeout|denied)\b", re.IGNORECASE) - - -def _inbound_port_errors(inbound: dict) -> list[str]: - port = int(inbound.get("port") or 0) - if port <= 0: - return [] - if port < 1024 and hasattr(os, "geteuid") and os.geteuid() != 0: - return [] - listen = inbound.get("listen") or "0.0.0.0" - tag = inbound.get("tag") or inbound.get("protocol") or "inbound" - networks = _inbound_networks(inbound) - errors: list[str] = [] - for network in networks: - error = _bind_error(listen, port, network) - if error: - errors.append(f"{tag} {listen}:{port}/{network} {error}") - return errors - - -def _inbound_networks(inbound: dict) -> set[str]: - protocol = str(inbound.get("protocol") or "").lower() - network = str((inbound.get("settings") or {}).get("network") or "") - if network: - return {item.strip() for item in network.split(",") if item.strip() in {"tcp", "udp"}} - if protocol == "dokodemo-door": - return {"tcp", "udp"} - if protocol in {"socks", "mixed"} and (inbound.get("settings") or {}).get("udp"): - return {"tcp", "udp"} - return {"tcp"} - - -def _bind_error(listen: str, port: int, network: str) -> str: - sock_type = socket.SOCK_DGRAM if network == "udp" else socket.SOCK_STREAM - family = socket.AF_INET6 if ":" in listen else socket.AF_INET - sock = socket.socket(family, sock_type) - try: - if network == "tcp": - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind((listen, port)) - if network == "tcp": - sock.listen(1) - except OSError as exc: - return str(exc) - finally: - sock.close() - return "" diff --git a/pyxray/libs/xray_transparent_runtime.py b/pyxray/libs/xray_transparent_runtime.py deleted file mode 100644 index 950b0cb..0000000 --- a/pyxray/libs/xray_transparent_runtime.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import subprocess -import threading -import time -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from pyxray.libs.xray_config.settings import XrayConfigSettings - - -CommandExecutor = Callable[[list[str]], subprocess.CompletedProcess[str]] -LocalCidrsProvider = Callable[[], list[str]] - - -@dataclass(slots=True) -class TransparentRuntimePaths: - """透明代理运行时脚本路径。""" - - directory: Path - ip_forward: Path - resolv_setup: Path - resolv_cleanup: Path - backend_setup: Path - backend_cleanup: Path - - -class TransparentRuntime: - """执行透明代理系统规则,并在失败时做 best-effort 清理。""" - - def __init__( - self, - *, - transparent_dir: str | Path, - log_path: str | Path, - backend: str = "auto", - executor: CommandExecutor | None = None, - local_cidrs_provider: LocalCidrsProvider | None = None, - watcher_interval: float = 3.0, - ) -> None: - if backend not in {"auto", "iptables", "nft"}: - raise ValueError("backend must be one of: auto, iptables, nft") - self.transparent_dir = Path(transparent_dir) - self.log_path = Path(log_path) - self.backend_preference = backend - self.backend = "iptables" if backend == "auto" else backend - self.executor = executor or self._run_subprocess - self.local_cidrs_provider = local_cidrs_provider or self._read_local_cidrs - self.watcher_interval = watcher_interval - self.active = False - self.settings: XrayConfigSettings | None = None - self.watcher: LocalIPWatcher | None = None - - def setup(self, settings: XrayConfigSettings) -> None: - """应用透明代理规则。 - - 语义参考 v2rayA:应用前先清理旧规则;任一步失败则回滚已应用的规则。 - """ - - self.cleanup(best_effort=True) - if settings.transparent.mode == "close": - self._append_log("transparent mode is close; skip setup") - self.active = False - return - - try: - self._run_script(self.paths.ip_forward, "apply ip-forward") - self._setup_backend() - self._run_script(self.paths.resolv_setup, "setup resolv hijack") - except Exception: - self.cleanup(best_effort=True) - raise - self.active = True - self.settings = settings - self._start_local_ip_watcher(settings) - - def cleanup(self, *, best_effort: bool = True) -> None: - """卸载透明代理规则。""" - - self._stop_local_ip_watcher() - errors: list[Exception] = [] - for path, action in ( - (self.paths.resolv_cleanup, "cleanup resolv hijack"), - (self.paths.backend_cleanup, f"cleanup transparent {self.backend}"), - ): - try: - self._run_script(path, action, best_effort=best_effort) - except Exception as exc: # noqa: BLE001 - errors.append(exc) - if not best_effort: - break - self.active = False - self.settings = None - if errors and not best_effort: - raise RuntimeError("; ".join(str(error) for error in errors)) - - def _setup_backend(self) -> None: - backends = [self.backend] if self.backend_preference != "auto" else ["iptables", "nft"] - errors: list[str] = [] - for backend in backends: - self.backend = backend - try: - self._run_script(self.paths.backend_setup, f"setup transparent {backend}") - return - except Exception as exc: # noqa: BLE001 - errors.append(f"{backend}: {exc}") - self._append_log(f"setup transparent {backend}: failed, try next backend") - self._run_script(self.paths.backend_cleanup, f"cleanup transparent {backend}", best_effort=True) - raise RuntimeError("; ".join(errors)) - - def _start_local_ip_watcher(self, settings: XrayConfigSettings) -> None: - if settings.transparent.type not in {"redirect", "tproxy"}: - return - self.watcher = LocalIPWatcher( - runtime=self, - mode=settings.transparent.type, - interval=self.watcher_interval, - cidrs_provider=self.local_cidrs_provider, - ) - self.watcher.start() - - def _stop_local_ip_watcher(self) -> None: - if self.watcher is None: - return - self.watcher.stop() - self.watcher = None - - def add_local_cidr_return(self, cidr: str, mode: str) -> None: - for command in self._local_cidr_commands(cidr, mode, add=True): - self._run_command(command, f"watch local cidr add {cidr}", best_effort=True) - - def remove_local_cidr_return(self, cidr: str, mode: str) -> None: - for command in self._local_cidr_commands(cidr, mode, add=False): - self._run_command(command, f"watch local cidr remove {cidr}", best_effort=True) - - def _local_cidr_commands(self, cidr: str, mode: str, *, add: bool) -> list[list[str]]: - if self.backend == "nft": - action = "add" if add else "delete" - return [["nft", action, "element", "inet", "v2raya", "interface", "{", cidr, "}"]] - - table = "nat" if mode == "redirect" else "mangle" - if add: - return [ - ["iptables", "-w", "2", "-t", table, "-D", "TP_RULE", "-d", cidr, "-j", "RETURN"], - ["iptables", "-w", "2", "-t", table, "-I", "TP_RULE", "1", "-d", cidr, "-j", "RETURN"], - ] - return [["iptables", "-w", "2", "-t", table, "-D", "TP_RULE", "-d", cidr, "-j", "RETURN"]] - - @property - def paths(self) -> TransparentRuntimePaths: - directory = self.transparent_dir - return TransparentRuntimePaths( - directory=directory, - ip_forward=directory / "ip-forward-apply.sh", - resolv_setup=directory / "resolv-hijack-setup.sh", - resolv_cleanup=directory / "resolv-hijack-cleanup.sh", - backend_setup=directory / f"transparent-{self.backend}-setup.sh", - backend_cleanup=directory / f"transparent-{self.backend}-cleanup.sh", - ) - - def _run_script(self, path: Path, action: str, *, best_effort: bool = False) -> None: - if not path.exists(): - self._append_log(f"{action}: skip missing {path}") - return - if not _script_has_body(path): - self._append_log(f"{action}: skip empty {path}") - return - - self._append_log(f"{action}: /bin/sh {path}") - self._run_command(["/bin/sh", str(path)], action, best_effort=best_effort) - - def _run_command(self, command: list[str], action: str, *, best_effort: bool = False) -> None: - try: - result = self.executor(command) - except Exception as exc: - self._append_log(f"{action}: failed to execute: {exc}") - if best_effort: - return - raise - if result.stdout: - self._append_log(result.stdout.rstrip()) - if result.stderr: - self._append_log(result.stderr.rstrip()) - if result.returncode != 0: - message = f"{action}: exit code {result.returncode}" - self._append_log(message) - if not best_effort: - raise RuntimeError(message) - - def _append_log(self, message: str) -> None: - self.log_path.parent.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now(timezone.utc).isoformat() - with self.log_path.open("ab") as log: - log.write(f"[{timestamp}] pyxray transparent {message}\n".encode()) - - def _run_subprocess(self, command: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run(command, capture_output=True, text=True, timeout=20, check=False) - - def _read_local_cidrs(self) -> list[str]: - result = self._run_subprocess(["ip", "-o", "-4", "addr", "show"]) - if result.returncode != 0: - return [] - cidrs: list[str] = [] - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 4: - cidrs.append(parts[3]) - return list(dict.fromkeys(cidrs)) - - -class LocalIPWatcher: - """Keep local interface CIDRs in transparent proxy destination returns.""" - - def __init__( - self, - *, - runtime: TransparentRuntime, - mode: str, - interval: float, - cidrs_provider: LocalCidrsProvider, - ) -> None: - self.runtime = runtime - self.mode = mode - self.interval = interval - self.cidrs_provider = cidrs_provider - self.cidrs: set[str] = set() - self.stopped = threading.Event() - self.thread: threading.Thread | None = None - - def start(self) -> None: - self.sync() - self.thread = threading.Thread(target=self._loop, daemon=True) - self.thread.start() - - def stop(self) -> None: - self.stopped.set() - if self.thread is not None: - self.thread.join(timeout=1) - - def sync(self) -> None: - try: - current = set(self.cidrs_provider()) - except Exception as exc: # noqa: BLE001 - self.runtime._append_log(f"watch local cidr: failed to read local CIDRs: {exc}") - return - for cidr in sorted(current - self.cidrs): - self.runtime.add_local_cidr_return(cidr, self.mode) - for cidr in sorted(self.cidrs - current): - self.runtime.remove_local_cidr_return(cidr, self.mode) - self.cidrs = current - - def _loop(self) -> None: - while not self.stopped.wait(self.interval): - self.sync() - - -def _script_has_body(path: Path) -> bool: - for line in path.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if stripped and not stripped.startswith("#") and stripped != "set -eu": - return True - return False diff --git a/pyxray/web/__init__.py b/pyxray/web/__init__.py deleted file mode 100644 index 29240bb..0000000 --- a/pyxray/web/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Flask web UI for pyxray.""" diff --git a/pyxray/web/activity_log.py b/pyxray/web/activity_log.py deleted file mode 100644 index d83fdac..0000000 --- a/pyxray/web/activity_log.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -import logging - -from flask import Flask - - -logger = logging.getLogger("pyxray") - - -def log_activity(app: Flask, message: str) -> None: - """Write an operator-facing activity message to stdout/stderr logging.""" - - logger.info(message) diff --git a/pyxray/web/dashboard.py b/pyxray/web/dashboard.py deleted file mode 100644 index 969dad2..0000000 --- a/pyxray/web/dashboard.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from dataclasses import asdict - -from flask import Blueprint, Flask, current_app, render_template - -from pyxray.libs.xray_assets import check_xray_assets, default_xray_version, official_archive_url -from pyxray.libs.xray_config.store import dump_settings_toml -from pyxray.web.nodes import get_node_manager -from pyxray.web.xray_assets import asset_form_from_settings, get_asset_settings_store -from pyxray.web.xray_config import get_settings_store -from pyxray.web.xray_service import get_xray_service - - -blueprint = Blueprint("dashboard", __name__) - - -def register_dashboard(app: Flask) -> None: - """注册 Web 控制台首页。""" - - app.register_blueprint(blueprint) - - -@blueprint.get("/") -def index(): # noqa: ANN202 - """渲染 pyxray 控制台。""" - - asset_settings = get_asset_settings_store(current_app).load() - form = asset_form_from_settings(asset_settings) - status = check_xray_assets(form["directory"]) - manager = get_node_manager(current_app) - nodes = [node.to_dict() for node in manager.list_nodes()] - selected_id = manager.selected_id() - selected_node = manager.get_selected_node() - settings_store = get_settings_store(current_app) - settings = settings_store.load() - service_status = get_xray_service(current_app).status() - log_path = current_app.config["XRAY_LOG_PATH"] - return render_template( - "index.html", - form=form, - status=asdict(status), - ready=status.ready, - missing=status.missing, - official_url=official_archive_url(form["version"] or default_xray_version()), - nodes=nodes, - selected_id=selected_id, - selected_name=selected_node.name if selected_node is not None else "", - settings=settings.to_dict(), - settings_toml=dump_settings_toml(settings), - config_path=current_app.config["XRAY_CONFIG_PATH"], - service_status=service_status, - log_path=log_path, - log_content="", - ) diff --git a/pyxray/web/jobs.py b/pyxray/web/jobs.py deleted file mode 100644 index 15e1612..0000000 --- a/pyxray/web/jobs.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import threading -import uuid -from typing import Any, Callable - -from flask import Flask - - -Job = dict[str, Any] -Worker = Callable[[Job], None] - - -class JobStore: - """线程内存任务表,用于 Web 页面轮询后台任务状态。""" - - def __init__(self, *, run_sync: bool = False) -> None: - self.run_sync = run_sync - self._jobs: dict[str, Job] = {} - self._lock = threading.Lock() - - def start(self, worker: Worker, payload: dict[str, Any] | None = None) -> Job: - """创建任务并启动 worker。""" - - job: Job = { - "id": uuid.uuid4().hex, - "state": "running", - "payload": payload or {}, - "steps": [], - "status": None, - "error": None, - } - with self._lock: - self._jobs[job["id"]] = job - if self.run_sync: - worker(job) - else: - threading.Thread(target=worker, args=(job,), daemon=True).start() - return job - - def get(self, job_id: str) -> Job | None: - """按 ID 获取任务。""" - - with self._lock: - return self._jobs.get(job_id) - - def cancel(self, job_id: str) -> Job | None: - """标记任务需要取消。""" - - with self._lock: - job = self._jobs.get(job_id) - if job is not None: - job["cancel_requested"] = True - if job.get("state") == "running": - job["state"] = "cancelled" - job["error"] = "cancelled" - job["steps"].append({"name": "任务已停止", "state": "bad", "detail": "用户已请求停止下载任务"}) - return job - - -def init_job_store(app: Flask, *, run_sync: bool = False) -> JobStore: - """给 Flask app 初始化通用任务表。""" - - store = JobStore(run_sync=run_sync) - app.extensions["pyxray_jobs"] = store - return store - - -def get_job_store(app: Flask) -> JobStore: - """从 Flask app 取出通用任务表。""" - - return app.extensions["pyxray_jobs"] diff --git a/pyxray/web/nodes.py b/pyxray/web/nodes.py deleted file mode 100644 index a0ecacb..0000000 --- a/pyxray/web/nodes.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from flask import Blueprint, Flask, current_app, jsonify, request - -from pyxray.libs.nodes import NodeManager, NodeStore -from pyxray.web.activity_log import log_activity - - -blueprint = Blueprint("nodes", __name__, url_prefix="/api/nodes") - - -def register_nodes(app: Flask, store_path: str | Path) -> None: - """注册节点管理 API。""" - - app.config["NODE_STORE_PATH"] = str(store_path) - app.register_blueprint(blueprint) - - -def get_node_manager(app: Flask) -> NodeManager: - """创建节点管理器。""" - - return NodeManager(NodeStore(app.config["NODE_STORE_PATH"])) - - -@blueprint.get("") -def list_nodes_api(): # noqa: ANN202 - """返回节点列表和当前选择。""" - - manager = get_node_manager(current_app) - return jsonify( - { - "nodes": [node.to_dict() for node in manager.list_nodes()], - "selected_id": manager.selected_id(), - } - ) - - -@blueprint.post("/import") -def import_nodes_api(): # noqa: ANN202 - """导入一批节点链接。""" - - text = request.form.get("links", "").strip() - results = get_node_manager(current_app).import_links(text) - imported = sum(1 for item in results if item.node is not None) - failed = len(results) - imported - log_activity(current_app, f"Nodes imported: imported={imported} failed={failed}") - return jsonify( - { - "results": [ - { - "ok": item.node is not None, - "node": item.node.to_dict() if item.node is not None else None, - "error": item.error, - "link": item.link, - } - for item in results - ] - } - ) - - -@blueprint.post("/select") -def select_node_api(): # noqa: ANN202 - """选择当前用于生成配置的节点。""" - - node_id = request.form.get("node_id", "").strip() - try: - node = get_node_manager(current_app).select_node(node_id) - except ValueError as exc: - return jsonify({"error": str(exc)}), 404 - log_activity(current_app, f"Node selected: {node.name or node.id}") - try: - from pyxray.web.xray_service import restart_xray_service_if_running - - restart_status = restart_xray_service_if_running(current_app, reason="node selected") - except Exception as exc: # noqa: BLE001 - return jsonify({"error": str(exc), "node": node.to_dict()}), 400 - return jsonify({"node": node.to_dict(), "service": restart_status}) - - -@blueprint.delete("/") -def delete_node_api(node_id: str): # noqa: ANN202 - """删除节点。""" - - removed = get_node_manager(current_app).remove_node(node_id) - log_activity(current_app, f"Node deleted: {node_id} removed={removed}") - return jsonify({"removed": removed}) diff --git a/pyxray/web/server.py b/pyxray/web/server.py deleted file mode 100644 index c5fa44e..0000000 --- a/pyxray/web/server.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import atexit -import logging -import os -import signal -import socket -import sys -from pathlib import Path - -from flask import Flask - -from pyxray import __version__ -from pyxray.libs.app_data import resolve_app_data_paths -from pyxray.web.dashboard import register_dashboard -from pyxray.web.jobs import init_job_store -from pyxray.web.nodes import register_nodes -from pyxray.web.xray_assets import register_xray_assets -from pyxray.web.xray_config import register_xray_config -from pyxray.web.xray_service import register_xray_service - - -def create_app( - default_xray_dir: str | Path = "data/xray", - *, - default_data_dir: str | Path | None = None, - run_jobs_sync: bool = False, -) -> Flask: - """创建 pyxray Web 应用。""" - - app = Flask(__name__) - paths = resolve_app_data_paths(default_xray_dir, data_dir=default_data_dir) - config_path = paths.generated_config - init_job_store(app, run_sync=run_jobs_sync) - register_xray_assets(app, paths.xray_dir, paths.download_settings) - register_nodes(app, paths.nodes) - register_xray_config(app, paths.settings, config_path) - register_xray_service(app, xray_dir=paths.xray_dir, config_path=config_path, log_path=paths.log) - _bind_xray_lifecycle(app) - register_dashboard(app) - return app - - -def run_web(host: str, port: int, default_xray_dir: str | Path = "data/xray") -> None: - """启动开发模式 Web 服务。""" - - logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s", force=True) - logging.getLogger("pyxray").setLevel(logging.INFO) - if os.environ.get("PYXRAY_ACCESS_LOG") not in {"1", "true", "yes", "on"}: - logging.getLogger("werkzeug").disabled = True - _print_startup_banner(host, port) - create_app(default_xray_dir).run(host=host, port=port) - - -def _print_listen_urls(host: str, port: int) -> None: - if host in {"0.0.0.0", "::"}: - print(f" * Pyxray URL: http://127.0.0.1:{port}", flush=True) - lan_ip = _primary_lan_ip() - if lan_ip: - print(f" * Pyxray URL: http://{lan_ip}:{port}", flush=True) - return - print(f" * Pyxray URL: http://{host}:{port}", flush=True) - - -def _print_startup_banner(host: str, port: int) -> None: - print(f" * Pyxray version: {__version__}", flush=True) - _print_listen_urls(host, port) - - -def _primary_lan_ip() -> str | None: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.connect(("8.8.8.8", 80)) - return sock.getsockname()[0] - except OSError: - return None - finally: - sock.close() - - -def _bind_xray_lifecycle(app: Flask) -> None: - """确保 pyxray 进程退出时关闭它启动的 Xray。""" - - service = app.extensions["pyxray_xray_service"] - - def shutdown() -> None: - service.shutdown() - - atexit.register(shutdown) - if signal.getsignal(signal.SIGTERM) == signal.SIG_DFL: - signal.signal(signal.SIGTERM, lambda signum, frame: _shutdown_and_exit(shutdown, signum)) - if signal.getsignal(signal.SIGINT) == signal.default_int_handler: - signal.signal(signal.SIGINT, lambda signum, frame: _shutdown_and_exit(shutdown, signum)) - - -def _shutdown_and_exit(shutdown, signum: int) -> None: # noqa: ANN001 - shutdown() - sys.exit(128 + signum) diff --git a/pyxray/web/static/css/app.css b/pyxray/web/static/css/app.css deleted file mode 100644 index ba53d6e..0000000 --- a/pyxray/web/static/css/app.css +++ /dev/null @@ -1,183 +0,0 @@ -.tab-nav { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.tab-button { - border-radius: 1.25rem; - border: 1px solid transparent; - color: rgb(161 161 170); - padding: 0.75rem 1.25rem; - transition: - background-color 150ms ease, - border-color 150ms ease, - color 150ms ease; -} - -.tab-button:hover { - background: rgb(39 39 42); - color: rgb(244 244 245); -} - -.tab-button.is-active { - background: rgb(244 244 245); - border-color: rgb(244 244 245); - color: rgb(9 9 11); -} - -.tab-panel { - display: none; -} - -.tab-panel.is-active { - display: block; -} - -.config-card { - border: 1px solid rgb(39 39 42); - border-radius: 1.5rem; - background: rgb(9 9 11 / 0.45); - padding: 1rem; -} - -.config-card-title { - color: rgb(244 244 245); - font-size: 0.95rem; - font-weight: 600; - margin-bottom: 1rem; -} - -.config-grid { - display: grid; - gap: 1rem; - grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); -} - -.config-grid-two { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.config-grid-three { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -@media (max-width: 640px) { - .config-grid-two, - .config-grid-three { - grid-template-columns: 1fr; - } -} - -.config-field { - display: grid; - gap: 0.5rem; -} - -.config-field span, -.config-check span { - color: rgb(161 161 170); - font-size: 0.875rem; -} - -.config-field small { - color: rgb(113 113 122); - font-size: 0.75rem; - line-height: 1.25rem; -} - -.config-field input, -.config-field select, -.config-field textarea { - background: rgb(9 9 11); - border: 1px solid rgb(63 63 70); - border-radius: 1rem; - color: rgb(244 244 245); - outline: none; - padding: 0.75rem 1rem; -} - -.config-field textarea { - font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; - font-size: 0.75rem; - line-height: 1.5rem; - min-height: 8rem; -} - -.config-field input:focus, -.config-field select:focus, -.config-field textarea:focus { - border-color: rgb(161 161 170); -} - -.config-check { - align-items: center; - border: 1px solid rgb(39 39 42); - border-radius: 1rem; - display: flex; - gap: 0.75rem; - padding: 0.75rem 1rem; -} - -.config-check input { - accent-color: rgb(244 244 245); - height: 1rem; - width: 1rem; -} - -.config-field-inline-check > span { - align-items: center; - display: flex; - gap: 0.5rem; -} - -.config-field-inline-check input[type="checkbox"] { - accent-color: rgb(244 244 245); - height: 1rem; - width: 1rem; -} - -.config-actions { - bottom: 1.5rem; - display: flex; - gap: 0.75rem; - opacity: 0; - pointer-events: none; - position: fixed; - right: 1.5rem; - transform: translateY(0.75rem); - transition: - opacity 150ms ease, - transform 150ms ease; - z-index: 30; -} - -.config-actions.is-visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - -.config-action-button { - align-items: center; - border-radius: 9999px; - border: 1px solid rgb(63 63 70); - box-shadow: 0 1.25rem 2.5rem rgb(0 0 0 / 0.35); - display: inline-flex; - font-size: 1.5rem; - font-weight: 700; - height: 3.25rem; - justify-content: center; - line-height: 1; - width: 3.25rem; -} - -.config-action-save { - background: rgb(244 244 245); - color: rgb(9 9 11); -} - -.config-action-reset { - background: rgb(24 24 27); - color: rgb(244 244 245); -} diff --git a/pyxray/web/static/js/app.js b/pyxray/web/static/js/app.js deleted file mode 100644 index f39791f..0000000 --- a/pyxray/web/static/js/app.js +++ /dev/null @@ -1,562 +0,0 @@ -const initialNodes = JSON.parse(document.querySelector("#initial-nodes").textContent); -let selectedNodeId = JSON.parse(document.querySelector("#selected-node-id").textContent); - -const form = document.querySelector("#asset-form"); -const targetInput = document.querySelector("#download-target"); -const buttons = Array.from(document.querySelectorAll(".download-button")); -const cancelButton = document.querySelector("#cancel-button"); -const fields = Array.from(form.querySelectorAll("input")); -const badge = document.querySelector("#progress-badge"); -const panel = document.querySelector("#progress-panel"); -const mainProgressBar = document.querySelector("#main-progress-bar"); -const mainProgressText = document.querySelector("#main-progress-text"); -const statusCards = Array.from(document.querySelectorAll("[data-asset-name]")); -const serviceToggle = document.querySelector("#xray-service-toggle"); -const refreshLogsButton = document.querySelector("#refresh-logs-button"); -const clearLogsButton = document.querySelector("#clear-logs-button"); -const logFormatSelect = document.querySelector("#log-format-select"); -const logContent = document.querySelector("#xray-log-content"); -const activeJobKey = "pyxray.activeAssetJobId"; -const activeFormKey = "pyxray.activeAssetForm"; -const activeTabKey = "pyxray.activeTab"; -let activeJobId = null; -let logOffset = null; - -const nodeImportForm = document.querySelector("#node-import-form"); -const nodeList = document.querySelector("#node-list"); -const nodeCount = document.querySelector("#node-count"); -const nodeMessage = document.querySelector("#node-message"); -const selectedNodeLabel = document.querySelector("#selected-node-label"); -const settingsForm = document.querySelector("#settings-form"); -const configMessage = document.querySelector("#config-message"); -const configActions = document.querySelector("#config-actions"); -const configResetButton = document.querySelector("#config-reset-button"); -let nodes = initialNodes; -let settingsSnapshot = ""; - -initTabs(); -initTransparentSettings(); -initSettingsActions(); -initServiceControls(); -initLogControls(); -renderNodes(); - -const savedJobId = localStorage.getItem(activeJobKey); -if (savedJobId) { - restoreForm(); - activeJobId = savedJobId; - setRunning(true); - badge.textContent = "处理中"; - badge.className = "rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-sm text-amber-300"; - pollJob(savedJobId).catch((error) => { - renderSteps([{ name: "恢复失败", state: "bad", detail: error.message }], "bad"); - finish("失败", "bad"); - }); -} - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - const submitter = event.submitter; - if (submitter && submitter.dataset.downloadTarget) targetInput.value = submitter.dataset.downloadTarget; - const formData = new FormData(form); - saveForm(); - setRunning(true); - if (submitter) submitter.textContent = "处理中..."; - badge.textContent = "处理中"; - badge.className = "rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-sm text-amber-300"; - renderSteps([{ name: "提交任务", state: "running", detail: "正在创建后端任务" }], "running"); - - try { - const response = await fetch("/api/xray/assets/ensure", { - method: "POST", - body: formData, - }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); - activeJobId = payload.job_id; - localStorage.setItem(activeJobKey, payload.job_id); - await pollJob(payload.job_id); - } catch (error) { - renderSteps([{ name: "请求失败", state: "bad", detail: error.message }], "bad"); - finish("失败", "bad"); - } -}); - -cancelButton.addEventListener("click", async () => { - if (!activeJobId) return; - cancelButton.disabled = true; - cancelButton.textContent = "正在停止..."; - badge.textContent = "停止中"; - try { - const response = await fetch(`/api/xray/assets/jobs/${activeJobId}/cancel`, { method: "POST" }); - if (!response.ok) { - const payload = await response.json(); - throw new Error(payload.error || `HTTP ${response.status}`); - } - } catch (error) { - cancelButton.disabled = false; - cancelButton.textContent = "停止下载"; - renderSteps([{ name: "停止请求失败", state: "bad", detail: error.message }], "bad"); - } -}); - -nodeImportForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const response = await fetch("/api/nodes/import", { - method: "POST", - body: new FormData(nodeImportForm), - }); - const payload = await response.json(); - if (!response.ok) { - showBox(nodeMessage, payload.error || `HTTP ${response.status}`, "bad"); - return; - } - const ok = payload.results.filter((item) => item.ok).length; - const failed = payload.results.length - ok; - showBox(nodeMessage, `导入成功 ${ok} 个,失败 ${failed} 个`, failed ? "warn" : "done"); - nodeImportForm.reset(); - await refreshNodes(); -}); - -nodeList.addEventListener("click", async (event) => { - const action = event.target.dataset.action; - const nodeId = event.target.dataset.nodeId; - if (!action || !nodeId) return; - - if (action === "select") { - const body = new FormData(); - body.append("node_id", nodeId); - const response = await fetch("/api/nodes/select", { method: "POST", body }); - const payload = await response.json(); - if (!response.ok) { - showBox(nodeMessage, payload.error || `HTTP ${response.status}`, "bad"); - return; - } - selectedNodeId = payload.node.id; - renderNodes(); - if (payload.service) renderServiceStatus(payload.service); - else refreshServiceStatus(); - await refreshLogs(); - showBox(nodeMessage, `已选择:${payload.node.name}`, "done"); - } - - if (action === "delete") { - const response = await fetch(`/api/nodes/${encodeURIComponent(nodeId)}`, { method: "DELETE" }); - const payload = await response.json(); - if (!response.ok) { - showBox(nodeMessage, payload.error || `HTTP ${response.status}`, "bad"); - return; - } - if (selectedNodeId === nodeId) selectedNodeId = ""; - showBox(nodeMessage, payload.removed ? "节点已删除" : "节点不存在", payload.removed ? "done" : "warn"); - await refreshNodes(); - refreshServiceStatus(); - } -}); - -settingsForm.addEventListener("submit", async (event) => { - event.preventDefault(); - const response = await fetch("/api/xray/config/settings", { - method: "POST", - body: new FormData(settingsForm), - }); - const payload = await response.json(); - if (!response.ok) { - showBox(configMessage, payload.error || `HTTP ${response.status}`, "bad"); - return; - } - settingsSnapshot = serializeForm(settingsForm); - updateSettingsActions(); - if (payload.service) renderServiceStatus(payload.service); - await refreshLogs(); - showBox(configMessage, "设置已保存", "done"); -}); - -configResetButton.addEventListener("click", () => { - settingsForm.reset(); - updateSettingsActions(); - showBox(configMessage, "已撤回未保存修改", "warn"); -}); - -function initTabs() { - const tabButtons = Array.from(document.querySelectorAll("[data-tab-target]")); - const tabPanels = Array.from(document.querySelectorAll("[data-tab-panel]")); - const names = tabButtons.map((button) => button.dataset.tabTarget); - const saved = localStorage.getItem(activeTabKey); - const initial = names.includes(saved) ? saved : "nodes"; - - tabButtons.forEach((button) => { - button.addEventListener("click", () => activateTab(button.dataset.tabTarget)); - }); - - activateTab(initial); - - function activateTab(name) { - tabButtons.forEach((button) => { - const active = button.dataset.tabTarget === name; - button.classList.toggle("is-active", active); - button.setAttribute("aria-selected", active ? "true" : "false"); - }); - tabPanels.forEach((panelElement) => { - panelElement.classList.toggle("is-active", panelElement.dataset.tabPanel === name); - }); - localStorage.setItem(activeTabKey, name); - } -} - -function initTransparentSettings() { - const typeSelect = document.querySelector("#transparent-type"); - const excludedInput = document.querySelector("#tproxy-excluded-interfaces"); - const dockerTransparent = document.querySelector("#docker-transparent"); - const dockerCidrs = document.querySelector("#docker-transparent-cidrs"); - if (!typeSelect || !excludedInput || !dockerTransparent || !dockerCidrs) return; - - const update = () => { - const disabled = !["redirect", "tproxy"].includes(typeSelect.value); - const cidrsDisabled = disabled || dockerTransparent.value !== "on"; - excludedInput.disabled = disabled; - dockerTransparent.disabled = disabled; - dockerCidrs.disabled = cidrsDisabled; - excludedInput.classList.toggle("opacity-50", disabled); - dockerTransparent.classList.toggle("opacity-50", disabled); - dockerCidrs.classList.toggle("opacity-50", cidrsDisabled); - }; - - typeSelect.addEventListener("change", update); - dockerTransparent.addEventListener("change", update); - update(); -} - -function initSettingsActions() { - settingsSnapshot = serializeForm(settingsForm); - settingsForm.addEventListener("input", updateSettingsActions); - settingsForm.addEventListener("change", updateSettingsActions); - updateSettingsActions(); -} - -function initServiceControls() { - if (!serviceToggle) return; - serviceToggle.addEventListener("click", async () => { - const running = serviceToggle.dataset.running === "true"; - serviceToggle.disabled = true; - serviceToggle.textContent = running ? "正在停止..." : "正在启动..."; - try { - const response = await fetch(`/api/xray/service/${running ? "stop" : "start"}`, { method: "POST" }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); - renderServiceStatus(payload); - await refreshLogs(); - } catch (error) { - serviceToggle.textContent = truncateServiceLabel(error.message); - serviceToggle.className = "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300"; - await refreshServiceStatus(); - await refreshLogs(); - if (logContent && !logContent.textContent.includes(error.message)) { - logContent.textContent = `${error.message}\n\n${logContent.textContent}`; - } - } finally { - serviceToggle.disabled = false; - } - }); - refreshServiceStatus(); - setInterval(refreshServiceStatus, 3000); -} - -function initLogControls() { - if (!refreshLogsButton) return; - refreshLogsButton.addEventListener("click", refreshLogs); - clearLogsButton.addEventListener("click", clearLogs); - logFormatSelect?.addEventListener("change", loadLogs); - resetLogOffset(); - setInterval(refreshLogs, 2000); -} - -async function refreshServiceStatus() { - const response = await fetch("/api/xray/service"); - if (!response.ok) return; - renderServiceStatus(await response.json()); -} - -function renderServiceStatus(status) { - serviceToggle.dataset.running = status.running ? "true" : "false"; - serviceToggle.textContent = `${status.running ? "关闭" : "开启"}: ${selectedNodeNameShort()}`; - serviceToggle.className = status.running - ? "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300" - : "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-5 py-2 text-sm font-medium text-emerald-300"; -} - -function selectedNodeNameShort() { - const selected = nodes.find((node) => node.id === selectedNodeId); - return truncateServiceLabel(selected?.name || "未选择"); -} - -function truncateServiceLabel(value) { - return value.length > 8 ? value.slice(0, 8) : value; -} - -async function refreshLogs() { - if (!logContent) return; - const query = logQuery(logOffset === null ? "end" : logOffset); - const response = await fetch(`/api/xray/service/logs${query}`); - const payload = await response.json(); - if (!response.ok) { - logContent.textContent = payload.error || `HTTP ${response.status}`; - return; - } - if (typeof payload.offset === "number") logOffset = payload.offset; - if (payload.content) { - const previous = logContent.textContent === "暂无日志。" ? "" : logContent.textContent; - logContent.textContent = `${previous}${previous ? "\n" : ""}${payload.content}`; - } else if (!logContent.textContent.trim()) { - logContent.textContent = "暂无日志。"; - } - logContent.scrollTop = logContent.scrollHeight; -} - -async function clearLogs() { - if (!logContent) return; - clearLogsButton.disabled = true; - try { - const response = await fetch("/api/xray/service/logs", { method: "DELETE" }); - const payload = await response.json(); - if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); - if (typeof payload.offset === "number") logOffset = payload.offset; - logContent.textContent = "暂无日志。"; - } catch (error) { - logContent.textContent = error.message; - } finally { - clearLogsButton.disabled = false; - } -} - -async function loadLogs() { - if (!logContent) return; - const response = await fetch(`/api/xray/service/logs${logFormatQuery()}`); - const payload = await response.json(); - if (!response.ok) { - logContent.textContent = payload.error || `HTTP ${response.status}`; - return; - } - if (typeof payload.offset === "number") logOffset = payload.offset; - logContent.textContent = payload.content || "暂无日志。"; - logContent.scrollTop = logContent.scrollHeight; -} - -async function resetLogOffset() { - if (!logContent) return; - const response = await fetch(`/api/xray/service/logs${logQuery("end")}`); - if (!response.ok) return; - const payload = await response.json(); - logOffset = payload.offset || 0; - logContent.textContent = "暂无日志。"; -} - -function logQuery(offset) { - const params = new URLSearchParams(); - params.set("offset", String(offset)); - if ((logFormatSelect?.value || "compact") === "compact") { - params.set("format", "compact"); - } - return `?${params.toString()}`; -} - -function logFormatQuery() { - const params = new URLSearchParams(); - if ((logFormatSelect?.value || "compact") === "compact") { - params.set("format", "compact"); - } - const query = params.toString(); - return query ? `?${query}` : ""; -} - -function updateSettingsActions() { - configActions.classList.toggle("is-visible", serializeForm(settingsForm) !== settingsSnapshot); -} - -function serializeForm(targetForm) { - return JSON.stringify(Array.from(new FormData(targetForm).entries()).sort(([left], [right]) => left.localeCompare(right))); -} - -async function refreshNodes() { - const response = await fetch("/api/nodes"); - const payload = await response.json(); - nodes = payload.nodes; - selectedNodeId = payload.selected_id; - renderNodes(); -} - -function renderNodes() { - nodeCount.textContent = `${nodes.length} 个节点`; - const selected = nodes.find((node) => node.id === selectedNodeId); - selectedNodeLabel.textContent = selected ? `${selected.name} / ${selected.protocol}` : "未选择节点"; - selectedNodeLabel.className = selected - ? "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-sm text-emerald-300" - : "rounded-full border border-zinc-800 px-3 py-1 text-sm text-zinc-400"; - if (!nodes.length) { - nodeList.innerHTML = `
还没有导入节点。
`; - return; - } - nodeList.innerHTML = nodes.map((node) => { - const selectedClass = node.id === selectedNodeId ? "border-emerald-500/40 bg-emerald-500/10" : "border-zinc-800 bg-zinc-950/60"; - const selectedText = node.id === selectedNodeId ? "当前选择" : "选择"; - return ` -
-
-
-
${escapeHtml(node.name)}
-
${escapeHtml(node.protocol)}://${escapeHtml(node.server)}:${node.port}
-
-
- - -
-
-
- `; - }).join(""); -} - -async function pollJob(jobId) { - while (true) { - const response = await fetch(`/api/xray/assets/jobs/${jobId}`); - const job = await response.json(); - if (!response.ok) { - if (response.status === 404) { - localStorage.removeItem(activeJobKey); - localStorage.removeItem(activeFormKey); - } - throw new Error(job.error || `HTTP ${response.status}`); - } - - renderSteps(job.steps, job.state); - if (job.status) renderStatus(job.status.files); - - if (job.state === "done") { - localStorage.removeItem(activeJobKey); - localStorage.removeItem(activeFormKey); - finish("已完成", "done"); - return; - } - if (job.state === "bad") { - localStorage.removeItem(activeJobKey); - localStorage.removeItem(activeFormKey); - finish("失败", "bad"); - return; - } - if (job.state === "cancelled") { - localStorage.removeItem(activeJobKey); - localStorage.removeItem(activeFormKey); - finish("已停止", "bad"); - return; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} - -function renderSteps(steps, state) { - const activeDownload = [...steps].reverse().find((step) => step.name === "下载资源" && step.state === "running" && step.percent !== null); - const percent = activeDownload ? activeDownload.percent : (steps.length === 0 ? 0 : Math.min(95, steps.length * 18)); - const finalPercent = state === "done" ? 100 : percent; - mainProgressBar.style.width = `${finalPercent}%`; - mainProgressBar.className = `h-full rounded-full ${state === "bad" ? "bg-red-400" : "bg-zinc-100"}`; - mainProgressText.textContent = `${finalPercent}%`; - panel.innerHTML = steps.length ? steps.map(stepCard).join("") : stepCard({ - name: "等待任务状态", - state: "running", - detail: "任务已创建,正在等待后端开始执行。", - }); -} - -function stepCard(step) { - const bad = step.state === "bad"; - const running = step.state === "running"; - const status = bad ? "失败" : running ? "进行中" : "完成"; - const color = bad ? "text-red-300" : running ? "text-amber-300" : "text-emerald-300"; - const border = bad ? "border-red-500/30 bg-red-500/10" : "border-zinc-800 bg-zinc-950/60"; - const downloadHint = step.name === "下载资源" && step.percent !== null && step.percent !== undefined - ? `

下载进度:${step.percent}%

` - : ""; - return ` -
-
- ${escapeHtml(step.name)} - ${status} -
-

${escapeHtml(step.detail || "")}

- ${downloadHint} -
- `; -} - -function renderStatus(files) { - statusCards.forEach((card) => { - const name = card.dataset.assetName; - const value = card.querySelector("[data-asset-state]"); - if (!value || !(name in files)) return; - value.textContent = files[name] ? "存在" : "缺失"; - value.className = files[name] ? "mt-2 text-sm text-emerald-300" : "mt-2 text-sm text-red-300"; - }); -} - -function finish(text, state) { - activeJobId = null; - setRunning(false); - document.querySelector('[data-download-target="all"]').textContent = "下载所有"; - document.querySelector('[data-download-target="xray"]').textContent = "下载 xray"; - document.querySelector('[data-download-target="geoip"]').textContent = "下载 geoip"; - document.querySelector('[data-download-target="geosite"]').textContent = "下载 geosite"; - badge.textContent = text; - badge.className = state === "bad" - ? "rounded-full border border-red-500/30 bg-red-500/10 px-3 py-1 text-sm text-red-300" - : "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-sm text-emerald-300"; -} - -function setRunning(isRunning) { - buttons.forEach((button) => { button.disabled = isRunning; }); - fields.forEach((field) => { field.disabled = isRunning; }); - cancelButton.classList.toggle("hidden", !isRunning); - cancelButton.disabled = !isRunning; - cancelButton.textContent = "停止下载"; -} - -function saveForm() { - const values = {}; - new FormData(form).forEach((value, key) => { values[key] = value; }); - values.force = form.elements.force.checked ? "on" : ""; - values.target = targetInput.value; - localStorage.setItem(activeFormKey, JSON.stringify(values)); -} - -function restoreForm() { - const raw = localStorage.getItem(activeFormKey); - if (!raw) return; - const values = JSON.parse(raw); - Object.entries(values).forEach(([key, value]) => { - const field = form.elements[key]; - if (!field) return; - if (field.type === "checkbox") { - field.checked = value === "on"; - } else { - field.value = value; - } - }); -} - -function showBox(element, text, state) { - const classes = { - done: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300", - warn: "border-amber-500/30 bg-amber-500/10 text-amber-300", - bad: "border-red-500/30 bg-red-500/10 text-red-300", - }; - element.className = `rounded-2xl border px-4 py-3 text-sm ${classes[state] || classes.done}`; - element.textContent = text; -} - -function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} diff --git a/pyxray/web/templates/configs/core.html b/pyxray/web/templates/configs/core.html deleted file mode 100644 index 6ba965d..0000000 --- a/pyxray/web/templates/configs/core.html +++ /dev/null @@ -1,26 +0,0 @@ -
-

核心

-
- - - -
- -
diff --git a/pyxray/web/templates/configs/dns.html b/pyxray/web/templates/configs/dns.html deleted file mode 100644 index cf7576e..0000000 --- a/pyxray/web/templates/configs/dns.html +++ /dev/null @@ -1,55 +0,0 @@ -
-

DNS

-
- - - -
- -
- - -
- - -
diff --git a/pyxray/web/templates/configs/inbounds.html b/pyxray/web/templates/configs/inbounds.html deleted file mode 100644 index b1500d6..0000000 --- a/pyxray/web/templates/configs/inbounds.html +++ /dev/null @@ -1,56 +0,0 @@ -
-

入站端口

-
- - -
- -
- - -
- -
- - -
- - - - - - - - - - -
diff --git a/pyxray/web/templates/configs/routing.html b/pyxray/web/templates/configs/routing.html deleted file mode 100644 index cf646d1..0000000 --- a/pyxray/web/templates/configs/routing.html +++ /dev/null @@ -1,18 +0,0 @@ -
-

路由

-
- -
- - -
diff --git a/pyxray/web/templates/configs/transparent.html b/pyxray/web/templates/configs/transparent.html deleted file mode 100644 index da0e6d2..0000000 --- a/pyxray/web/templates/configs/transparent.html +++ /dev/null @@ -1,64 +0,0 @@ -
-

透明代理

-
- - -
-
- - -
-
- - -
-
- - -
- -
diff --git a/pyxray/web/templates/index.html b/pyxray/web/templates/index.html deleted file mode 100644 index 703d200..0000000 --- a/pyxray/web/templates/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - pyxray - - - - -
-
-
-

pyxray

-

Xray 资源、节点和配置生成控制台

-
-
- -
-
- - - -
- {% include "partials/nodes_tab.html" %} -
- -
- {% include "partials/config_tab.html" %} -
- -
- {% include "partials/download_tab.html" %} -
- -
- {% include "partials/logs_tab.html" %} -
-
- - - - - - diff --git a/pyxray/web/templates/partials/config_tab.html b/pyxray/web/templates/partials/config_tab.html deleted file mode 100644 index 9805e09..0000000 --- a/pyxray/web/templates/partials/config_tab.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-

配置设置

-

通过界面保存 settings.toml。

-
- -
- {% include "configs/core.html" %} - {% include "configs/inbounds.html" %} - {% include "configs/routing.html" %} - {% include "configs/transparent.html" %} - {% include "configs/dns.html" %} - - -
- - -
-
-
diff --git a/pyxray/web/templates/partials/download_tab.html b/pyxray/web/templates/partials/download_tab.html deleted file mode 100644 index 8ad199d..0000000 --- a/pyxray/web/templates/partials/download_tab.html +++ /dev/null @@ -1,89 +0,0 @@ -
-
-
-
-

资源下载

-

xray / geoip.dat / geosite.dat

-
-
- -
- {% for name, exists in status.files.items() %} -
-
{{ name }}
- {% if exists %} -
存在
- {% else %} -
缺失
- {% endif %} -
- {% endfor %} -
- -
- - - - - - - - - - - - - - - - -
- - - - -
- -
-
- - -
diff --git a/pyxray/web/templates/partials/logs_tab.html b/pyxray/web/templates/partials/logs_tab.html deleted file mode 100644 index 15fe154..0000000 --- a/pyxray/web/templates/partials/logs_tab.html +++ /dev/null @@ -1,22 +0,0 @@ -
-
-
-

运行日志

-

Xray stdout / stderr,日志文件:{{ log_path }}

-
-
- - - -
-
- -
-
{{ log_content or "暂无日志。" }}
-
-
diff --git a/pyxray/web/templates/partials/nodes_tab.html b/pyxray/web/templates/partials/nodes_tab.html deleted file mode 100644 index bf0517b..0000000 --- a/pyxray/web/templates/partials/nodes_tab.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
-
-

节点管理

-

导入、选择和删除用于生成配置的节点。

-
- 0 个节点 -
- -
- -
- - 未选择节点 -
-
- - -
-
diff --git a/pyxray/web/xray_assets.py b/pyxray/web/xray_assets.py deleted file mode 100644 index 8aa18d7..0000000 --- a/pyxray/web/xray_assets.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from flask import Blueprint, Flask, current_app, jsonify, request - -from pyxray.libs.xray_assets import ( - DEFAULT_VERSION, - check_xray_assets, - default_xray_version, - download_bytes_stream, - ensure_xray_assets, -) -from pyxray.libs.xray_asset_settings import XrayAssetSettings, XrayAssetSettingsStore -from pyxray.web.activity_log import log_activity -from pyxray.web.jobs import Job, get_job_store - - -blueprint = Blueprint("xray_assets", __name__) - - -def register_xray_assets(app: Flask, default_xray_dir: str | Path, settings_path: str | Path) -> None: - """注册 Xray 资源管理 API。""" - - app.config["DEFAULT_XRAY_DIR"] = str(default_xray_dir) - app.config["XRAY_ASSET_SETTINGS_PATH"] = str(settings_path) - app.register_blueprint(blueprint) - - -def get_asset_settings_store(app: Flask) -> XrayAssetSettingsStore: - """创建下载设置存储器。""" - - return XrayAssetSettingsStore(app.config["XRAY_ASSET_SETTINGS_PATH"], default_directory=app.config["DEFAULT_XRAY_DIR"]) - - -@blueprint.post("/api/xray/assets/ensure") -def ensure_api(): # noqa: ANN202 - form = _form_values() - get_asset_settings_store(current_app).save(_settings_from_form(form)) - log_activity(current_app, f"Asset ensure started: target={form['target'] or 'all'} directory={form['directory']}") - job = get_job_store(current_app).start(lambda item: _run_asset_job(item, form), payload=form) - return jsonify({"job_id": job["id"]}) - - -@blueprint.get("/api/xray/assets/settings") -def get_asset_settings_api(): # noqa: ANN202 - settings = get_asset_settings_store(current_app).load() - return jsonify(settings.to_dict()) - - -@blueprint.post("/api/xray/assets/settings") -def save_asset_settings_api(): # noqa: ANN202 - form = _form_values() - settings = _settings_from_form(form) - get_asset_settings_store(current_app).save(settings) - log_activity(current_app, f"Asset settings saved: directory={settings.directory} version={settings.version}") - return jsonify(settings.to_dict()) - - -@blueprint.get("/api/xray/assets/jobs/") -def job_api(job_id: str): # noqa: ANN202 - job = get_job_store(current_app).get(job_id) - if job is None: - return jsonify({"error": "job not found"}), 404 - return jsonify(job) - - -@blueprint.post("/api/xray/assets/jobs//cancel") -def cancel_job_api(job_id: str): # noqa: ANN202 - job = get_job_store(current_app).cancel(job_id) - if job is None: - return jsonify({"error": "job not found"}), 404 - log_activity(current_app, f"Asset job cancelled: {job_id}") - return jsonify({"job_id": job_id, "cancel_requested": True}) - - -def default_asset_form(directory: str) -> dict[str, str]: - """默认表单值。""" - - return { - "directory": directory, - "version": default_xray_version(), - "archive_url": "", - "geoip_url": "", - "geosite_url": "", - "proxy_url": "", - "target": "all", - "force": "", - } - - -def asset_form_from_settings(settings: XrayAssetSettings) -> dict[str, str]: - """把持久化设置转换为 HTML 表单值。""" - - values = settings.to_dict() - return {key: ("on" if value is True else "" if value is False else str(value)) for key, value in values.items()} - - -def _form_values() -> dict[str, str]: - """从 HTML 表单读取参数。""" - - defaults = default_asset_form("data/xray") - return {key: request.form.get(key, defaults[key]).strip() for key in defaults} - - -def _settings_from_form(form: dict[str, str]) -> XrayAssetSettings: - return XrayAssetSettings( - directory=form["directory"], - version=form["version"] or default_xray_version(), - archive_url=form["archive_url"], - geoip_url=form["geoip_url"], - geosite_url=form["geosite_url"], - proxy_url=form["proxy_url"], - target=form["target"] or "all", - force=form["force"] == "on", - ) - - -def _run_asset_job(job: Job, form: dict[str, str]) -> None: - """后台执行 Xray 资源补齐,并记录可轮询的步骤状态。""" - - def add_step(name: str, state: str, detail: str = "") -> None: - job["steps"].append({"name": name, "state": state, "detail": detail}) - - def check_cancelled() -> None: - if job.get("cancel_requested"): - raise DownloadCancelled() - - def tracked_download(url: str) -> bytes: - check_cancelled() - step = { - "name": "下载资源", - "state": "running", - "detail": url, - "url": url, - "received": 0, - "total": None, - "percent": None, - } - job["steps"].append(step) - - def progress(progress_url: str, received: int, total: int | None) -> None: - check_cancelled() - percent = int(received * 100 / total) if total else None - step.update( - { - "detail": _download_detail(progress_url, received, total), - "received": received, - "total": total, - "percent": percent, - } - ) - - data = download_bytes_stream(url, progress, proxy_url=form["proxy_url"] or None) - step.update( - { - "state": "done", - "detail": _download_detail(url, len(data), len(data)), - "received": len(data), - "total": len(data), - "percent": 100, - } - ) - return data - - try: - check_cancelled() - before = check_xray_assets(form["directory"]) - missing = ", ".join(before.missing) if before.missing else "无缺失文件" - add_step("检查本地文件", "done", missing) - - assets = ensure_xray_assets( - form["directory"], - version=form["version"], - archive_url=form["archive_url"] or None, - geoip_url=form["geoip_url"] or None, - geosite_url=form["geosite_url"] or None, - proxy_url=form["proxy_url"] or None, - target=form["target"] or "all", - force=form["force"] == "on", - downloader=tracked_download, - ) - downloaded = ", ".join(assets.downloaded) if assets.downloaded else "无需下载,文件已存在" - skipped = ", ".join(assets.skipped) if assets.skipped else "无" - add_step("解压 / 写入文件", "done", downloaded) - check_cancelled() - add_step("已有文件跳过", "done", skipped) - add_step("校验目标文件", "done", f"本次目标:{form['target'] or 'all'}") - if assets.xray.exists(): - add_step("设置执行权限", "done", str(assets.xray)) - - status = check_xray_assets(form["directory"]) - job["status"] = { - "directory": str(status.directory), - "files": status.files, - "ready": status.ready, - "missing": status.missing, - } - if job.get("state") != "cancelled": - job["state"] = "done" - except DownloadCancelled: - if not any(step.get("name") == "任务已停止" for step in job["steps"]): - add_step("任务已停止", "bad", "用户已请求停止下载任务") - job["error"] = "cancelled" - job["state"] = "cancelled" - except Exception as exc: # noqa: BLE001 - add_step("执行失败", "bad", str(exc)) - job["error"] = str(exc) - job["state"] = "bad" - - -class DownloadCancelled(Exception): - """用户请求取消下载任务。""" - - -def _download_detail(url: str, received: int, total: int | None) -> str: - """格式化下载进度文案。""" - - if total: - return f"{url} - {_format_bytes(received)} / {_format_bytes(total)}" - return f"{url} - {_format_bytes(received)}" - - -def _format_bytes(value: int) -> str: - """把字节数格式化为短文本。""" - - units = ("B", "KB", "MB", "GB") - size = float(value) - for unit in units: - if size < 1024 or unit == units[-1]: - return f"{size:.1f} {unit}" if unit != "B" else f"{value} B" - size /= 1024 - return f"{value} B" diff --git a/pyxray/web/xray_config.py b/pyxray/web/xray_config.py deleted file mode 100644 index 466f1fb..0000000 --- a/pyxray/web/xray_config.py +++ /dev/null @@ -1,257 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import tomlkit -from flask import Blueprint, Flask, current_app, jsonify, request - -from pyxray.libs.xray_config import ( - XrayConfigSettings, - XrayConfigSettingsStore, - generate_xray_config, - write_tinytun_config_file, - write_transparent_rule_files, -) -from pyxray.libs.xray_config.settings import ( - CustomInboundSettings, - DnsRuleSettings, - OutboundSetting, -) -from pyxray.libs.xray_config.store import dump_settings_toml -from pyxray.web.activity_log import log_activity -from pyxray.web.nodes import get_node_manager - - -blueprint = Blueprint("xray_config", __name__, url_prefix="/api/xray/config") - - -def register_xray_config(app: Flask, settings_path: str | Path, config_path: str | Path) -> None: - """注册 Xray 配置生成 API。""" - - app.config["XRAY_SETTINGS_PATH"] = str(settings_path) - app.config["XRAY_CONFIG_PATH"] = str(config_path) - app.register_blueprint(blueprint) - - -def get_settings_store(app: Flask) -> XrayConfigSettingsStore: - """创建设置存储器。""" - - return XrayConfigSettingsStore(app.config["XRAY_SETTINGS_PATH"]) - - -@blueprint.get("") -def get_config_state_api(): # noqa: ANN202 - """返回当前设置、选中节点和已生成配置。""" - - store = get_settings_store(current_app) - selected = get_node_manager(current_app).get_selected_node() - config_path = Path(current_app.config["XRAY_CONFIG_PATH"]) - generated = config_path.read_text(encoding="utf-8") if config_path.exists() else "" - return jsonify( - { - "settings_toml": dump_settings_toml(store.load()), - "selected_node": selected.to_dict() if selected is not None else None, - "config_path": str(config_path), - "generated_config": generated, - } - ) - - -@blueprint.post("/settings") -def save_settings_api(): # noqa: ANN202 - """保存配置生成设置。""" - - try: - settings = _settings_from_request() - get_settings_store(current_app).save(settings) - except Exception as exc: # noqa: BLE001 - return jsonify({"error": str(exc)}), 400 - log_activity(current_app, "Xray settings saved") - try: - from pyxray.web.xray_service import restart_xray_service_if_running - - restart_status = restart_xray_service_if_running(current_app, reason="settings saved") - except Exception as exc: # noqa: BLE001 - return jsonify({"error": str(exc), "settings_toml": dump_settings_toml(settings)}), 400 - return jsonify({"settings_toml": dump_settings_toml(settings), "service": restart_status}) - - -@blueprint.post("/generate") -def generate_config_api(): # noqa: ANN202 - """使用当前选中节点生成 Xray JSON 配置。""" - - try: - generated = generate_current_xray_config(current_app) - except ValueError as exc: - return jsonify({"error": str(exc)}), 400 - log_activity(current_app, f"Xray config generated: {generated['config_path']}") - return jsonify(generated) - - -def generate_current_xray_config(app: Flask) -> dict: - """使用当前选中节点和保存设置生成 Xray JSON 及配套文件。""" - - manager = get_node_manager(app) - node = manager.get_selected_node() - if node is None: - raise ValueError("未选择节点,无法生成配置") - - settings = get_settings_store(app).load() - config = generate_xray_config(node, settings) - content = json.dumps(config, ensure_ascii=False, indent=2) - config_path = Path(app.config["XRAY_CONFIG_PATH"]) - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text(content + "\n", encoding="utf-8") - transparent_dir = config_path.parent / "transparent" - geoip_file = Path(app.config["DEFAULT_XRAY_DIR"]) / "geoip.dat" - transparent_files = write_transparent_rule_files(settings, transparent_dir, geoip_file=geoip_file) - geosite_file = Path(app.config["DEFAULT_XRAY_DIR"]) / "geosite.dat" - tinytun_path = write_tinytun_config_file(node, settings, transparent_dir, geosite_file=str(geosite_file)) - return { - "config_path": str(config_path), - "config": config, - "generated_config": content, - "transparent_rule_paths": { - "ip_forward": str(transparent_files.ip_forward), - "resolv_setup": str(transparent_files.resolv_setup), - "resolv_cleanup": str(transparent_files.resolv_cleanup), - "iptables_setup": str(transparent_files.iptables_setup), - "iptables_cleanup": str(transparent_files.iptables_cleanup), - "nft_setup": str(transparent_files.nft_setup), - "nft_cleanup": str(transparent_files.nft_cleanup), - "nftables": str(transparent_files.nftables) if transparent_files.nftables else None, - "tinytun": str(tinytun_path) if tinytun_path else None, - }, - } - - -def _settings_from_request() -> XrayConfigSettings: - """从表单控件或旧版 TOML 输入恢复设置。""" - - raw = request.form.get("settings_toml") - if raw is not None: - return XrayConfigSettings.from_dict(dict(tomlkit.parse(raw))) - - form = request.form - settings = XrayConfigSettings() - - settings.core.log_level = form.get("core.log_level", settings.core.log_level) - settings.core.tcp_fast_open = form.get("core.tcp_fast_open", settings.core.tcp_fast_open) - settings.core.mux_concurrency = _int("core.mux_concurrency", settings.core.mux_concurrency) - settings.core.mux_enabled = settings.core.mux_concurrency > 0 - if settings.core.mux_concurrency == 0: - settings.core.mux_concurrency = 8 - - settings.inbounds.listen = form.get("inbounds.listen", settings.inbounds.listen) - settings.inbounds.port_sharing = False - settings.inbounds.socks_port = _int("inbounds.socks_port", settings.inbounds.socks_port) - settings.inbounds.http_port = _int("inbounds.http_port", settings.inbounds.http_port) - settings.inbounds.rule_socks_port = _int("inbounds.rule_socks_port", settings.inbounds.rule_socks_port) - settings.inbounds.rule_http_port = _int("inbounds.rule_http_port", settings.inbounds.rule_http_port) - settings.inbounds.auth_user = form.get("inbounds.auth_user", settings.inbounds.auth_user).strip() - settings.inbounds.auth_password = form.get("inbounds.auth_password", settings.inbounds.auth_password).strip() - settings.inbounds.vmess_port = _int("inbounds.vmess_port", settings.inbounds.vmess_port) - settings.inbounds.inbound_sniffing = form.get("inbounds.inbound_sniffing", settings.inbounds.inbound_sniffing) - settings.inbounds.route_only = _enabled("inbounds.route_only") - settings.inbounds.domains_excluded = form.get("inbounds.domains_excluded", settings.inbounds.domains_excluded) - settings.inbounds.api.port = _int("inbounds.api.port", settings.inbounds.api.port) - settings.inbounds.api.services = _lines("inbounds.api.services") or settings.inbounds.api.services - settings.inbounds.custom = [ - CustomInboundSettings(tag=parts[0], protocol=parts[1], port=int(parts[2])) - for parts in _split_table("inbounds.custom", 3) - ] - - settings.routing.mode = form.get("routing.mode", settings.routing.mode) - settings.routing.default_rule = "proxy" - settings.routing.routing_a = form.get("routing.routing_a", settings.routing.routing_a) - - settings.transparent.mode = form.get("transparent.mode", settings.transparent.mode) - settings.transparent.type = form.get("transparent.type", settings.transparent.type) - settings.transparent.port = _int("transparent.port", settings.transparent.port) - settings.transparent.socks_port = _int("transparent.socks_port", settings.transparent.socks_port) - settings.transparent.ipforward = _enabled("transparent.ipforward") - settings.transparent.docker_transparent = _enabled_default( - "transparent.docker_transparent", - settings.transparent.docker_transparent, - ) - settings.transparent.docker_transparent_cidrs = form.get( - "transparent.docker_transparent_cidrs", - settings.transparent.docker_transparent_cidrs, - ) - settings.transparent.tproxy_excluded_interfaces = form.get( - "transparent.tproxy_excluded_interfaces", - settings.transparent.tproxy_excluded_interfaces, - ) - settings.transparent.output_bypass_rules = form.get( - "transparent.output_bypass_rules", - settings.transparent.output_bypass_rules, - ) - settings.transparent.tproxy_white_country_codes = _lines("transparent.tproxy_white_country_codes") - settings.transparent.tproxy_white_custom_ips = _lines("transparent.tproxy_white_custom_ips") - settings.transparent.tun_bypass_interfaces = form.get("transparent.tun_bypass_interfaces", settings.transparent.tun_bypass_interfaces) - settings.transparent.tun_auto_route = _enabled("transparent.tun_auto_route") - settings.transparent.tun_route_shell_type = form.get("transparent.tun_route_shell_type", settings.transparent.tun_route_shell_type) - settings.transparent.tun_route_shell_path = form.get("transparent.tun_route_shell_path", settings.transparent.tun_route_shell_path) - settings.transparent.tun_setup_script = form.get("transparent.tun_setup_script", settings.transparent.tun_setup_script) - settings.transparent.tun_teardown_script = form.get("transparent.tun_teardown_script", settings.transparent.tun_teardown_script) - settings.transparent.tun_process_backend = form.get("transparent.tun_process_backend", settings.transparent.tun_process_backend) - settings.transparent.tun_exclude_processes = form.get("transparent.tun_exclude_processes", settings.transparent.tun_exclude_processes) - - settings.dns.query_strategy = form.get("dns.query_strategy", settings.dns.query_strategy) - settings.dns.disable_fallback = _bool("dns.disable_fallback") - settings.dns.local_dns_listen = _bool("dns.local_dns_listen") - settings.dns.antipollution = form.get("dns.antipollution", settings.dns.antipollution) - settings.dns.special_mode = form.get("dns.special_mode", settings.dns.special_mode) - settings.dns.fakedns_domains = form.get("dns.fakedns_domains", settings.dns.fakedns_domains) - settings.dns.rules = [ - DnsRuleSettings(server=parts[0], domains=parts[1], outbound=parts[2]) - for parts in _split_table("dns.rules", 3) - ] or settings.dns.rules - - settings.outbounds = [OutboundSetting()] - - settings.auto_update.gfwlist_auto_update_mode = "none" - settings.auto_update.gfwlist_auto_update_interval_hour = 0 - settings.auto_update.subscription_auto_update_mode = "none" - settings.auto_update.subscription_auto_update_interval_hour = 0 - settings.auto_update.proxy_mode_when_subscribe = "direct" - return settings - - -def _bool(name: str) -> bool: - return request.form.get(name) == "on" - - -def _enabled(name: str) -> bool: - return request.form.get(name) in {"on", "true", "yes", "1"} - - -def _enabled_default(name: str, default: bool) -> bool: - raw = request.form.get(name) - if raw is None: - return default - return raw in {"on", "true", "yes", "1"} - - -def _int(name: str, default: int) -> int: - raw = request.form.get(name, "").strip() - return default if raw == "" else int(raw) - - -def _lines(name: str) -> list[str]: - raw = request.form.get(name, "") - normalized = raw.replace(",", "\n") - return [line.strip() for line in normalized.splitlines() if line.strip()] - - -def _split_table(name: str, columns: int) -> list[list[str]]: - rows: list[list[str]] = [] - for line in request.form.get(name, "").splitlines(): - if not line.strip(): - continue - parts = [part.strip() for part in line.split("|")] - if len(parts) != columns: - raise ValueError(f"{name} 每行需要 {columns} 列,使用 | 分隔") - rows.append(parts) - return rows diff --git a/pyxray/web/xray_service.py b/pyxray/web/xray_service.py deleted file mode 100644 index 91a92e5..0000000 --- a/pyxray/web/xray_service.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from flask import Blueprint, Flask, current_app, jsonify, request - -from pyxray.libs.xray_runtime import XrayServiceManager, compact_xray_log, log_file_size, read_log_since, read_log_tail -from pyxray.libs.xray_transparent_runtime import TransparentRuntime -from pyxray.web.activity_log import log_activity -from pyxray.web.xray_assets import get_asset_settings_store -from pyxray.web.xray_config import generate_current_xray_config, get_settings_store - - -blueprint = Blueprint("xray_service", __name__, url_prefix="/api/xray/service") - - -def register_xray_service(app: Flask, *, xray_dir: str | Path, config_path: str | Path, log_path: str | Path) -> None: - """注册 Xray 运行控制 API。""" - - app.config["XRAY_LOG_PATH"] = str(log_path) - app.config["XRAY_SERVICE_STATE_PATH"] = str(Path(config_path).parent / "service-state.json") - app.extensions["pyxray_transparent_runtime"] = TransparentRuntime( - transparent_dir=Path(config_path).parent / "transparent", - log_path=log_path, - ) - app.extensions["pyxray_xray_service"] = XrayServiceManager( - xray_dir=xray_dir, - config_path=config_path, - log_path=log_path, - preferred_xray_dir=lambda: get_asset_settings_store(app).load().directory, - before_stop=lambda: get_transparent_runtime(app).cleanup(best_effort=True), - ) - app.register_blueprint(blueprint) - restore_xray_service(app) - - -def get_xray_service(app: Flask) -> XrayServiceManager: - return app.extensions["pyxray_xray_service"] - - -def get_transparent_runtime(app: Flask) -> TransparentRuntime: - return app.extensions["pyxray_transparent_runtime"] - - -@blueprint.get("") -def status_api(): # noqa: ANN202 - return jsonify(get_xray_service(current_app).status()) - - -@blueprint.post("/start") -def start_api(): # noqa: ANN202 - try: - log_activity(current_app, "Xray start requested") - status = start_xray_service(current_app) - save_service_state(current_app, desired_running=True) - log_activity(current_app, f"Xray started: pid={status['pid']}") - return jsonify(status) - except Exception as exc: # noqa: BLE001 - log_activity(current_app, f"Xray start failed: {exc}") - return jsonify({"error": str(exc), "status": get_xray_service(current_app).status()}), 400 - - -@blueprint.post("/stop") -def stop_api(): # noqa: ANN202 - log_activity(current_app, "Xray stop requested") - status = get_xray_service(current_app).stop() - save_service_state(current_app, desired_running=False) - log_activity(current_app, "Xray stopped") - return jsonify(status) - - -@blueprint.get("/logs") -def logs_api(): # noqa: ANN202 - path = current_app.config["XRAY_LOG_PATH"] - offset = request.args.get("offset") - compact = request.args.get("format") == "compact" - if offset == "end": - size = log_file_size(path) - return jsonify({"path": path, "content": "", "offset": size}) - if offset is not None: - content, size = read_log_since(path, int(offset or 0)) - if compact: - content = compact_xray_log(content) - return jsonify({"path": path, "content": content, "offset": size}) - content = read_log_tail(path) - if compact: - content = compact_xray_log(content) - return jsonify({"path": path, "content": content, "offset": log_file_size(path)}) - - -@blueprint.delete("/logs") -def clear_logs_api(): # noqa: ANN202 - path = Path(current_app.config["XRAY_LOG_PATH"]) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("", encoding="utf-8") - return jsonify({"path": str(path), "content": "", "offset": 0}) - - -def start_xray_service(app: Flask) -> dict[str, Any]: - """生成当前配置并启动 Xray 及透明代理规则。""" - - generate_current_xray_config(app) - settings = get_settings_store(app).load() - service = get_xray_service(app) - was_running = service.status()["running"] - status = service.start() - if not was_running: - try: - get_transparent_runtime(app).setup(settings) - except Exception: - service.stop() - raise - return status - - -def restart_xray_service_if_running(app: Flask, *, reason: str) -> dict[str, Any] | None: - """Restart Xray after a config-affecting change when it is currently running.""" - - service = get_xray_service(app) - if not service.status()["running"]: - return None - log_activity(app, f"Xray restart requested: {reason}") - service.stop() - try: - status = start_xray_service(app) - except Exception as exc: - log_activity(app, f"Xray restart failed: {reason}: {exc}") - raise - save_service_state(app, desired_running=True) - log_activity(app, f"Xray restarted: pid={status['pid']}") - return status - - -def restore_xray_service(app: Flask) -> None: - """应用启动时按上次用户期望恢复 Xray 运行状态。""" - - if not load_service_state(app).get("desired_running", False): - return - try: - log_activity(app, "Xray restore requested") - start_xray_service(app) - _append_service_message(app, "restored desired running state") - log_activity(app, "Xray restored") - except Exception as exc: # noqa: BLE001 - _append_service_message(app, f"failed to restore desired running state: {exc}") - log_activity(app, f"Xray restore failed: {exc}") - - -def load_service_state(app: Flask) -> dict[str, Any]: - path = Path(app.config["XRAY_SERVICE_STATE_PATH"]) - if not path.exists(): - return {"desired_running": False} - try: - raw = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return {"desired_running": False} - return {"desired_running": bool(raw.get("desired_running", False))} - - -def save_service_state(app: Flask, *, desired_running: bool) -> None: - path = Path(app.config["XRAY_SERVICE_STATE_PATH"]) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"desired_running": desired_running}, indent=2) + "\n", encoding="utf-8") - - -def _append_service_message(app: Flask, message: str) -> None: - service = get_xray_service(app) - service.log_message(message) diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index c611d93..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/sh -set -eu - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) - -IMAGE_NAME=${IMAGE_NAME:-pyxray} -APT_MIRROR=${APT_MIRROR:-https://mirrors.ustc.edu.cn/debian} -UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.mirrors.ustc.edu.cn/simple/} - -cd "$PROJECT_DIR" - -PYTHON_BIN=${PYTHON_BIN:-} -if [ -z "$PYTHON_BIN" ]; then - if command -v python3 >/dev/null 2>&1; then - PYTHON_BIN=python3 - else - PYTHON_BIN=python - fi -fi - -IMAGE_VERSION=${IMAGE_VERSION:-$("$PYTHON_BIN" -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')} - -docker build \ - --build-arg "APT_MIRROR=$APT_MIRROR" \ - --build-arg "UV_INDEX_URL=$UV_INDEX_URL" \ - -t "$IMAGE_NAME:latest" \ - -t "$IMAGE_NAME:$IMAGE_VERSION" \ - . diff --git a/tests/libs/test_node_manager.py b/tests/libs/test_node_manager.py deleted file mode 100644 index cd7f9eb..0000000 --- a/tests/libs/test_node_manager.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import base64 - -import pytest - -from pyxray.libs.nodes import NodeManager, NodeStore, parse_node_link - - -def test_node_store_saves_nodes_and_selected_id_as_toml(tmp_path) -> None: - manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) - result = manager.add_link(_ss_link("secret", "ss-node")) - - selected = manager.select_node(result.node.id) - reloaded = NodeManager(NodeStore(tmp_path / "nodes.toml")) - - assert selected.id == result.node.id - assert reloaded.selected_id() == result.node.id - assert reloaded.get_selected_node() is not None - assert reloaded.get_selected_node().name == "ss-node" - assert "selected_id" in (tmp_path / "nodes.toml").read_text(encoding="utf-8") - assert "[[nodes]]" in (tmp_path / "nodes.toml").read_text(encoding="utf-8") - - -def test_node_manager_updates_existing_node_by_id_and_keeps_created_at(tmp_path) -> None: - manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) - node = parse_node_link(_ss_link("secret", "first-name")) - created = manager.add_node(node) - - same_node = parse_node_link(_ss_link("secret", "second-name")) - updated = manager.add_node(same_node) - nodes = manager.list_nodes() - - assert created.created is True - assert updated.created is False - assert len(nodes) == 1 - assert nodes[0].name == "second-name" - assert nodes[0].created_at == created.node.created_at - assert nodes[0].updated_at >= nodes[0].created_at - - -def test_node_manager_removes_selected_node_and_clears_selection(tmp_path) -> None: - manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) - result = manager.add_link(_ss_link("secret", "ss-node")) - manager.select_node(result.node.id) - - assert manager.remove_node(result.node.id) is True - - assert manager.list_nodes() == [] - assert manager.selected_id() == "" - assert manager.get_selected_node() is None - - -def test_node_manager_rejects_unknown_selection(tmp_path) -> None: - manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) - - with pytest.raises(ValueError, match="node does not exist"): - manager.select_node("missing") - - -def test_node_manager_imports_valid_links_and_returns_invalid_results(tmp_path) -> None: - manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) - results = manager.import_links(f"bad://example\n{_ss_link('secret', 'ss-node')}\n") - - assert [result.ok for result in results] == [False, True] - assert len(manager.list_nodes()) == 1 - assert manager.list_nodes()[0].name == "ss-node" - - -def _ss_link(password: str, name: str) -> str: - user = base64.urlsafe_b64encode(f"chacha20-ietf-poly1305:{password}".encode()).decode().rstrip("=") - return f"ss://{user}@ss.example.net:8388#{name}" diff --git a/tests/libs/test_nodes.py b/tests/libs/test_nodes.py deleted file mode 100644 index 1978b32..0000000 --- a/tests/libs/test_nodes.py +++ /dev/null @@ -1,212 +0,0 @@ -from __future__ import annotations - -import base64 -import json - -from pyxray.libs.nodes import import_node_links, parse_node_link - - -def test_parse_vless_reality_link_normalizes_node() -> None: - node = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision" - "&sni=www.example.com&fp=chrome&pbk=public-key&sid=abcd&spx=%2F#hk" - ) - - assert node.protocol == "vless" - assert node.name == "hk" - assert node.server == "example.com" - assert node.port == 443 - assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000001" - assert node.settings["flow"] == "xtls-rprx-vision" - assert node.transport == { - "network": "tcp", - "header_type": "none", - "host": "", - "path": "", - } - assert node.security["type"] == "reality" - assert node.security["server_name"] == "www.example.com" - assert node.security["public_key"] == "public-key" - assert node.security["short_id"] == "abcd" - assert node.fingerprint - assert node.id.startswith("vless_") - assert node.canonical_link.startswith("vless://") - - -def test_parse_trojan_ws_tls_link_normalizes_node() -> None: - node = parse_node_link( - "trojan://secret@example.org:443" - "?type=ws&host=cdn.example.org&path=%2Fws&sni=edge.example.org&allowInsecure=1#trojan" - ) - - assert node.protocol == "trojan" - assert node.name == "trojan" - assert node.server == "example.org" - assert node.port == 443 - assert node.settings["password"] == "secret" - assert node.transport["network"] == "ws" - assert node.transport["path"] == "/ws" - assert node.transport["host"] == "cdn.example.org" - assert node.security["type"] == "tls" - assert node.security["server_name"] == "edge.example.org" - assert node.security["allow_insecure"] is True - - -def test_parse_vmess_base64_json_link_normalizes_node() -> None: - payload = { - "v": "2", - "ps": "vmess-node", - "add": "vmess.example.net", - "port": "443", - "id": "00000000-0000-4000-8000-000000000002", - "aid": "0", - "scy": "auto", - "net": "websocket", - "type": "none", - "host": "cdn.example.net", - "path": "/ray", - "tls": "tls", - "sni": "sni.example.net", - } - encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - - node = parse_node_link(f"vmess://{encoded}") - - assert node.protocol == "vmess" - assert node.name == "vmess-node" - assert node.server == "vmess.example.net" - assert node.port == 443 - assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000002" - assert node.transport["network"] == "ws" - assert node.security["type"] == "tls" - - -def test_parse_shadowsocks_sip002_link_normalizes_node() -> None: - user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") - - node = parse_node_link(f"ss://{user}@ss.example.net:8388#ss-node") - - assert node.protocol == "shadowsocks" - assert node.name == "ss-node" - assert node.server == "ss.example.net" - assert node.port == 8388 - assert node.settings["method"] == "chacha20-ietf-poly1305" - assert node.settings["password"] == "secret" - assert node.security["type"] == "none" - - -def test_import_node_links_parses_multiline_input_without_subscription_groups() -> None: - user = base64.urlsafe_b64encode(b"aes-128-gcm:secret").decode().rstrip("=") - results = import_node_links( - "\n" - "# comment\n" - "bad://example\n" - f"ss://{user}@ss.example.net:8388#ss-node\n" - ) - - assert len(results) == 2 - assert results[0].ok is False - assert results[0].error == "unsupported node link scheme: bad" - assert results[1].ok is True - assert results[1].node is not None - assert results[1].node.protocol == "shadowsocks" - - -def test_parse_trojan_go_keeps_protocol_and_specific_fields() -> None: - node = parse_node_link( - "trojan-go://secret@example.org:443" - "?type=ws&host=cdn.example.org&path=%2Fgo&sni=edge.example.org" - "&encryption=ss%3Baes-128-gcm%3Asecret&allowInsecure=1#tg" - ) - - assert node.protocol == "trojan-go" - assert node.settings["password"] == "secret" - assert node.settings["encryption"] == "ss;aes-128-gcm:secret" - assert node.transport["network"] == "ws" - assert node.transport["host"] == "cdn.example.org" - assert node.security["allow_insecure"] is False - assert node.canonical_link.startswith("trojan-go://") - - -def test_parse_vless_xhttp_ws_and_grpc_extension_fields() -> None: - xhttp = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=xhttp&security=tls&sni=www.example.com#xhttp" - ) - ws = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=ws&security=tls&maxEarlyData=2048&earlyDataHeaderName=Sec-WebSocket-Protocol#ws" - ) - grpc = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=grpc&security=tls&serviceName=svc&multiMode=true&idleTimeout=60" - "&healthCheckTimeout=20&permitWithoutStream=true&initialWindowsSize=65535#grpc" - ) - - assert xhttp.transport["network"] == "xhttp" - assert xhttp.transport["xhttp_mode"] == "auto" - assert ws.transport["max_early_data"] == "2048" - assert ws.transport["early_data_header_name"] == "Sec-WebSocket-Protocol" - assert grpc.transport["service_name"] == "svc" - assert grpc.transport["multi_mode"] == "true" - assert grpc.transport["idle_timeout"] == "60" - assert grpc.transport["health_check_timeout"] == "20" - assert grpc.transport["permit_without_stream"] == "true" - assert grpc.transport["initial_windows_size"] == "65535" - - -def test_parse_vmess_legacy_link() -> None: - payload = base64.urlsafe_b64encode(b"auto:00000000-0000-4000-8000-000000000003@legacy.example:443").decode() - - node = parse_node_link( - f"vmess://{payload}?remarks=legacy&obfs=websocket&obfsParam=cdn.example" - "&path=%2Fray&alterId=0&tls=1&sni=sni.example" - ) - - assert node.protocol == "vmess" - assert node.name == "legacy" - assert node.server == "legacy.example" - assert node.port == 443 - assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000003" - assert node.transport["network"] == "ws" - assert node.transport["host"] == "cdn.example" - assert node.transport["path"] == "/ray" - assert node.security["type"] == "tls" - assert node.security["server_name"] == "sni.example" - - -def test_parse_vmess_moves_path_from_host_when_needed() -> None: - payload = { - "ps": "host-path", - "add": "vmess.example.net", - "port": "443", - "id": "00000000-0000-4000-8000-000000000004", - "host": "/ray", - "net": "ws", - } - encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - - node = parse_node_link(f"vmess://{encoded}") - - assert node.transport["host"] == "" - assert node.transport["path"] == "/ray" - - -def test_parse_shadowsocks_plugin_as_structured_sip003_options() -> None: - user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") - - node = parse_node_link( - f"ss://{user}@ss.example.net:8388?plugin=simpleobfs%3Bobfs%3Dhttp%3Bobfs-host%3Dcdn.example" - "%3Bobfs-uri%3Dws#ss-plugin" - ) - - assert node.settings["plugin_options"] == { - "name": "simple-obfs", - "raw": "simpleobfs;obfs=http;obfs-host=cdn.example;obfs-uri=ws", - "tls": "", - "obfs": "http", - "host": "cdn.example", - "path": "/ws", - "impl": "", - } diff --git a/tests/libs/test_tinytun_config.py b/tests/libs/test_tinytun_config.py deleted file mode 100644 index 95ecbc6..0000000 --- a/tests/libs/test_tinytun_config.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from pyxray.libs.nodes import parse_node_link -from pyxray.libs.xray_config import XrayConfigSettings, generate_tinytun_config, write_tinytun_config_file - - -def test_generate_tinytun_config_matches_v2raya_defaults() -> None: - settings = XrayConfigSettings() - node = parse_node_link(_ss_link("198.51.100.10")) - - config = generate_tinytun_config(node, settings, geosite_file="/data/geosite.dat") - - assert config["tun"] == { - "name": "tun0", - "ip": "198.18.0.1", - "netmask": "255.255.255.255", - "ipv6_mode": "auto", - "ipv6": "fd00::1", - "ipv6_prefix": 128, - "auto_route": True, - "mtu": 1500, - } - assert config["socks5"] == {"name": "proxy", "address": "127.0.0.1:52345"} - assert "198.51.100.10" in config["filtering"]["skip_ips"] - assert config["filtering"]["skip_networks"][:5] == ["127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] - assert config["filtering"]["block_ports"] == [22, 23, 25, 110, 143] - assert config["filtering"]["allow_ports"] == [80, 443, 53] - assert config["route"] == {"auto_detect_interface": True} - - -def test_generate_tinytun_dns_groups_and_routing_from_dns_rules() -> None: - settings = XrayConfigSettings() - node = parse_node_link(_ss_link("ss.example.net")) - - config = generate_tinytun_config(node, settings, geosite_file="/data/geosite.dat") - dns = config["dns"] - - assert dns["groups"][0] == {"name": "direct", "servers": ["223.5.5.5:53"], "strategy": "concurrent", "upstream": "direct", "protocol": "udp"} - assert dns["groups"][1] == {"name": "proxy", "servers": ["8.8.8.8:53"], "strategy": "concurrent", "upstream": "proxy", "protocol": "udp"} - assert "match(geosite:private),direct" in dns["routing"]["rules"] - assert "match(geosite:cn),direct" in dns["routing"]["rules"] - assert dns["routing"]["fallback_group"] == "proxy" - assert dns["routing"]["geosite_file"] == "/data/geosite.dat" - assert dns["routing"]["enable_cache"] is True - assert dns["routing"]["cache_capacity"] == 4096 - assert dns["hijack"] == {"enabled": False, "mark": 1, "table_id": 100, "capture_tcp": True} - - -def test_write_tinytun_config_file_only_for_tun_mode(tmp_path) -> None: - settings = XrayConfigSettings() - node = parse_node_link(_ss_link("198.51.100.10")) - - assert write_tinytun_config_file(node, settings, tmp_path) is None - - settings.transparent.mode = "proxy" - settings.transparent.type = "tun" - settings.transparent.tun_auto_route = False - settings.transparent.tun_bypass_interfaces = "172.17.0.0/16" - settings.transparent.tun_exclude_processes = "/usr/bin/xray, pyxray" - path = write_tinytun_config_file(node, settings, tmp_path, geosite_file="/data/geosite.dat") - content = path.read_text(encoding="utf-8") - - assert path.name == "tinytun.yaml" - assert "auto_route: false" in content - assert "- 172.17.0.0/16" in content - assert "- xray" in content - assert "- pyxray" in content - - -def _ss_link(host: str) -> str: - return f"ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpzZWNyZXQ@{host}:8388#ss-node" diff --git a/tests/libs/test_transparent_rules.py b/tests/libs/test_transparent_rules.py deleted file mode 100644 index 0f9f856..0000000 --- a/tests/libs/test_transparent_rules.py +++ /dev/null @@ -1,305 +0,0 @@ -from __future__ import annotations - -import ipaddress - -from pyxray.libs.xray_config import XrayConfigSettings, generate_transparent_rules -from pyxray.libs.xray_config.transparent_rules import write_transparent_rule_files - - -def test_redirect_iptables_rules_match_v2raya_shape() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.port = 52345 - settings.transparent.docker_transparent = False - - rules = generate_transparent_rules(settings, backend="iptables") - - assert rules.mode == "redirect" - assert "iptables -w 2 -t nat -N TP_OUT" in rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -d 10.0.0.0/8 -j RETURN" in rules.setup - assert "ip -o -4 addr show" in rules.setup - assert 'iptables -w 2 -t nat -A TP_RULE -d "$cidr" -j RETURN' in rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -i docker+ -j RETURN" in rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports 52345" in rules.setup - assert "iptables -w 2 -t nat -I PREROUTING -p tcp -j TP_PRE" in rules.setup - assert "iptables -w 2 -t nat -D OUTPUT -p tcp -j TP_OUT" in rules.cleanup - - -def test_tproxy_iptables_rules_match_v2raya_shape() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - settings.transparent.port = 52345 - settings.transparent.docker_transparent = False - settings.transparent.tproxy_white_custom_ips = ["203.0.113.88/32"] - - rules = generate_transparent_rules(settings, backend="iptables") - - assert rules.mode == "tproxy" - assert "ip rule add fwmark 0x40/0xc0 table 100" in rules.setup - assert "ip route add local 0.0.0.0/0 dev lo table 100" in rules.setup - assert "iptables -w 2 -t mangle -N TP_MARK" in rules.setup - assert "iptables -w 2 -t mangle -A TP_RULE -p udp --dport 53 -j TP_MARK" in rules.setup - assert "ip -o -4 addr show" in rules.setup - assert 'iptables -w 2 -t mangle -A TP_RULE -d "$cidr" -j RETURN' in rules.setup - assert "iptables -w 2 -t mangle -A TP_RULE -d 10.0.0.0/8 -j RETURN" in rules.setup - assert "iptables -w 2 -t mangle -A TP_RULE -d 172.16.0.0/12 -j RETURN" in rules.setup - assert "iptables -w 2 -t mangle -A TP_RULE -d 203.0.113.88/32 -j RETURN" in rules.setup - assert "iptables -w 2 -t mangle -A TP_PRE -p udp -m mark --mark 0x40/0xc0 -j TPROXY --on-port 52345 --on-ip 127.0.0.1" in rules.setup - assert "iptables -w 2 -t mangle -F TP_MARK" in rules.cleanup - - -def test_cleanup_scripts_are_best_effort_like_v2raya() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - - redirect = generate_transparent_rules(settings, backend="iptables") - settings.transparent.type = "tproxy" - tproxy = generate_transparent_rules(settings, backend="iptables") - - assert "iptables -w 2 -t nat -F TP_OUT 2>/dev/null || true" in redirect.cleanup - assert "iptables -w 2 -t mangle -F TP_OUT 2>/dev/null || true" in tproxy.cleanup - assert "ip rule del fwmark 0x40/0xc0 table 100 2>/dev/null || true" in tproxy.cleanup - - -def test_redirect_nft_rules_include_table_and_loader_command() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = False - - rules = generate_transparent_rules(settings, backend="nft", nftables_path="/tmp/pyxray.nft") - - assert rules.setup == "nft -f /tmp/pyxray.nft" - assert rules.cleanup == "nft delete table inet v2raya" - assert "table inet v2raya" in rules.nftables - assert "set whitelist" in rules.nftables - assert "meta mark & 0x80 == 0x80 return" in rules.nftables - assert "iifname \"docker*\" return" in rules.nftables - assert "meta l4proto tcp redirect to :52345" in rules.nftables - - -def test_tproxy_nft_rules_return_reserved_destinations() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - - rules = generate_transparent_rules(settings, backend="nft") - - assert "set whitelist" in rules.nftables - assert "10.0.0.0/8" in rules.nftables - assert "172.16.0.0/12" in rules.nftables - assert "ip daddr @whitelist return" in rules.nftables - - -def test_tproxy_country_code_whitelist_reads_geoip_dat(tmp_path) -> None: # noqa: ANN001 - geoip = tmp_path / "geoip.dat" - geoip.write_bytes(_geoip_dat({"cn": ["1.2.3.0/24", "2001:db8::/32"], "private": ["198.18.0.0/15"]})) - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - settings.transparent.tproxy_white_country_codes = ["cn", "private"] - settings.transparent.tproxy_white_custom_ips = ["203.0.113.88/32"] - - iptables_rules = generate_transparent_rules(settings, backend="iptables", geoip_file=geoip) - nft_rules = generate_transparent_rules(settings, backend="nft", geoip_file=geoip) - - assert "iptables -w 2 -t mangle -A TP_RULE -d 1.2.3.0/24 -j RETURN" in iptables_rules.setup - assert "iptables -w 2 -t mangle -A TP_RULE -d 203.0.113.88/32 -j RETURN" in iptables_rules.setup - assert "198.18.0.0/15" not in iptables_rules.setup - assert "ip daddr 1.2.3.0/24 return" in nft_rules.nftables - - -def test_docker_transparent_removes_docker_interface_exclusions() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = True - settings.transparent.tproxy_excluded_interfaces = "docker*,veth*,wg*,ppp*,br-*" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t nat -A TP_RULE -i docker+ -j RETURN" not in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -i veth+ -j RETURN" not in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -i br-+ -j RETURN" not in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_RULE -i wg+ -j RETURN" in iptables_rules.setup - assert "iifname \"docker*\" return" not in nft_rules.nftables - assert "iifname \"veth*\" return" not in nft_rules.nftables - assert "iifname \"br-*\" return" not in nft_rules.nftables - assert "iifname \"wg*\" return" in nft_rules.nftables - - -def test_docker_transparent_keeps_configured_docker_cidrs_as_destination_returns() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = True - settings.transparent.docker_transparent_cidrs = "172.16.0.0/12" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t nat -A TP_RULE -d 172.16.0.0/12 -j RETURN" in iptables_rules.setup - assert "172.16.0.0/12" in nft_rules.nftables - - -def test_docker_transparent_limits_prerouting_to_configured_source_cidrs() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = True - settings.transparent.docker_transparent_cidrs = "172.16.0.0/12;172.30.250.0/24" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t nat -A TP_PRE -s 172.16.0.0/12 -j TP_RULE" in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_PRE -s 172.30.250.0/24 -j TP_RULE" in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_PRE -j TP_RULE" not in iptables_rules.setup - assert "ip saddr 172.16.0.0/12 meta l4proto tcp jump tp_rule" in nft_rules.nftables - assert "ip saddr 172.30.250.0/24 meta l4proto tcp jump tp_rule" in nft_rules.nftables - - -def test_docker_transparent_limits_tproxy_prerouting_to_configured_source_cidrs() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - settings.transparent.docker_transparent = True - settings.transparent.docker_transparent_cidrs = "172.16.0.0/12" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t mangle -A TP_PRE -s 172.16.0.0/12 -p tcp" in iptables_rules.setup - assert "iptables -w 2 -t mangle -A TP_PRE -s 172.16.0.0/12 -p udp" in iptables_rules.setup - assert "iptables -w 2 -t mangle -A TP_PRE -p tcp -m addrtype" not in iptables_rules.setup - assert "ip saddr 172.16.0.0/12 meta l4proto { tcp, udp }" in nft_rules.nftables - - -def test_redirect_output_bypass_rules_return_before_transparent_rule() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.output_bypass_rules = "tcp 117.72.47.28:33010\nall 192.168.0.0/24\nudp 198.51.100.10:3478" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN" in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_OUT -p tcp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup - assert "iptables -w 2 -t nat -A TP_OUT -p udp" not in iptables_rules.setup - assert iptables_rules.setup.index("-d 117.72.47.28 --dport 33010") < iptables_rules.setup.index("iptables -w 2 -t nat -A TP_OUT -j TP_RULE") - assert "ip daddr 117.72.47.28 meta l4proto tcp th dport 33010 return" in nft_rules.nftables - assert "meta l4proto udp" not in nft_rules.nftables - - -def test_tproxy_output_bypass_rules_support_udp_and_all() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - settings.transparent.output_bypass_rules = "udp 198.51.100.10:3478\nall 192.168.0.0/24" - - iptables_rules = generate_transparent_rules(settings, backend="iptables") - nft_rules = generate_transparent_rules(settings, backend="nft") - - assert "iptables -w 2 -t mangle -A TP_OUT -p udp -d 198.51.100.10 --dport 3478 -j RETURN" in iptables_rules.setup - assert "iptables -w 2 -t mangle -A TP_OUT -p tcp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup - assert "iptables -w 2 -t mangle -A TP_OUT -p udp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup - assert "ip daddr 198.51.100.10 meta l4proto udp th dport 3478 return" in nft_rules.nftables - - -def test_close_mode_has_no_system_rules() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "close" - - rules = generate_transparent_rules(settings) - - assert rules.setup == "" - assert rules.cleanup == "" - - -def test_write_transparent_rule_files_outputs_auditable_scripts(tmp_path) -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = False - - files = write_transparent_rule_files(settings, tmp_path) - - assert files.iptables_setup.read_text(encoding="utf-8").startswith("#!/bin/sh\nset -eu\n") - assert "printf '%s' 0 > /proc/sys/net/ipv4/ip_forward" in files.ip_forward.read_text(encoding="utf-8") - assert "nameserver 127.2.0.17" in files.resolv_setup.read_text(encoding="utf-8") - assert "REDIRECT --to-ports 52345" in files.iptables_setup.read_text(encoding="utf-8") - assert "nft -f" in files.nft_setup.read_text(encoding="utf-8") - assert files.nftables is not None - assert "table inet v2raya" in files.nftables.read_text(encoding="utf-8") - - -def test_ip_forward_script_follows_setting_like_v2raya(tmp_path) -> None: - settings = XrayConfigSettings() - settings.transparent.ipforward = True - - files = write_transparent_rule_files(settings, tmp_path) - content = files.ip_forward.read_text(encoding="utf-8") - - assert "printf '%s' 1 > /proc/sys/net/ipv4/ip_forward" in content - assert "printf '%s' 1 > /proc/sys/net/ipv6/conf/all/forwarding" in content - - -def test_docker_transparent_forces_ip_forward_script(tmp_path) -> None: - settings = XrayConfigSettings() - settings.transparent.ipforward = False - settings.transparent.docker_transparent = True - - files = write_transparent_rule_files(settings, tmp_path) - content = files.ip_forward.read_text(encoding="utf-8") - - assert "printf '%s' 1 > /proc/sys/net/ipv4/ip_forward" in content - - -def test_resolv_hijack_scripts_follow_v2raya_redirect_dns_behavior(tmp_path) -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - - files = write_transparent_rule_files(settings, tmp_path) - - assert "nameserver 127.2.0.17" in files.resolv_setup.read_text(encoding="utf-8") - assert "nameserver 119.29.29.29" in files.resolv_setup.read_text(encoding="utf-8") - assert "nameserver 223.6.6.6" in files.resolv_cleanup.read_text(encoding="utf-8") - - -def _geoip_dat(entries: dict[str, list[str]]) -> bytes: - return b"".join(_field_bytes(1, _geoip_entry(code, cidrs)) for code, cidrs in entries.items()) - - -def _geoip_entry(country_code: str, cidrs: list[str]) -> bytes: - content = _field_bytes(1, country_code.encode()) - for cidr in cidrs: - content += _field_bytes(2, _cidr(cidr)) - return content - - -def _cidr(cidr: str) -> bytes: - network = ipaddress.ip_network(cidr) - return _field_bytes(1, network.network_address.packed) + _field_varint(2, network.prefixlen) - - -def _field_bytes(field: int, value: bytes) -> bytes: - return _varint((field << 3) | 2) + _varint(len(value)) + value - - -def _field_varint(field: int, value: int) -> bytes: - return _varint(field << 3) + _varint(value) - - -def _varint(value: int) -> bytes: - out = bytearray() - while value >= 0x80: - out.append((value & 0x7F) | 0x80) - value >>= 7 - out.append(value) - return bytes(out) diff --git a/tests/libs/test_xray_assets.py b/tests/libs/test_xray_assets.py deleted file mode 100644 index 9ffe58a..0000000 --- a/tests/libs/test_xray_assets.py +++ /dev/null @@ -1,208 +0,0 @@ -from __future__ import annotations - -import zipfile -from io import BytesIO -from urllib.response import addinfourl - -import pytest - -from pyxray.libs.xray_assets import ( - DEFAULT_VERSION, - default_archive_name, - default_xray_version, - download_bytes, - download_bytes_stream, - ensure_xray_assets, - latest_xray_version, - official_archive_url, - required_files, -) - - -def _zip_bytes(files: dict[str, bytes]) -> bytes: - buffer = BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - for name, content in files.items(): - archive.writestr(name, content) - return buffer.getvalue() - - -def test_official_archive_url_defaults_to_xray_core_v26_5_9() -> None: - assert DEFAULT_VERSION == "v26.5.9" - assert official_archive_url(archive_name="Xray-linux-64.zip") == ( - "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-64.zip" - ) - - -def test_default_archive_name_is_platform_specific() -> None: - assert default_archive_name(os_name="posix", machine="x86_64") == "Xray-linux-64.zip" - assert default_archive_name(os_name="nt", machine="AMD64") == "Xray-windows-64.zip" - assert default_archive_name(os_name="nt", machine="ARM64") == "Xray-windows-arm64-v8a.zip" - - -def test_required_files_are_platform_specific() -> None: - assert required_files(os_name="posix") == ("xray", "geoip.dat", "geosite.dat") - assert required_files(os_name="nt") == ("xray.exe", "geoip.dat", "geosite.dat") - - -def test_latest_xray_version_reads_github_release_tag() -> None: - def fetcher(url: str, timeout: float) -> str: - assert url == "https://api.github.com/repos/XTLS/Xray-core/releases/latest" - assert timeout == 5.0 - return '{"tag_name": "v99.1.2"}' - - assert latest_xray_version(fetcher=fetcher) == "v99.1.2" - - -def test_default_xray_version_falls_back_to_pinned_version(monkeypatch) -> None: # noqa: ANN001 - monkeypatch.setattr("pyxray.libs.xray_assets._DEFAULT_VERSION_CACHE", None) - monkeypatch.setattr( - "pyxray.libs.xray_assets.latest_xray_version", - lambda *, timeout: (_ for _ in ()).throw(TimeoutError("slow")), - ) - - assert default_xray_version(timeout=5.0) == DEFAULT_VERSION - - -def test_ensure_xray_assets_extracts_official_archive_files(tmp_path) -> None: # noqa: ANN001 - xray_name = required_files()[0] - archive = _zip_bytes( - { - xray_name: b"bin", - "geoip.dat": b"geoip", - "geosite.dat": b"geosite", - "README.md": b"ignored", - } - ) - calls = [] - - def downloader(url: str) -> bytes: - calls.append(url) - return archive - - result = ensure_xray_assets(tmp_path, downloader=downloader) - - assert result.ready is True - assert result.downloaded == ("archive",) - assert calls == [official_archive_url()] - assert (tmp_path / xray_name).read_bytes() == b"bin" - assert (tmp_path / "geoip.dat").read_bytes() == b"geoip" - assert (tmp_path / "geosite.dat").read_bytes() == b"geosite" - - -def test_version_and_archive_url_can_be_overridden(tmp_path) -> None: # noqa: ANN001 - archive = _zip_bytes({required_files()[0]: b"bin", "geoip.dat": b"geoip", "geosite.dat": b"geosite"}) - calls = [] - - def downloader(url: str) -> bytes: - calls.append(url) - return archive - - ensure_xray_assets(tmp_path, version="v1.2.3", archive_url="https://mirror.invalid/xray.zip", downloader=downloader) - - assert calls == ["https://mirror.invalid/xray.zip"] - - -def test_dat_urls_override_archive_dat_files(tmp_path) -> None: # noqa: ANN001 - xray_name = required_files()[0] - archive = _zip_bytes({xray_name: b"bin", "geoip.dat": b"old-geoip", "geosite.dat": b"old-geosite"}) - payloads = { - official_archive_url(): archive, - "https://mirror.invalid/geoip.dat": b"new-geoip", - "https://mirror.invalid/geosite.dat": b"new-geosite", - } - - result = ensure_xray_assets( - tmp_path, - geoip_url="https://mirror.invalid/geoip.dat", - geosite_url="https://mirror.invalid/geosite.dat", - downloader=payloads.__getitem__, - ) - - assert result.downloaded == ("archive", "geoip.dat", "geosite.dat") - assert (tmp_path / xray_name).read_bytes() == b"bin" - assert (tmp_path / "geoip.dat").read_bytes() == b"new-geoip" - assert (tmp_path / "geosite.dat").read_bytes() == b"new-geosite" - - -def test_existing_files_skip_download(tmp_path) -> None: # noqa: ANN001 - xray_name = required_files()[0] - (tmp_path / xray_name).write_bytes(b"bin") - (tmp_path / "geoip.dat").write_bytes(b"geoip") - (tmp_path / "geosite.dat").write_bytes(b"geosite") - - result = ensure_xray_assets(tmp_path, downloader=lambda url: pytest.fail(f"unexpected download: {url}")) - - assert result.ready is True - assert result.downloaded == () - assert result.skipped == (xray_name, "geoip.dat", "geosite.dat") - - -def test_force_redownloads_selected_existing_file(tmp_path) -> None: # noqa: ANN001 - (tmp_path / "geoip.dat").write_bytes(b"old") - calls = [] - - def downloader(url: str) -> bytes: - calls.append(url) - return b"new" - - result = ensure_xray_assets( - tmp_path, - target="geoip", - force=True, - geoip_url="https://mirror.invalid/geoip.dat", - downloader=downloader, - ) - - assert result.downloaded == ("geoip.dat",) - assert result.skipped == () - assert calls == ["https://mirror.invalid/geoip.dat"] - assert (tmp_path / "geoip.dat").read_bytes() == b"new" - - -def test_missing_required_file_raises_after_bad_archive(tmp_path) -> None: # noqa: ANN001 - archive = _zip_bytes({required_files()[0]: b"bin", "geoip.dat": b"geoip"}) - - with pytest.raises(FileNotFoundError, match="geosite.dat"): - ensure_xray_assets(tmp_path, downloader=lambda url: archive) - - -def test_download_bytes_stream_reports_progress(monkeypatch) -> None: # noqa: ANN001 - payload = b"abcdef" - events = [] - - def fake_urlopen(url): # noqa: ANN001 - return addinfourl(BytesIO(payload), {"Content-Length": str(len(payload))}, url) - - monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.urlopen", fake_urlopen) - - result = download_bytes_stream("https://example.invalid/file", lambda *event: events.append(event), chunk_size=2) - - assert result == payload - assert events[0] == ("https://example.invalid/file", 0, 6) - assert events[-1] == ("https://example.invalid/file", 6, 6) - - -def test_download_bytes_uses_explicit_proxy(monkeypatch) -> None: # noqa: ANN001 - captured = {} - - class FakeOpener: - def open(self, url): # noqa: ANN001 - captured["url"] = url - return addinfourl(BytesIO(b"ok"), {}, url) - - def fake_proxy_handler(proxies): # noqa: ANN001 - captured["proxies"] = proxies - return object() - - monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.ProxyHandler", fake_proxy_handler) - monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.build_opener", lambda handler: FakeOpener()) - - result = download_bytes("https://example.invalid/file", proxy_url="http://proxy.example.invalid:8080") - - assert result == b"ok" - assert captured["url"] == "https://example.invalid/file" - assert captured["proxies"] == { - "http": "http://proxy.example.invalid:8080", - "https": "http://proxy.example.invalid:8080", - } diff --git a/tests/libs/test_xray_config.py b/tests/libs/test_xray_config.py deleted file mode 100644 index 1355109..0000000 --- a/tests/libs/test_xray_config.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import base64 -import json - -import pytest - -from pyxray.libs.nodes import parse_node_link -from pyxray.libs.xray_config import XrayConfigSettings, XrayConfigSettingsStore, generate_xray_config -from pyxray.libs.xray_config.settings import CustomInboundSettings, CustomRoutingRuleSettings, DnsRuleSettings, validate_settings - - -def test_settings_store_round_trips_all_sections(tmp_path) -> None: - settings = XrayConfigSettings() - settings.core.log_level = "debug" - settings.core.tcp_fast_open = "yes" - settings.core.mux_enabled = True - settings.inbounds.port_sharing = True - settings.inbounds.api.port = 2017 - settings.inbounds.custom.append(CustomInboundSettings(tag="lan-socks", protocol="socks", port=2080)) - settings.routing.mode = "custom" - settings.routing.custom_rules.append( - CustomRoutingRuleSettings(filename="geosite.dat", tags=["cn"], match_type="domain", rule_type="direct") - ) - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - settings.transparent.tproxy_white_country_codes = ["cn"] - settings.dns.query_strategy = "UseIP" - settings.dns.rules.append(DnsRuleSettings(server="https://dns.google/dns-query", domains="geosite:google", outbound="proxy")) - settings.outbounds[0].probe_interval = "30s" - settings.auto_update.gfwlist_auto_update_mode = "auto_update" - - store = XrayConfigSettingsStore(tmp_path / "settings.toml") - store.save(settings) - loaded = store.load() - - assert loaded.core.log_level == "debug" - assert loaded.core.tcp_fast_open == "yes" - assert loaded.core.mux_enabled is True - assert loaded.inbounds.port_sharing is True - assert loaded.inbounds.api.port == 2017 - assert loaded.inbounds.custom[0].tag == "lan-socks" - assert loaded.routing.custom_rules[0].filename == "geosite.dat" - assert loaded.transparent.tproxy_white_country_codes == ["cn"] - assert loaded.dns.rules[-1].server == "https://dns.google/dns-query" - assert loaded.outbounds[0].probe_interval == "30s" - assert loaded.auto_update.gfwlist_auto_update_mode == "auto_update" - - -def test_settings_store_loads_toml_arrays_inside_dict(tmp_path) -> None: - store = XrayConfigSettingsStore(tmp_path / "settings.toml") - store.path.write_text( - """ -[dns.hosts] -"courier.push.apple.com" = ["1-courier.push.apple.com"] -""".strip(), - encoding="utf-8", - ) - - settings = store.load() - - assert settings.to_dict()["dns"]["hosts"]["courier.push.apple.com"] == ["1-courier.push.apple.com"] - - -def test_settings_store_migrates_legacy_log_levels(tmp_path) -> None: - store = XrayConfigSettingsStore(tmp_path / "settings.toml") - store.path.write_text('[core]\nlog_level = "trace"\n', encoding="utf-8") - - settings = store.load() - - assert settings.core.log_level == "debug" - - -def test_settings_defaults_match_v2raya_core_values() -> None: - settings = XrayConfigSettings() - - assert settings.core.log_level == "info" - assert settings.core.mux_concurrency == 8 - assert settings.inbounds.socks_port == 20170 - assert settings.inbounds.http_port == 20171 - assert settings.inbounds.rule_socks_port == 0 - assert settings.inbounds.rule_http_port == 20172 - assert settings.inbounds.auth_user == "" - assert settings.inbounds.auth_password == "" - assert settings.transparent.mode == "close" - assert settings.transparent.type == "redirect" - assert settings.transparent.port == 52345 - assert settings.transparent.docker_transparent is True - assert settings.transparent.docker_transparent_cidrs == "172.16.0.0/12" - assert settings.transparent.output_bypass_rules == "" - assert settings.dns.query_strategy == "UseIPv4" - assert settings.dns.special_mode == "none" - assert settings.dns.fakedns_domains == "geosite:geolocation-!cn" - assert settings.dns.rules == [ - DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"), - DnsRuleSettings(server="223.5.5.5", domains="geosite:cn", outbound="direct"), - DnsRuleSettings(server="8.8.8.8", domains="", outbound="proxy"), - ] - - -def test_generate_default_config_matches_v2raya_template_shape() -> None: - node = parse_node_link(_ss_link()) - config = generate_xray_config(node) - - assert config["log"] == {"loglevel": "info", "access": "", "error": ""} - assert _inbound(config, "socks")["port"] == 20170 - assert _inbound(config, "socks")["protocol"] == "socks" - assert _inbound(config, "socks")["settings"]["udp"] is True - assert _inbound(config, "http")["port"] == 20171 - assert _inbound(config, "rule-mixed")["port"] == 20172 - assert _inbound(config, "rule-mixed")["protocol"] == "mixed" - assert _inbound(config, "rule-mixed")["settings"] == {"auth": "noauth", "udp": True, "allowTransparent": False} - assert "rule-http" not in {item["tag"] for item in config["inbounds"]} - assert "rule-socks" not in {item["tag"] for item in config["inbounds"]} - assert _outbound(config, "proxy")["protocol"] == "shadowsocks" - assert _outbound(config, "direct")["protocol"] == "freedom" - assert _outbound(config, "direct")["settings"]["domainStrategy"] == "UseIP" - assert _outbound(config, "block")["protocol"] == "blackhole" - assert _outbound(config, "dns-out")["protocol"] == "dns" - assert config["routing"]["domainStrategy"] == "IPOnDemand" - assert config["routing"]["domainMatcher"] == "mph" - assert config["dns"]["hosts"]["courier.push.apple.com"] == ["1-courier.push.apple.com"] - assert config["dns"]["queryStrategy"] == "UseIPv4" - assert "dns-in" not in {item["tag"] for item in config["inbounds"]} - - -def test_generate_mixed_inbound_with_password_auth() -> None: - settings = XrayConfigSettings() - settings.inbounds.auth_user = "alice" - settings.inbounds.auth_password = "secret" - - mixed = _inbound(generate_xray_config(parse_node_link(_ss_link()), settings), "rule-mixed") - - assert mixed["protocol"] == "mixed" - assert mixed["settings"] == { - "auth": "password", - "udp": True, - "allowTransparent": False, - "accounts": [{"user": "alice", "pass": "secret"}], - } - - -def test_generate_none_log_level_disables_xray_logs() -> None: - settings = XrayConfigSettings() - settings.core.log_level = "none" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert config["log"] == {"loglevel": "none", "access": "none", "error": "none"} - - -def test_generate_redirect_dns_inbound_when_transparent_redirect_enabled() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert _inbound(config, "transparent")["listen"] == "0.0.0.0" - assert _inbound(config, "dns-in")["listen"] == "127.2.0.17" - assert {"type": "field", "inboundTag": ["dns-in"], "outboundTag": "dns-out"} in config["routing"]["rules"] - assert _outbound(config, "proxy")["streamSettings"]["sockopt"]["mark"] == 128 - assert _outbound(config, "direct")["streamSettings"]["sockopt"]["mark"] == 128 - - -def test_generate_redirect_transparent_inbound_stays_local_when_docker_transparent_disabled() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - settings.transparent.docker_transparent = False - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert _inbound(config, "transparent")["listen"] == "127.0.0.1" - - -def test_generate_lan_dns_inbound_when_redirect_and_port_sharing_enabled() -> None: - settings = XrayConfigSettings() - settings.inbounds.port_sharing = True - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert _inbound(config, "dns-in")["listen"] == "0.0.0.0" - assert _inbound(config, "dns-in-local")["listen"] == "127.2.0.17" - assert {"type": "field", "inboundTag": ["dns-in", "dns-in-local"], "outboundTag": "dns-out"} in config["routing"]["rules"] - - -def test_skip_local_dns_inbound_for_tproxy_and_tun_like_v2raya() -> None: - node = parse_node_link(_ss_link()) - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - - tproxy_config = generate_xray_config(node, settings) - assert "dns-in" not in {item["tag"] for item in tproxy_config["inbounds"]} - - settings.transparent.type = "tun" - tun_config = generate_xray_config(node, settings) - assert "dns-in" not in {item["tag"] for item in tun_config["inbounds"]} - - -def test_generate_vless_reality_outbound_stream_settings() -> None: - settings = XrayConfigSettings() - settings.core.mux_enabled = True - settings.core.tcp_fast_open = "yes" - node = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision" - "&sni=www.example.com&fp=chrome&pbk=public-key&sid=abcd&spx=%2F#hk" - ) - - outbound = _outbound(generate_xray_config(node, settings), "proxy") - - assert outbound["protocol"] == "vless" - assert outbound["settings"]["vnext"][0]["users"][0]["flow"] == "xtls-rprx-vision" - assert outbound["streamSettings"]["security"] == "reality" - assert outbound["streamSettings"]["realitySettings"]["serverName"] == "www.example.com" - assert outbound["streamSettings"]["realitySettings"]["publicKey"] == "public-key" - assert outbound["streamSettings"]["sockopt"]["tcpFastOpen"] is True - assert outbound["mux"] == {"enabled": True, "concurrency": 8} - - -def test_generate_direct_outbound_uses_shared_sockopt_settings() -> None: - settings = XrayConfigSettings() - settings.core.tcp_fast_open = "yes" - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - - direct = _outbound(generate_xray_config(parse_node_link(_ss_link()), settings), "direct") - - assert direct["streamSettings"]["sockopt"] == {"tcpFastOpen": True, "mark": 128} - - -def test_generate_vmess_ws_tls_outbound() -> None: - payload = { - "ps": "vmess-node", - "add": "vmess.example.net", - "port": "443", - "id": "00000000-0000-4000-8000-000000000002", - "aid": "0", - "scy": "auto", - "net": "websocket", - "type": "none", - "host": "cdn.example.net", - "path": "/ray", - "tls": "tls", - "sni": "sni.example.net", - } - encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - - outbound = _outbound(generate_xray_config(parse_node_link(f"vmess://{encoded}")), "proxy") - - assert outbound["protocol"] == "vmess" - assert outbound["settings"]["vnext"][0]["address"] == "vmess.example.net" - assert outbound["settings"]["vnext"][0]["users"][0]["alterId"] == 0 - assert outbound["streamSettings"]["network"] == "ws" - assert outbound["streamSettings"]["security"] == "tls" - assert outbound["streamSettings"]["tlsSettings"]["serverName"] == "sni.example.net" - assert outbound["streamSettings"]["wsSettings"]["headers"]["Host"] == "cdn.example.net" - assert outbound["streamSettings"]["wsSettings"]["path"] == "/ray" - - -def test_generate_trojan_grpc_and_trojan_go_outbound() -> None: - trojan = parse_node_link( - "trojan://secret@example.org:443?type=grpc&serviceName=svc&sni=edge.example.org#trojan" - ) - trojan_go = parse_node_link( - "trojan-go://secret@example.org:443?type=ws&host=cdn.example.org&path=%2Fgo&sni=edge.example.org#tg" - ) - - trojan_out = _outbound(generate_xray_config(trojan), "proxy") - trojan_go_out = _outbound(generate_xray_config(trojan_go), "proxy") - - assert trojan_out["protocol"] == "trojan" - assert trojan_out["settings"]["servers"][0]["password"] == "secret" - assert trojan_out["streamSettings"]["grpcSettings"]["serviceName"] == "svc" - assert trojan_out["streamSettings"]["tlsSettings"]["serverName"] == "edge.example.org" - assert trojan_go_out["protocol"] == "trojan" - assert trojan_go_out["streamSettings"]["wsSettings"]["path"] == "/go" - - -def test_generate_vless_ws_early_data_grpc_and_xhttp_settings() -> None: - ws = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=ws&security=tls&host=cdn.example&path=%2Fws&maxEarlyData=2048" - "&earlyDataHeaderName=Sec-WebSocket-Protocol#ws" - ) - grpc = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=grpc&security=tls&serviceName=svc&multiMode=true&idleTimeout=60" - "&healthCheckTimeout=20&permitWithoutStream=true&initialWindowsSize=65535#grpc" - ) - xhttp = parse_node_link( - "vless://00000000-0000-4000-8000-000000000001@example.com:443" - "?type=xhttp&security=tls&host=cdn.example&path=%2Fxhttp#xhttp" - ) - - ws_stream = _outbound(generate_xray_config(ws), "proxy")["streamSettings"] - grpc_stream = _outbound(generate_xray_config(grpc), "proxy")["streamSettings"] - xhttp_stream = _outbound(generate_xray_config(xhttp), "proxy")["streamSettings"] - - assert ws_stream["wsSettings"]["maxEarlyData"] == 2048 - assert ws_stream["wsSettings"]["earlyDataHeaderName"] == "Sec-WebSocket-Protocol" - assert grpc_stream["grpcSettings"]["multiMode"] is True - assert grpc_stream["grpcSettings"]["idle_timeout"] == 60 - assert grpc_stream["grpcSettings"]["health_check_timeout"] == 20 - assert grpc_stream["grpcSettings"]["permit_without_stream"] is True - assert grpc_stream["grpcSettings"]["initial_windows_size"] == 65535 - assert xhttp_stream["xhttpSettings"] == {"path": "/xhttp", "host": "cdn.example", "mode": "auto"} - - -def test_generate_inbounds_custom_api_and_transparent_tproxy() -> None: - settings = XrayConfigSettings() - settings.inbounds.port_sharing = True - settings.inbounds.vmess_port = 20174 - settings.inbounds.api.port = 20175 - settings.inbounds.custom.append(CustomInboundSettings(tag="extra-http", protocol="http", port=20176)) - settings.transparent.mode = "proxy" - settings.transparent.type = "tproxy" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert _inbound(config, "socks")["listen"] == "0.0.0.0" - assert _inbound(config, "rule-mixed")["listen"] == "0.0.0.0" - assert _inbound(config, "rule-mixed")["port"] == 20172 - assert _inbound(config, "vmess")["protocol"] == "vmess" - assert _inbound(config, "extra-http")["protocol"] == "http" - assert _inbound(config, "transparent")["streamSettings"]["sockopt"]["tproxy"] == "tproxy" - assert _inbound(config, "api-in")["port"] == 20175 - assert config["api"]["services"] == ["LoggerService"] - assert {"type": "field", "inboundTag": ["transparent"], "outboundTag": "proxy"} in config["routing"]["rules"] - - -def test_generate_routing_modes_follow_v2raya_rules() -> None: - settings = XrayConfigSettings() - - whitelist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"] - assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"} in whitelist - assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:google"], "outboundTag": "proxy"} in whitelist - - settings.routing.mode = "gfwlist" - gfwlist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"] - assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct"} in gfwlist - assert any("91.108.4.0/22" in rule.get("ip", []) for rule in gfwlist) - - -def test_generate_custom_routing_a_applies_before_proxy_mode_fallback() -> None: - settings = XrayConfigSettings() - settings.routing.mode = "proxy" - settings.routing.routing_a = "domain(keyword:google)->direct" - - rules = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"] - - google_rule = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct", "domain": ["keyword:google"]} - fallback = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"} - assert google_rule in rules - assert fallback in rules - assert rules.index(google_rule) < rules.index(fallback) - - -def test_generate_custom_text_rules_before_route_mode_and_default_proxy() -> None: - settings = XrayConfigSettings() - settings.routing.mode = "whitelist" - settings.routing.default_rule = "proxy" - settings.routing.routing_a = "domain(geosite:google, domain:example.com)->proxy\nip(geoip:cn)->direct" - - rules = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"] - custom_domain = { - "type": "field", - "inboundTag": ["rule-mixed"], - "domain": ["geosite:google", "domain:example.com"], - "outboundTag": "proxy", - } - custom_ip = { - "type": "field", - "inboundTag": ["rule-mixed"], - "ip": ["geoip:cn"], - "outboundTag": "direct", - } - - assert rules.index(custom_domain) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"}) - assert rules.index(custom_ip) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"}) - assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"} in rules - - -def test_generate_dns_rules_and_dns_routing() -> None: - settings = XrayConfigSettings() - settings.dns.query_strategy = "UseIP" - settings.dns.disable_fallback = True - settings.dns.rules = [ - DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"), - DnsRuleSettings(server="223.5.5.5", domains="geosite:cn", outbound="direct"), - DnsRuleSettings(server="https://dns.google/dns-query", domains="", outbound="proxy"), - ] - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert config["dns"]["queryStrategy"] == "UseIP" - assert config["dns"]["disableFallback"] is True - assert {"address": "localhost", "domains": ["geosite:private"]} in config["dns"]["servers"] - assert {"address": "223.5.5.5", "domains": ["geosite:cn"]} in config["dns"]["servers"] - assert "https://dns.google/dns-query" in config["dns"]["servers"] - assert any(rule.get("domain") == ["dns.google"] and rule.get("outboundTag") == "proxy" for rule in config["routing"]["rules"]) - - -def test_generate_fakedns_adds_dns_server_pool_and_sniffing() -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "gfwlist" - settings.transparent.type = "redirect" - settings.dns.special_mode = "fakedns" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert config["fakedns"] == [{"ipPool": "198.18.0.0/15", "poolSize": 65535}] - assert {"address": "fakedns", "domains": ["geosite:geolocation-!cn"]} in config["dns"]["servers"] - assert "fakedns" in _inbound(config, "transparent")["sniffing"]["destOverride"] - assert "fakedns" in _inbound(config, "rule-mixed")["sniffing"]["destOverride"] - - -def test_generate_fakedns_uses_custom_domain_scope() -> None: - settings = XrayConfigSettings() - settings.dns.special_mode = "fakedns" - settings.dns.fakedns_domains = "geosite:gfw\nkeyword:example" - - config = generate_xray_config(parse_node_link(_ss_link()), settings) - - assert {"address": "fakedns", "domains": ["geosite:gfw", "keyword:example"]} in config["dns"]["servers"] - - -def test_validate_settings_rejects_invalid_values() -> None: - settings = XrayConfigSettings() - settings.transparent.type = "bad" - - with pytest.raises(ValueError, match="transparent.type"): - validate_settings(settings) - - -def test_validate_settings_requires_complete_inbound_auth() -> None: - settings = XrayConfigSettings() - settings.inbounds.auth_user = "alice" - - with pytest.raises(ValueError, match="auth_user"): - validate_settings(settings) - - -def _ss_link() -> str: - user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") - return f"ss://{user}@ss.example.net:8388#ss-node" - - -def _inbound(config: dict, tag: str) -> dict: - return next(item for item in config["inbounds"] if item.get("tag") == tag) - - -def _outbound(config: dict, tag: str) -> dict: - return next(item for item in config["outbounds"] if item.get("tag") == tag) diff --git a/tests/libs/test_xray_runtime.py b/tests/libs/test_xray_runtime.py deleted file mode 100644 index a2c9d8d..0000000 --- a/tests/libs/test_xray_runtime.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import subprocess -import socket -import os -import shutil -import sys -from pathlib import Path - -from pyxray.libs.xray_assets import xray_executable_name -from pyxray.libs.xray_config import XrayConfigSettings, write_transparent_rule_files -from pyxray.libs.xray_runtime import XrayServiceManager -from pyxray.libs.xray_transparent_runtime import TransparentRuntime - - -def test_transparent_runtime_auto_backend_prefers_iptables_and_starts_local_ip_watcher(tmp_path: Path) -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - write_transparent_rule_files(settings, tmp_path) - commands: list[list[str]] = [] - - runtime = TransparentRuntime( - transparent_dir=tmp_path, - log_path=tmp_path / "xray.log", - backend="auto", - executor=_executor(commands), - local_cidrs_provider=lambda: ["198.51.100.10/32"], - watcher_interval=60, - ) - - runtime.setup(settings) - runtime.cleanup() - - assert runtime.backend == "iptables" - assert ["/bin/sh", str(tmp_path / "transparent-iptables-setup.sh")] in commands - assert ["iptables", "-w", "2", "-t", "nat", "-I", "TP_RULE", "1", "-d", "198.51.100.10/32", "-j", "RETURN"] in commands - assert ["iptables", "-w", "2", "-t", "nat", "-D", "TP_RULE", "-d", "198.51.100.10/32", "-j", "RETURN"] in commands - - -def test_transparent_runtime_auto_backend_falls_back_to_nft_when_iptables_setup_fails(tmp_path: Path) -> None: - settings = XrayConfigSettings() - settings.transparent.mode = "proxy" - settings.transparent.type = "redirect" - write_transparent_rule_files(settings, tmp_path) - commands: list[list[str]] = [] - - runtime = TransparentRuntime( - transparent_dir=tmp_path, - log_path=tmp_path / "xray.log", - backend="auto", - executor=_executor(commands, failures={"transparent-iptables-setup.sh"}), - local_cidrs_provider=lambda: [], - ) - - runtime.setup(settings) - runtime.cleanup() - - assert runtime.backend == "nft" - assert ["/bin/sh", str(tmp_path / "transparent-iptables-setup.sh")] in commands - assert ["/bin/sh", str(tmp_path / "transparent-nft-setup.sh")] in commands - - -def test_xray_service_manager_reports_inbound_port_conflict(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - listener.bind(("127.0.0.1", 0)) - listener.listen(1) - port = listener.getsockname()[1] - config = tmp_path / "config.json" - config.write_text( - f'{{"inbounds":[{{"tag":"conflict-http","protocol":"http","listen":"127.0.0.1","port":{port}}}]}}', - encoding="utf-8", - ) - manager = XrayServiceManager(xray_dir=tmp_path, config_path=config, log_path=tmp_path / "xray.log") - - try: - try: - manager.start() - except RuntimeError as exc: - message = str(exc) - else: - raise AssertionError("expected port conflict") - finally: - listener.close() - - assert "inbound port conflict" in message - assert f"conflict-http 127.0.0.1:{port}/tcp" in message - assert "inbound port conflict" in (tmp_path / "xray.log").read_text(encoding="utf-8") - - -def _executor(commands: list[list[str]], failures: set[str] | None = None): - failures = failures or set() - - def execute(command: list[str]) -> subprocess.CompletedProcess[str]: - commands.append(command) - script = Path(command[-1]).name - return subprocess.CompletedProcess( - args=command, - returncode=1 if script in failures else 0, - stdout="", - stderr=f"{script} failed" if script in failures else "", - ) - - return execute - - -def _write_fake_xray(directory: Path) -> Path: - xray = directory / xray_executable_name() - if os.name == "nt": - try: - os.link(sys.executable, xray) - except OSError: - shutil.copy2(sys.executable, xray) - (directory / "run").write_text("import time\ntime.sleep(30)\n", encoding="utf-8") - return xray - xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8") - xray.chmod(0o755) - return xray diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 3474e45..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from pyxray import cli -from pyxray.libs.xray_assets import XrayAssets - - -def test_no_subcommand_defaults_to_web(monkeypatch) -> None: # noqa: ANN001 - captured = {} - - def fake_run_web(host: str, port: int, xray_dir: str) -> None: - captured.update({"host": host, "port": port, "xray_dir": xray_dir}) - - monkeypatch.setattr(cli, "run_web", fake_run_web) - - cli.main([]) - - assert captured == {"host": "0.0.0.0", "port": 8000, "xray_dir": "data/xray"} - - -def test_web_subcommand_still_uses_web_runner(monkeypatch) -> None: # noqa: ANN001 - captured = {} - - def fake_run_web(host: str, port: int, xray_dir: str) -> None: - captured.update({"host": host, "port": port, "xray_dir": xray_dir}) - - monkeypatch.setattr(cli, "run_web", fake_run_web) - - cli.main(["web", "--host", "127.0.0.1", "--port", "9000", "--xray-dir", "runtime/xray"]) - - assert captured == {"host": "127.0.0.1", "port": 9000, "xray_dir": "runtime/xray"} - - -def test_configs_download_prints_download_toml(tmp_path: Path, capsys, monkeypatch) -> None: # noqa: ANN001 - monkeypatch.chdir(tmp_path) - data = tmp_path / "data" - data.mkdir() - (data / "download.toml").write_text('version = "v1.2.3"\n', encoding="utf-8") - - cli.main(["configs", "--download", "--xray-dir", "data/xray"]) - - assert capsys.readouterr().out == 'version = "v1.2.3"\n' - - -def test_clear_download_removes_only_download_settings_and_known_assets(tmp_path: Path, monkeypatch) -> None: # noqa: ANN001 - monkeypatch.chdir(tmp_path) - data = tmp_path / "data" - xray_dir = data / "xray" - xray_dir.mkdir(parents=True) - (data / "download.toml").write_text("download", encoding="utf-8") - (data / "nodes.toml").write_text("nodes", encoding="utf-8") - (xray_dir / "xray").write_bytes(b"xray") - (xray_dir / "xray.exe").write_bytes(b"xray.exe") - (xray_dir / "geoip.dat").write_bytes(b"geoip") - (xray_dir / "geosite.dat").write_bytes(b"geosite") - (xray_dir / "user-file.txt").write_text("keep", encoding="utf-8") - - cli.main(["clear", "--download", "--xray-dir", "data/xray"]) - - assert not (data / "download.toml").exists() - assert not (xray_dir / "xray").exists() - assert not (xray_dir / "xray.exe").exists() - assert not (xray_dir / "geoip.dat").exists() - assert not (xray_dir / "geosite.dat").exists() - assert (data / "nodes.toml").exists() - assert (xray_dir / "user-file.txt").exists() - - -def test_clear_all_removes_known_data_and_keeps_unrelated_files(tmp_path: Path, monkeypatch) -> None: # noqa: ANN001 - monkeypatch.chdir(tmp_path) - data = tmp_path / "data" - xray_dir = data / "xray" - transparent_dir = data / "transparent" - transparent_dir.mkdir(parents=True) - xray_dir.mkdir() - for name in ("download.toml", "nodes.toml", "settings.toml", "config.json", "service-state.json", "xray.log"): - (data / name).write_text(name, encoding="utf-8") - (transparent_dir / "transparent-iptables-setup.sh").write_text("script", encoding="utf-8") - (xray_dir / "xray").write_bytes(b"xray") - (xray_dir / "geoip.dat").write_bytes(b"geoip") - (data / "notes.txt").write_text("keep", encoding="utf-8") - (xray_dir / "custom.dat").write_text("keep", encoding="utf-8") - - cli.main(["clear", "--all", "--xray-dir", "data/xray"]) - - for name in ("download.toml", "nodes.toml", "settings.toml", "config.json", "service-state.json", "xray.log"): - assert not (data / name).exists() - assert not transparent_dir.exists() - assert not (xray_dir / "xray").exists() - assert not (xray_dir / "geoip.dat").exists() - assert (data / "notes.txt").exists() - assert (xray_dir / "custom.dat").exists() - - -def test_download_command_persists_settings_and_reuses_ensure(monkeypatch, tmp_path: Path) -> None: # noqa: ANN001 - monkeypatch.chdir(tmp_path) - captured = {} - - def fake_ensure_xray_assets(directory, **options): # noqa: ANN001 - captured["directory"] = directory - captured["options"] = options - path = Path(directory) - path.mkdir(parents=True, exist_ok=True) - xray = path / "xray" - xray.write_bytes(b"xray") - geoip = path / "geoip.dat" - geoip.write_bytes(b"geoip") - geosite = path / "geosite.dat" - geosite.write_bytes(b"geosite") - return XrayAssets(directory=path, xray=xray, geoip=geoip, geosite=geosite, downloaded=("geoip.dat",)) - - monkeypatch.setattr("pyxray.libs.app_data.ensure_xray_assets", fake_ensure_xray_assets) - - cli.main( - [ - "download", - "--target", - "geoip", - "--directory", - "data/xray", - "--version", - "v1.2.3", - "--geoip-url", - "https://mirror.example.invalid/geoip.dat", - "--force", - ] - ) - - assert captured["directory"] == "data/xray" - assert captured["options"]["target"] == "geoip" - assert captured["options"]["version"] == "v1.2.3" - assert captured["options"]["geoip_url"] == "https://mirror.example.invalid/geoip.dat" - assert captured["options"]["force"] is True - content = (tmp_path / "data" / "download.toml").read_text(encoding="utf-8") - assert 'directory = "data/xray"' in content - assert 'target = "geoip"' in content - assert 'version = "v1.2.3"' in content diff --git a/tests/web/test_server_startup.py b/tests/web/test_server_startup.py deleted file mode 100644 index 0949fdf..0000000 --- a/tests/web/test_server_startup.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from pyxray import __version__ -from pyxray.web import server - - -def test_startup_banner_prints_version_first(capsys) -> None: # noqa: ANN001 - server._print_startup_banner("127.0.0.1", 3309) - - lines = capsys.readouterr().out.splitlines() - assert lines[0] == f" * Pyxray version: {__version__}" - assert lines[1] == " * Pyxray URL: http://127.0.0.1:3309" diff --git a/tests/web/test_xray_assets_web.py b/tests/web/test_xray_assets_web.py deleted file mode 100644 index 2cd0704..0000000 --- a/tests/web/test_xray_assets_web.py +++ /dev/null @@ -1,890 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import os -import shutil -import subprocess -import sys -import time -from pathlib import Path - -from pyxray.libs.xray_assets import XrayAssets, xray_executable_name -from pyxray.web.server import create_app - - -def test_index_shows_asset_status(tmp_path: Path) -> None: - (tmp_path / xray_executable_name()).write_bytes(b"bin") - - app = create_app(tmp_path) - client = app.test_client() - - response = client.get("/") - - assert response.status_code == 200 - body = response.get_data(as_text=True) - assert "xray" in body - assert "geoip.dat" in body - assert "geosite.dat" in body - assert "缺失" in body - assert "操作结果" not in body - assert "检查文件" not in body - assert "当前进度" in body - assert "节点管理" in body - assert "配置生成" in body - assert "保存下载设置" not in body - assert "清除日志" in body - assert "正在检查并补齐 Xray 资源,请等待后端返回结果" not in body - - -def test_ensure_api_uses_form_values(monkeypatch, tmp_path: Path) -> None: # noqa: ANN001 - captured = {} - - def fake_ensure_xray_assets(directory, **options): # noqa: ANN001 - captured["directory"] = directory - captured["options"] = options - Path(directory).mkdir(parents=True, exist_ok=True) - xray = Path(directory) / xray_executable_name() - xray.write_bytes(b"bin") - (Path(directory) / "geoip.dat").write_bytes(b"geoip") - (Path(directory) / "geosite.dat").write_bytes(b"geosite") - return XrayAssets( - directory=Path(directory), - xray=xray, - geoip=Path(directory) / "geoip.dat", - geosite=Path(directory) / "geosite.dat", - downloaded=("archive",), - ) - - monkeypatch.setattr("pyxray.web.xray_assets.ensure_xray_assets", fake_ensure_xray_assets) - app = create_app(tmp_path, run_jobs_sync=True) - client = app.test_client() - - response = client.post( - "/api/xray/assets/ensure", - data={ - "directory": str(tmp_path), - "version": "v1.2.3", - "archive_url": "https://mirror.invalid/xray.zip", - "geoip_url": "", - "geosite_url": "https://mirror.invalid/geosite.dat", - "proxy_url": "http://proxy.example.invalid:8080", - "target": "geosite", - "force": "on", - }, - ) - - assert response.status_code == 200 - job_id = response.get_json()["job_id"] - job = client.get(f"/api/xray/assets/jobs/{job_id}").get_json() - assert captured["directory"] == str(tmp_path) - assert captured["options"]["version"] == "v1.2.3" - assert captured["options"]["archive_url"] == "https://mirror.invalid/xray.zip" - assert captured["options"]["geoip_url"] is None - assert captured["options"]["geosite_url"] == "https://mirror.invalid/geosite.dat" - assert captured["options"]["proxy_url"] == "http://proxy.example.invalid:8080" - assert captured["options"]["target"] == "geosite" - assert captured["options"]["force"] is True - assert callable(captured["options"]["downloader"]) - assert job["state"] == "done" - assert any(step["name"] == "检查本地文件" for step in job["steps"]) - assert any(step["name"] == "解压 / 写入文件" and "archive" in step["detail"] for step in job["steps"]) - assert job["status"]["ready"] is True - assert (tmp_path / "download.toml").exists() - assert 'version = "v1.2.3"' in (tmp_path / "download.toml").read_text(encoding="utf-8") - assert 'proxy_url = "http://proxy.example.invalid:8080"' in (tmp_path / "download.toml").read_text(encoding="utf-8") - - -def test_asset_settings_api_persists_download_form_values(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - - saved = client.post( - "/api/xray/assets/settings", - data={ - "directory": str(tmp_path / "custom-xray"), - "version": "v9.9.9", - "archive_url": "https://mirror.invalid/xray.zip", - "geoip_url": "https://mirror.invalid/geoip.dat", - "geosite_url": "https://mirror.invalid/geosite.dat", - "proxy_url": "http://127.0.0.1:1080", - "target": "geoip", - "force": "on", - }, - ) - loaded = client.get("/api/xray/assets/settings") - index = client.get("/") - - assert saved.status_code == 200 - assert loaded.get_json()["directory"] == str(tmp_path / "custom-xray") - assert loaded.get_json()["version"] == "v9.9.9" - assert loaded.get_json()["force"] is True - body = index.get_data(as_text=True) - assert str(tmp_path / "custom-xray") in body - assert "v9.9.9" in body - - -def test_asset_settings_default_version_uses_latest_release(monkeypatch, tmp_path: Path) -> None: # noqa: ANN001 - monkeypatch.setattr("pyxray.libs.xray_assets._DEFAULT_VERSION_CACHE", None) - monkeypatch.setattr("pyxray.libs.xray_assets.latest_xray_version", lambda *, timeout: "v99.9.9") - app = create_app(tmp_path) - client = app.test_client() - - payload = client.get("/api/xray/assets/settings").get_json() - - assert payload["version"] == "v99.9.9" - - -def test_job_records_real_download_percent(monkeypatch, tmp_path: Path) -> None: # noqa: ANN001 - def fake_download_bytes_stream(url, progress, **options): # noqa: ANN001 - progress(url, 0, 10) - progress(url, 5, 10) - progress(url, 10, 10) - return b"zip" - - def fake_ensure_xray_assets(directory, *, downloader, **options): # noqa: ANN001, ARG001 - downloader("https://mirror.invalid/xray.zip") - Path(directory).mkdir(parents=True, exist_ok=True) - xray = Path(directory) / xray_executable_name() - xray.write_bytes(b"bin") - (Path(directory) / "geoip.dat").write_bytes(b"geoip") - (Path(directory) / "geosite.dat").write_bytes(b"geosite") - return XrayAssets( - directory=Path(directory), - xray=xray, - geoip=Path(directory) / "geoip.dat", - geosite=Path(directory) / "geosite.dat", - downloaded=("archive",), - ) - - monkeypatch.setattr("pyxray.web.xray_assets.download_bytes_stream", fake_download_bytes_stream) - monkeypatch.setattr("pyxray.web.xray_assets.ensure_xray_assets", fake_ensure_xray_assets) - app = create_app(tmp_path, run_jobs_sync=True) - client = app.test_client() - - response = client.post("/api/xray/assets/ensure", data={"directory": str(tmp_path), "version": "v1.2.3", "target": "all"}) - job = client.get(f"/api/xray/assets/jobs/{response.get_json()['job_id']}").get_json() - download_step = next(step for step in job["steps"] if step["name"] == "下载资源") - - assert download_step["percent"] == 100 - assert download_step["received"] == 3 - assert download_step["total"] == 3 - - -def test_cancel_job_api_marks_job_cancel_requested(tmp_path: Path) -> None: - app = create_app(tmp_path) - store = app.extensions["pyxray_jobs"] - job = store.start(lambda item: item.update({"state": "running"})) - client = app.test_client() - - response = client.post(f"/api/xray/assets/jobs/{job['id']}/cancel") - payload = response.get_json() - - assert response.status_code == 200 - assert payload["cancel_requested"] is True - assert store.get(job["id"])["state"] == "cancelled" - assert store.get(job["id"])["cancel_requested"] is True - assert any(step["name"] == "任务已停止" for step in store.get(job["id"])["steps"]) - - -def test_xray_service_api_starts_stops_and_reads_logs(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stdout="xray-started") - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - started = client.post("/api/xray/service/start") - status = client.get("/api/xray/service") - stopped = client.post("/api/xray/service/stop") - logs = client.get("/api/xray/service/logs") - - assert started.status_code == 200 - assert started.get_json()["running"] is True - assert status.get_json()["pid"] == started.get_json()["pid"] - assert stopped.status_code == 200 - assert stopped.get_json()["running"] is False - assert "pyxray start xray" in logs.get_json()["content"] - assert "xray-started" in logs.get_json()["content"] - assert json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))["log"]["error"] == "" - assert json.loads((tmp_path / "service-state.json").read_text(encoding="utf-8")) == {"desired_running": False} - - -def test_xray_service_restores_desired_running_state_on_app_start(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stdout="restored-start") - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post( - "/api/xray/config/settings", - data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'}, - ) - - started = client.post("/api/xray/service/start") - app.extensions["pyxray_xray_service"].shutdown() - - restored_app = create_app(tmp_path) - restored_client = restored_app.test_client() - restored_status = restored_client.get("/api/xray/service") - restored_logs = restored_client.get("/api/xray/service/logs").get_json()["content"] - restored_app.extensions["pyxray_xray_service"].shutdown() - - assert started.status_code == 200 - assert json.loads((tmp_path / "service-state.json").read_text(encoding="utf-8")) == {"desired_running": True} - assert restored_status.get_json()["running"] is True - assert "pyxray restored desired running state" in restored_logs - - -def test_xray_service_log_forwarder_flushes_line_output_quickly(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stdout="first-line") - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - started = client.post("/api/xray/service/start") - time.sleep(0.1) - logs = client.get("/api/xray/service/logs").get_json()["content"] - client.post("/api/xray/service/stop") - - assert started.status_code == 200 - assert "first-line" in logs - - -def test_xray_service_start_regenerates_config_from_saved_settings(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post("/api/xray/config/settings", data={"settings_toml": "[core]\nlog_level = \"debug\"\n"}) - (tmp_path / "config.json").write_text('{"old": true}', encoding="utf-8") - - started = client.post("/api/xray/service/start") - client.post("/api/xray/service/stop") - config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8")) - - assert started.status_code == 200 - assert config["log"]["loglevel"] == "debug" - assert config["outbounds"][0]["protocol"] == "shadowsocks" - - -def test_xray_service_applies_transparent_rules_on_start_and_cleans_on_stop(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - app = create_app(tmp_path) - commands: list[str] = [] - app.extensions["pyxray_transparent_runtime"].executor = _recording_executor(commands) - app.extensions["pyxray_transparent_runtime"].local_cidrs_provider = lambda: [] - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post( - "/api/xray/config/settings", - data={ - "transparent.mode": "proxy", - "transparent.type": "redirect", - "transparent.port": "52345", - "transparent.ipforward": "on", - "dns.local_dns_listen": "on", - }, - ) - - started = client.post("/api/xray/service/start") - stopped = client.post("/api/xray/service/stop") - - assert started.status_code == 200 - assert stopped.status_code == 200 - assert commands == [ - "resolv-hijack-cleanup.sh", - "transparent-iptables-cleanup.sh", - "ip-forward-apply.sh", - "transparent-iptables-setup.sh", - "resolv-hijack-setup.sh", - "resolv-hijack-cleanup.sh", - "transparent-iptables-cleanup.sh", - ] - - -def test_xray_service_rolls_back_when_transparent_setup_fails(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - app = create_app(tmp_path) - commands: list[str] = [] - app.extensions["pyxray_transparent_runtime"].executor = _recording_executor( - commands, - failures={"transparent-iptables-setup.sh", "transparent-nft-setup.sh"}, - ) - app.extensions["pyxray_transparent_runtime"].local_cidrs_provider = lambda: [] - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post( - "/api/xray/config/settings", - data={ - "transparent.mode": "proxy", - "transparent.type": "redirect", - "transparent.port": "52345", - "transparent.ipforward": "on", - "dns.local_dns_listen": "on", - }, - ) - - response = client.post("/api/xray/service/start") - - assert response.status_code == 400 - assert response.get_json()["status"]["running"] is False - assert "transparent-iptables-setup.sh" in commands - assert "transparent-nft-setup.sh" in commands - assert commands.count("transparent-iptables-cleanup.sh") >= 2 - assert commands.count("transparent-nft-cleanup.sh") >= 1 - assert commands[-2:] == ["resolv-hijack-cleanup.sh", "transparent-nft-cleanup.sh"] - - -def test_xray_service_shutdown_stops_managed_process(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - started = client.post("/api/xray/service/start").get_json() - service = app.extensions["pyxray_xray_service"] - - service.shutdown() - - assert started["running"] is True - assert service.status()["running"] is False - assert "pyxray shutdown xray" in client.get("/api/xray/service/logs").get_json()["content"] - - -def test_xray_service_uses_absolute_paths_when_app_created_with_relative_xray_dir(tmp_path: Path, monkeypatch) -> None: # noqa: ANN001 - monkeypatch.chdir(tmp_path) - xray_dir = tmp_path / "data" / "xray" - xray_dir.mkdir(parents=True) - xray = _write_fake_xray(xray_dir, stdout="relative-started") - app = create_app("data/xray") - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - started = client.post("/api/xray/service/start") - client.post("/api/xray/service/stop") - logs = client.get("/api/xray/service/logs").get_json()["content"] - - assert started.status_code == 200 - assert started.get_json()["running"] is True - assert str(xray) in logs - - -def test_xray_service_api_reports_missing_config(tmp_path: Path) -> None: - _write_fake_xray(tmp_path) - app = create_app(tmp_path) - client = app.test_client() - - response = client.post("/api/xray/service/start") - - assert response.status_code == 400 - assert "未选择节点" in response.get_json()["error"] - - -def test_xray_service_api_records_immediate_start_failure_output(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stderr="bind: permission denied", exit_code=23, sleep_seconds=0) - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - response = client.post("/api/xray/service/start") - logs = client.get("/api/xray/service/logs") - - assert response.status_code == 400 - assert "Xray exited immediately with code 23" in response.get_json()["error"] - assert "bind: permission denied" in response.get_json()["error"] - assert "bind: permission denied" in logs.get_json()["content"] - - -def test_xray_service_api_clears_logs(tmp_path: Path) -> None: - (tmp_path / "xray.log").write_text("old log", encoding="utf-8") - app = create_app(tmp_path) - client = app.test_client() - - response = client.delete("/api/xray/service/logs") - logs = client.get("/api/xray/service/logs") - - assert response.status_code == 200 - assert response.get_json()["content"] == "" - assert response.get_json()["offset"] == 0 - assert logs.get_json()["content"] == "" - assert client.get(f"/api/xray/service/logs?offset={response.get_json()['offset']}").get_json()["content"] == "" - assert (tmp_path / "xray.log").read_text(encoding="utf-8") == "" - - -def test_xray_service_logs_api_reads_from_offset(tmp_path: Path) -> None: - log = tmp_path / "xray.log" - log.write_text("old log\n", encoding="utf-8") - app = create_app(tmp_path) - client = app.test_client() - offset = client.get("/api/xray/service/logs?offset=end").get_json()["offset"] - with log.open("a", encoding="utf-8") as file: - file.write("new log\n") - - payload = client.get(f"/api/xray/service/logs?offset={offset}").get_json() - - assert payload["content"] == "new log" - assert payload["offset"] == log.stat().st_size - - -def test_xray_service_logs_api_returns_compact_route_lines(tmp_path: Path) -> None: - log = tmp_path / "xray.log" - log.write_text( - "\n".join( - [ - "2026/05/27 04:17:06.829641 [Info] [3636958196] app/dispatcher: sniffed domain: git.pchuan.top", - "2026/05/27 04:17:06.829662 [Info] [3636958196] app/dispatcher: taking detour [direct] for [tcp:git.pchuan.top:80]", - "2026/05/27 04:17:06.829733 from 192.168.0.76:53842 accepted tcp:117.72.47.28:80 [transparent -> direct]", - "2026/05/27 04:17:18.057882 [Info] app/proxyman/outbound: failed to process outbound traffic", - ] - ), - encoding="utf-8", - ) - app = create_app(tmp_path) - client = app.test_client() - - payload = client.get("/api/xray/service/logs?format=compact").get_json() - - assert "2026/05/27 04:17:06 git.pchuan.top:80 -> direct" in payload["content"] - assert "accepted tcp" not in payload["content"] - assert "failed to process outbound traffic" in payload["content"] - - -def test_xray_service_logs_api_returns_latest_1000_lines(tmp_path: Path) -> None: - (tmp_path / "xray.log").write_text("\n".join(f"line-{index}" for index in range(1205)), encoding="utf-8") - app = create_app(tmp_path) - client = app.test_client() - - content = client.get("/api/xray/service/logs").get_json()["content"] - - assert "line-204" not in content - assert "line-205" in content - assert "line-1204" in content - assert len(content.splitlines()) == 1000 - - -def test_xray_service_prefers_persisted_download_directory(tmp_path: Path) -> None: - default_dir = tmp_path / "default-xray" - preferred_dir = tmp_path / "download-xray" - default_dir.mkdir() - preferred_dir.mkdir() - _write_fake_xray(default_dir, stdout="default-xray") - preferred_xray = _write_fake_xray(preferred_dir, stdout="preferred-xray") - app = create_app(default_dir, default_data_dir=tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post("/api/xray/assets/settings", data={"directory": str(preferred_dir), "version": "v1.2.3"}) - - started = client.post("/api/xray/service/start").get_json() - client.post("/api/xray/service/stop") - logs = client.get("/api/xray/service/logs").get_json()["content"] - - assert started["xray"] == str(preferred_xray) - assert started["xray_dir"] == str(preferred_dir) - assert str(preferred_xray) in logs - - -def test_xray_service_falls_back_to_default_directory_when_saved_directory_has_no_xray(tmp_path: Path) -> None: - default_dir = tmp_path / "default-xray" - preferred_dir = tmp_path / "download-xray" - default_dir.mkdir() - preferred_dir.mkdir() - default_xray = _write_fake_xray(default_dir, stdout="default-xray") - app = create_app(default_dir, default_data_dir=tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post("/api/xray/assets/settings", data={"directory": str(preferred_dir), "version": "v1.2.3"}) - - started = client.post("/api/xray/service/start").get_json() - client.post("/api/xray/service/stop") - logs = client.get("/api/xray/service/logs").get_json()["content"] - - assert started["xray"] == str(default_xray) - assert started["xray_dir"] == str(default_dir) - assert started["fallback_xray_dir"] == str(default_dir) - assert str(default_xray) in logs - - -def test_nodes_api_imports_lists_selects_and_deletes_node(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - - imported = client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - listed = client.get("/api/nodes") - node = listed.get_json()["nodes"][0] - selected = client.post("/api/nodes/select", data={"node_id": node["id"]}) - deleted = client.delete(f"/api/nodes/{node['id']}") - - assert imported.status_code == 200 - assert imported.get_json()["results"][0]["ok"] is True - assert listed.status_code == 200 - assert node["name"] == "ss-node" - assert selected.status_code == 200 - assert selected.get_json()["node"]["id"] == node["id"] - assert deleted.status_code == 200 - assert deleted.get_json()["removed"] is True - assert client.get("/api/nodes").get_json()["nodes"] == [] - - -def test_selecting_node_restarts_running_xray(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stdout="started", echo_args=True) - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": "\n".join([_ss_link("one", "one"), _ss_link("two", "two")])}) - nodes = client.get("/api/nodes").get_json()["nodes"] - client.post("/api/nodes/select", data={"node_id": nodes[0]["id"]}) - client.post( - "/api/xray/config/settings", - data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'}, - ) - first = client.post("/api/xray/service/start").get_json() - - selected = client.post("/api/nodes/select", data={"node_id": nodes[1]["id"]}) - config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8")) - client.post("/api/xray/service/stop") - - assert selected.status_code == 200 - assert selected.get_json()["service"]["running"] is True - assert selected.get_json()["service"]["pid"] != first["pid"] - assert config["outbounds"][0]["settings"]["servers"][0]["password"] == "two" - - -def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - saved = client.post( - "/api/xray/config/settings", - data={"settings_toml": "[core]\nlog_level = \"debug\"\n"}, - ) - generated = client.post("/api/xray/config/generate") - payload = generated.get_json() - - assert saved.status_code == 200 - assert "log_level = \"debug\"" in saved.get_json()["settings_toml"] - assert generated.status_code == 200 - assert payload["config"]["log"]["loglevel"] == "debug" - assert payload["config"]["outbounds"][0]["protocol"] == "shadowsocks" - assert (tmp_path / "config.json").exists() - assert json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))["log"]["loglevel"] == "debug" - assert (tmp_path / "transparent" / "transparent-iptables-setup.sh").exists() - - -def test_saving_settings_restarts_running_xray(tmp_path: Path) -> None: - _write_fake_xray(tmp_path, stdout="settings-started") - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - client.post( - "/api/xray/config/settings", - data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'}, - ) - first = client.post("/api/xray/service/start").get_json() - - saved = client.post( - "/api/xray/config/settings", - data={"settings_toml": '[core]\nlog_level = "debug"\n[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'}, - ) - config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8")) - client.post("/api/xray/service/stop") - - assert saved.status_code == 200 - assert saved.get_json()["service"]["running"] is True - assert saved.get_json()["service"]["pid"] != first["pid"] - assert config["log"]["loglevel"] == "debug" - - -def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - saved = client.post( - "/api/xray/config/settings", - data={ - "core.log_level": "error", - "core.tcp_fast_open": "default", - "core.mux_concurrency": "16", - "inbounds.listen": "127.0.0.1", - "inbounds.socks_port": "20180", - "inbounds.http_port": "0", - "inbounds.rule_socks_port": "0", - "inbounds.rule_http_port": "20181", - "inbounds.auth_user": "alice", - "inbounds.auth_password": "secret", - "inbounds.vmess_port": "0", - "inbounds.inbound_sniffing": "http,tls", - "inbounds.route_only": "on", - "inbounds.api.port": "0", - "inbounds.api.services": "LoggerService", - "routing.mode": "proxy", - "routing.default_rule": "proxy", - "transparent.mode": "close", - "transparent.type": "redirect", - "transparent.port": "52345", - "transparent.socks_port": "52306", - "transparent.ipforward": "off", - "transparent.tun_auto_route": "on", - "transparent.output_bypass_rules": "tcp 117.72.47.28:33010", - "dns.query_strategy": "UseIPv4", - "dns.local_dns_listen": "on", - "dns.antipollution": "closed", - "dns.special_mode": "fakedns", - "dns.fakedns_domains": "geosite:gfw\nkeyword:example", - "dns.rules": "localhost|geosite:private|direct\n8.8.8.8||proxy", - "outbounds.0.tag": "proxy", - "outbounds.0.probe_url": "https://www.gstatic.com/generate_204", - "outbounds.0.probe_interval": "30s", - "outbounds.0.type": "leastping", - "auto_update.gfwlist_auto_update_mode": "none", - "auto_update.gfwlist_auto_update_interval_hour": "0", - "auto_update.subscription_auto_update_mode": "none", - "auto_update.subscription_auto_update_interval_hour": "0", - "auto_update.proxy_mode_when_subscribe": "direct", - }, - ) - generated = client.post("/api/xray/config/generate") - config = generated.get_json()["config"] - - assert saved.status_code == 200 - assert "log_level = \"error\"" in saved.get_json()["settings_toml"] - assert "socks_port = 0" in saved.get_json()["settings_toml"] - assert "http_port = 0" in saved.get_json()["settings_toml"] - assert "auth_user = \"alice\"" in saved.get_json()["settings_toml"] - assert "auth_password = \"secret\"" in saved.get_json()["settings_toml"] - assert "route_only = true" in saved.get_json()["settings_toml"] - assert "output_bypass_rules = \"tcp 117.72.47.28:33010\"" in saved.get_json()["settings_toml"] - assert "special_mode = \"fakedns\"" in saved.get_json()["settings_toml"] - assert "fakedns_domains = \"geosite:gfw\\nkeyword:example\"" in saved.get_json()["settings_toml"] - assert generated.status_code == 200 - assert config["log"]["loglevel"] == "error" - assert any(inbound["tag"] == "rule-mixed" and inbound["port"] == 20181 for inbound in config["inbounds"]) - assert next(inbound for inbound in config["inbounds"] if inbound["tag"] == "rule-mixed")["settings"]["accounts"] == [ - {"user": "alice", "pass": "secret"} - ] - assert config["outbounds"][0]["mux"] == {"enabled": True, "concurrency": 16} - assert config["dns"]["queryStrategy"] == "UseIPv4" - assert config["fakedns"] == [{"ipPool": "198.18.0.0/15", "poolSize": 65535}] - assert {"address": "fakedns", "domains": ["geosite:gfw", "keyword:example"]} in config["dns"]["servers"] - - -def test_xray_config_api_mux_zero_disables_mux(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - - saved = client.post( - "/api/xray/config/settings", - data={ - "core.log_level": "info", - "core.tcp_fast_open": "default", - "core.mux_concurrency": "0", - "inbounds.listen": "127.0.0.1", - "inbounds.socks_port": "20170", - "inbounds.http_port": "20171", - "inbounds.rule_socks_port": "0", - "inbounds.rule_http_port": "20172", - "inbounds.vmess_port": "0", - "inbounds.inbound_sniffing": "http,tls,quic", - "inbounds.api.port": "0", - "routing.mode": "whitelist", - "routing.default_rule": "proxy", - "transparent.mode": "close", - "transparent.type": "redirect", - "transparent.port": "52345", - "transparent.socks_port": "52306", - "transparent.ipforward": "on", - "transparent.tun_auto_route": "off", - "dns.query_strategy": "", - "dns.local_dns_listen": "on", - "dns.antipollution": "closed", - "dns.special_mode": "none", - "outbounds.0.tag": "proxy", - "outbounds.0.probe_url": "https://www.gstatic.com/generate_204", - "outbounds.0.probe_interval": "60s", - "outbounds.0.type": "leastping", - "auto_update.gfwlist_auto_update_mode": "none", - "auto_update.gfwlist_auto_update_interval_hour": "0", - "auto_update.subscription_auto_update_mode": "none", - "auto_update.subscription_auto_update_interval_hour": "0", - "auto_update.proxy_mode_when_subscribe": "direct", - }, - ) - - assert saved.status_code == 200 - assert "mux_enabled = false" in saved.get_json()["settings_toml"] - assert "ipforward = true" in saved.get_json()["settings_toml"] - assert "tun_auto_route = false" in saved.get_json()["settings_toml"] - - -def test_xray_config_api_requires_selected_node(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - - response = client.post("/api/xray/config/generate") - - assert response.status_code == 400 - assert "未选择节点" in response.get_json()["error"] - - -def test_xray_config_api_generates_transparent_rule_files(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - client.post( - "/api/xray/config/settings", - data={ - "transparent.mode": "proxy", - "transparent.type": "tproxy", - "transparent.port": "52345", - "transparent.socks_port": "52306", - "transparent.ipforward": "off", - "transparent.docker_transparent": "off", - "transparent.docker_transparent_cidrs": "172.16.0.0/12;172.18.0.0/16", - "transparent.tproxy_excluded_interfaces": "docker*,veth*", - "transparent.tun_auto_route": "on", - "dns.disable_fallback": "off", - "dns.local_dns_listen": "on", - }, - ) - response = client.post("/api/xray/config/generate") - payload = response.get_json() - - assert response.status_code == 200 - assert payload["transparent_rule_paths"]["ip_forward"] == str(tmp_path / "transparent" / "ip-forward-apply.sh") - assert payload["transparent_rule_paths"]["resolv_setup"] == str(tmp_path / "transparent" / "resolv-hijack-setup.sh") - assert payload["transparent_rule_paths"]["nftables"] == str(tmp_path / "transparent" / "v2raya.nft") - assert "printf '%s' 0 > /proc/sys/net/ipv4/ip_forward" in (tmp_path / "transparent" / "ip-forward-apply.sh").read_text(encoding="utf-8") - assert "TPROXY --on-port 52345" in (tmp_path / "transparent" / "transparent-iptables-setup.sh").read_text(encoding="utf-8") - assert "ip rule add fwmark 0x40/0xc0 table 100" in (tmp_path / "transparent" / "transparent-nft-setup.sh").read_text(encoding="utf-8") - assert "table inet v2raya" in (tmp_path / "transparent" / "v2raya.nft").read_text(encoding="utf-8") - - -def test_xray_config_api_generates_tinytun_config_for_tun_mode(tmp_path: Path) -> None: - app = create_app(tmp_path) - client = app.test_client() - client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")}) - node = client.get("/api/nodes").get_json()["nodes"][0] - client.post("/api/nodes/select", data={"node_id": node["id"]}) - - client.post( - "/api/xray/config/settings", - data={ - "transparent.mode": "proxy", - "transparent.type": "tun", - "transparent.port": "52345", - "transparent.socks_port": "52306", - "transparent.ipforward": "off", - "transparent.tun_auto_route": "off", - "transparent.tun_bypass_interfaces": "172.17.0.0/16", - "transparent.tun_exclude_processes": "xray", - "dns.disable_fallback": "off", - "dns.local_dns_listen": "on", - }, - ) - response = client.post("/api/xray/config/generate") - payload = response.get_json() - tinytun_path = tmp_path / "transparent" / "tinytun.yaml" - - assert response.status_code == 200 - assert payload["transparent_rule_paths"]["tinytun"] == str(tinytun_path) - assert "ip: 198.18.0.1" in tinytun_path.read_text(encoding="utf-8") - assert "address: 127.0.0.1:52345" in tinytun_path.read_text(encoding="utf-8") - assert f"geosite_file: {tmp_path / 'geosite.dat'}" in tinytun_path.read_text(encoding="utf-8") - - -def _ss_link(password: str, name: str) -> str: - user = base64.urlsafe_b64encode(f"chacha20-ietf-poly1305:{password}".encode()).decode().rstrip("=") - return f"ss://{user}@ss.example.net:8388#{name}" - - -def _write_fake_xray( - directory: Path, - *, - stdout: str = "", - stderr: str = "", - exit_code: int = 0, - sleep_seconds: float = 30, - echo_args: bool = False, -) -> Path: - xray = directory / xray_executable_name() - code = _fake_xray_code(stdout=stdout, stderr=stderr, exit_code=exit_code, sleep_seconds=sleep_seconds, echo_args=echo_args) - if os.name == "nt": - try: - os.link(sys.executable, xray) - except OSError: - shutil.copy2(sys.executable, xray) - (directory / "run").write_text(code, encoding="utf-8") - return xray - xray.write_text(f"#!{sys.executable}\n{code}", encoding="utf-8") - xray.chmod(0o755) - return xray - - -def _fake_xray_code( - *, - stdout: str, - stderr: str, - exit_code: int, - sleep_seconds: float, - echo_args: bool, -) -> str: - lines = ["import sys", "import time"] - if stdout: - if echo_args: - lines.append(f"print({stdout!r} + ' ' + ' '.join(sys.argv[1:]), flush=True)") - else: - lines.append(f"print({stdout!r}, flush=True)") - if stderr: - lines.append(f"print({stderr!r}, file=sys.stderr, flush=True)") - if sleep_seconds: - lines.append(f"time.sleep({sleep_seconds!r})") - lines.append(f"raise SystemExit({exit_code})") - return "\n".join(lines) + "\n" - - -def _recording_executor( - commands: list[str], - failures: set[str] | None = None, -): - failures = failures or set() - - def execute(command: list[str]) -> subprocess.CompletedProcess[str]: - script = Path(command[-1]).name - commands.append(script) - return subprocess.CompletedProcess( - args=command, - returncode=1 if script in failures else 0, - stdout="", - stderr=f"{script} failed" if script in failures else "", - ) - - return execute diff --git a/uv.lock b/uv.lock deleted file mode 100644 index cb822ef..0000000 --- a/uv.lock +++ /dev/null @@ -1,197 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - -[[package]] -name = "click" -version = "8.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pyxray" -version = "1.0.5" -source = { editable = "." } -dependencies = [ - { name = "flask" }, - { name = "tomlkit" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "flask", specifier = ">=3.1.2" }, - { name = "tomlkit", specifier = ">=0.13.3" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8.4.2" }] - -[[package]] -name = "tomlkit" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, -] - -[[package]] -name = "werkzeug" -version = "3.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, -]