chore: 清除所有的代码

This commit is contained in:
2026-05-28 14:20:31 +08:00
parent fc95b79092
commit 9cef0e3ee1
84 changed files with 0 additions and 10792 deletions
-28
View File
@@ -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
-84
View File
@@ -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}"
-29
View File
@@ -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
-51
View File
@@ -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"]
-167
View File
@@ -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://<host-ip>: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) |
-16
View File
@@ -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
View File
-54
View File
@@ -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 入站。 |
-34
View File
@@ -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 zipWindows x64 使用 `Xray-windows-64.zip`Linux x64 使用 `Xray-linux-64.zip`。 |
| `geoip_url` / `geosite_url` 非空 | 对应 dat 文件使用自定义 URL,优先于 release zip 内置版本。 |
| Docker 部署 | 资源仍由 Web 下载页处理,不在 Dockerfile 中下载。 |
-61
View File
@@ -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 下只生成 TCPtproxy 下生成 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 规则。 |
-46
View File
@@ -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,避免代理节点域名解析走错。 |
-78
View File
@@ -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/
```
-22
View File
@@ -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` | 订阅更新时连接模式预留。当前无订阅功能。 | 当前不用改。 |
-43
View File
@@ -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:<filename>:<tag>`。 |
| `tags` | 隐藏 | `[]` | 字符串数组 | geosite/geoip/tag 列表。 |
| `match_type` | 隐藏 | `domain` | `domain` / `ip` | 决定写入 Xray rule 的 `domain` 还是 `ip`。 |
| `rule_type` | 隐藏 | `proxy` | `direct` / `proxy` / `block` | 命中后的出口。 |
-75
View File
@@ -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` 解析白名单 CIDRtproxy 下 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 配置。 |
-220
View File
@@ -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`。 |
-74
View File
@@ -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<br/>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<br/>tcp://117.72.47.28:33010] --> O[nat OUTPUT]
O --> TPO[TP_OUT]
TPO --> B{match tcp<br/>117.72.47.28:33010}
B -->|yes| D[RETURN<br/>直连 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` 行为。
-89
View File
@@ -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<br/>v1.mux.cool]
M --> V[VLESS<br/>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<br/>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)
-183
View File
@@ -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:<filename>:<tag>`。 |
| `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 |
-302
View File
@@ -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` | 当前配置实际生成出的清理脚本。 |
-30
View File
@@ -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 = ""
-10
View File
@@ -1,10 +0,0 @@
"""pyxray package."""
from importlib.metadata import PackageNotFoundError, version
__all__ = ["__version__"]
try:
__version__ = version("pyxray")
except PackageNotFoundError:
__version__ = "0.0.0"
-124
View File
@@ -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()
-1
View File
@@ -1 +0,0 @@
"""Small reusable building blocks for pyxray."""
-149
View File
@@ -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))
-21
View File
@@ -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",
]
-27
View File
@@ -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
-13
View File
@@ -1,13 +0,0 @@
from __future__ import annotations
class NodeLinkError(Exception):
"""节点链接处理错误基类。"""
class UnsupportedNodeLinkError(NodeLinkError):
"""链接协议当前不支持。"""
class InvalidNodeLinkError(NodeLinkError):
"""链接协议支持,但内容格式无效。"""
-43
View File
@@ -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
-110
View File
@@ -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
-61
View File
@@ -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,
}
-205
View File
@@ -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)
}
-28
View File
@@ -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 '<empty>'}")
-11
View File
@@ -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",
]
-92
View File
@@ -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
-42
View File
@@ -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"},
},
)
-60
View File
@@ -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,
)
-118
View File
@@ -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
-92
View File
@@ -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
-75
View File
@@ -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)
-334
View File
@@ -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)
-22
View File
@@ -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",
]
-463
View File
@@ -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
-73
View File
@@ -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
-225
View File
@@ -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)
}
-242
View File
@@ -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")
-73
View File
@@ -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
-253
View File
@@ -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
@@ -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_RULETCP 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)
-314
View File
@@ -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<outbound>[^\]]+)\] for \[(?P<target>[^\]]+)\]")
_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 ""
-265
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
"""Flask web UI for pyxray."""
-14
View File
@@ -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)
-55
View File
@@ -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="",
)
-72
View File
@@ -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"]
-89
View File
@@ -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("/<node_id>")
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})
-98
View File
@@ -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)
-183
View File
@@ -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);
}
-562
View File
@@ -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 = `<div class="rounded-2xl border border-zinc-800 bg-zinc-950/60 px-4 py-3 text-sm text-zinc-400">还没有导入节点。</div>`;
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 `
<div class="rounded-2xl border ${selectedClass} px-4 py-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-medium">${escapeHtml(node.name)}</div>
<div class="mt-1 font-mono text-xs text-zinc-500">${escapeHtml(node.protocol)}://${escapeHtml(node.server)}:${node.port}</div>
</div>
<div class="flex gap-2">
<button class="rounded-full border border-zinc-700 px-3 py-1 text-xs text-zinc-200 hover:bg-zinc-800" data-action="select" data-node-id="${escapeHtml(node.id)}">${selectedText}</button>
<button class="rounded-full border border-red-500/30 px-3 py-1 text-xs text-red-300 hover:bg-red-500/10" data-action="delete" data-node-id="${escapeHtml(node.id)}">删除</button>
</div>
</div>
</div>
`;
}).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
? `<p class="mt-2 text-xs text-zinc-500">下载进度:${step.percent}%</p>`
: "";
return `
<div class="rounded-2xl border ${border} px-4 py-3">
<div class="flex items-center justify-between gap-3">
<span class="text-sm font-medium">${escapeHtml(step.name)}</span>
<span class="text-sm ${color}">${status}</span>
</div>
<p class="mt-2 break-all font-mono text-xs leading-5 text-zinc-400">${escapeHtml(step.detail || "")}</p>
${downloadHint}
</div>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
-26
View File
@@ -1,26 +0,0 @@
<section class="config-card">
<h3 class="config-card-title">核心</h3>
<div class="config-grid">
<label class="config-field">
<span>日志等级</span>
<select name="core.log_level">
{% for value in ["debug", "info", "warning", "error", "none"] %}
<option value="{{ value }}" {% if settings.core.log_level == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>Mux 并发数</span>
<select name="core.mux_concurrency">
{% for value in [0, 1, 2, 4, 8, 16, 32, 64] %}
<option value="{{ value }}" {% if (not settings.core.mux_enabled and value == 0) or (settings.core.mux_enabled and settings.core.mux_concurrency == value) %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<input name="core.tcp_fast_open" type="hidden" value="default" />
</div>
<label class="config-field mt-4">
<span>transparent</span>
<textarea name="transparent.output_bypass_rules" rows="3" placeholder="tcp 117.72.47.28:33010&#10;all 192.168.0.0/24">{{ settings.transparent.output_bypass_rules }}</textarea>
</label>
</section>
-55
View File
@@ -1,55 +0,0 @@
<section class="config-card">
<h3 class="config-card-title">DNS</h3>
<div class="config-grid config-grid-three">
<label class="config-field">
<span>查询策略</span>
<select name="dns.query_strategy">
{% for value in ["", "UseIP", "UseIPv4", "UseIPv6"] %}
<option value="{{ value }}" {% if settings.dns.query_strategy == value %}selected{% endif %}>{{ value or "默认" }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>防污染模式</span>
<select name="dns.antipollution">
{% for value in ["closed", "none", "dnsforward", "doh", "advanced"] %}
<option value="{{ value }}" {% if settings.dns.antipollution == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>特殊模式</span>
<select name="dns.special_mode">
{% for value in ["none", "supervisor", "fakedns"] %}
<option value="{{ value }}" {% if settings.dns.special_mode == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="config-grid config-grid-two mt-4">
<label class="config-field">
<span>禁用 fallback</span>
<select name="dns.disable_fallback">
<option value="off" {% if not settings.dns.disable_fallback %}selected{% endif %}>关闭</option>
<option value="on" {% if settings.dns.disable_fallback %}selected{% endif %}>开启</option>
</select>
</label>
<label class="config-field">
<span>监听本地 DNS</span>
<select name="dns.local_dns_listen">
<option value="off" {% if not settings.dns.local_dns_listen %}selected{% endif %}>关闭</option>
<option value="on" {% if settings.dns.local_dns_listen %}selected{% endif %}>开启</option>
</select>
</label>
</div>
<label class="config-field mt-4">
<span>FakeDNS 域名范围,每行一个 domain/geosite/keyword 规则</span>
<textarea name="dns.fakedns_domains" placeholder="geosite:geolocation-!cn">{{ settings.dns.fakedns_domains }}</textarea>
</label>
<label class="config-field mt-4">
<span>DNS 规则,每行 server|domains|outbound</span>
<textarea name="dns.rules">{% for item in settings.dns.rules %}{{ item.server }}|{{ item.domains }}|{{ item.outbound }}
{% endfor %}</textarea>
</label>
</section>
@@ -1,56 +0,0 @@
<section class="config-card">
<h3 class="config-card-title">入站端口</h3>
<div class="config-grid">
<label class="config-field">
<span>监听地址</span>
<select name="inbounds.listen">
{% for value in ["127.0.0.1", "0.0.0.0"] %}
<option value="{{ value }}" {% if settings.inbounds.listen == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>Mixed 端口</span>
<input name="inbounds.rule_http_port" type="number" min="0" max="65535" value="{{ settings.inbounds.rule_http_port }}" />
</label>
</div>
<div class="config-grid mt-4">
<label class="config-field">
<span>认证用户名</span>
<input name="inbounds.auth_user" type="text" autocomplete="username" value="{{ settings.inbounds.auth_user }}" placeholder="留空使用 noauth" />
</label>
<label class="config-field">
<span>认证密码</span>
<input name="inbounds.auth_password" type="password" autocomplete="current-password" value="{{ settings.inbounds.auth_password }}" placeholder="留空使用 noauth" />
</label>
</div>
<div class="config-grid mt-4">
<label class="config-field">
<span>Sniffing</span>
<select name="inbounds.inbound_sniffing">
{% for value in ["disable", "http,tls", "http,tls,quic"] %}
<option value="{{ value }}" {% if settings.inbounds.inbound_sniffing == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>Sniffing 仅用于路由</span>
<select name="inbounds.route_only">
<option value="off" {% if not settings.inbounds.route_only %}selected{% endif %}>关闭</option>
<option value="on" {% if settings.inbounds.route_only %}selected{% endif %}>开启</option>
</select>
</label>
</div>
<input name="inbounds.socks_port" type="hidden" value="0" />
<input name="inbounds.http_port" type="hidden" value="0" />
<input name="inbounds.rule_socks_port" type="hidden" value="0" />
<input name="inbounds.vmess_port" type="hidden" value="0" />
<input name="inbounds.api.port" type="hidden" value="0" />
<input name="inbounds.port_sharing" type="hidden" value="off" />
<input name="inbounds.domains_excluded" type="hidden" value="" />
<input name="inbounds.custom" type="hidden" value="" />
<input name="inbounds.api.services" type="hidden" value="{{ settings.inbounds.api.services | join(',') }}" />
</section>
-18
View File
@@ -1,18 +0,0 @@
<section class="config-card">
<h3 class="config-card-title">路由</h3>
<div class="config-grid">
<label class="config-field">
<span>路由模式</span>
<select name="routing.mode">
{% for value in ["whitelist", "gfwlist", "proxy", "direct", "block"] %}
<option value="{{ value }}" {% if settings.routing.mode == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
</div>
<label class="config-field mt-4">
<span>自定义规则</span>
<textarea name="routing.routing_a" placeholder="domain(geosite:google)->proxy&#10;ip(geoip:cn)->direct">{{ settings.routing.routing_a }}</textarea>
</label>
<input name="routing.default_rule" type="hidden" value="proxy" />
</section>
@@ -1,64 +0,0 @@
<section class="config-card">
<h3 class="config-card-title">透明代理</h3>
<div class="config-grid config-grid-two">
<label class="config-field">
<span>模式</span>
<select name="transparent.mode">
{% for value in ["close", "proxy", "whitelist", "gfwlist", "pac"] %}
<option value="{{ value }}" {% if settings.transparent.mode == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>类型</span>
<select id="transparent-type" name="transparent.type">
{% for value in ["redirect", "tproxy", "system_proxy", "tun"] %}
<option value="{{ value }}" {% if settings.transparent.type == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="config-grid config-grid-two mt-4">
<label class="config-field">
<span>透明代理端口</span>
<input name="transparent.port" type="number" min="0" max="65535" value="{{ settings.transparent.port }}" />
</label>
<label class="config-field">
<span>系统代理 SOCKS 端口</span>
<input name="transparent.socks_port" type="number" min="0" max="65535" value="{{ settings.transparent.socks_port }}" />
</label>
</div>
<div class="config-grid config-grid-two mt-4">
<label class="config-field">
<span>启用 IP Forward</span>
<select name="transparent.ipforward">
<option value="off" {% if not settings.transparent.ipforward %}selected{% endif %}>关闭</option>
<option value="on" {% if settings.transparent.ipforward %}selected{% endif %}>开启</option>
</select>
</label>
<label class="config-field">
<span>TUN 自动路由</span>
<select name="transparent.tun_auto_route">
<option value="off" {% if not settings.transparent.tun_auto_route %}selected{% endif %}>关闭</option>
<option value="on" {% if settings.transparent.tun_auto_route %}selected{% endif %}>开启</option>
</select>
</label>
</div>
<div class="config-grid config-grid-two mt-4">
<label class="config-field">
<span>排除接口</span>
<input id="tproxy-excluded-interfaces" name="transparent.tproxy_excluded_interfaces" value="{{ settings.transparent.tproxy_excluded_interfaces }}" />
</label>
<label class="config-field">
<span>Docker 容器透明代理</span>
<select id="docker-transparent" name="transparent.docker_transparent">
<option value="on" {% if settings.transparent.docker_transparent %}selected{% endif %}>开启</option>
<option value="off" {% if not settings.transparent.docker_transparent %}selected{% endif %}>关闭</option>
</select>
</label>
</div>
<label class="config-field mt-4">
<span>Docker 透明代理关注网段</span>
<input id="docker-transparent-cidrs" name="transparent.docker_transparent_cidrs" value="{{ settings.transparent.docker_transparent_cidrs }}" placeholder="172.16.0.0/12;172.18.0.0/16" />
</label>
</section>
-57
View File
@@ -1,57 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>pyxray</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
</head>
<body class="min-h-screen bg-zinc-950 text-zinc-100">
<main class="mx-auto grid min-h-screen w-full max-w-7xl content-start gap-6 px-5 py-8">
<header class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-3xl font-semibold tracking-tight">pyxray</h1>
<p class="mt-2 text-sm text-zinc-400">Xray 资源、节点和配置生成控制台</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
id="xray-service-toggle"
class="rounded-full border px-5 py-2 text-sm font-medium {% if service_status.running %}border-red-500/30 bg-red-500/10 text-red-300{% else %}border-emerald-500/30 bg-emerald-500/10 text-emerald-300{% endif %}"
type="button"
data-running="{{ 'true' if service_status.running else 'false' }}"
>
{{ "关闭" if service_status.running else "开启" }}: {{ selected_name[:8] if selected_name else "未选择" }}
</button>
</div>
</header>
<nav class="tab-nav rounded-3xl border border-zinc-800 bg-zinc-900/80 p-2" aria-label="功能切换">
<button class="tab-button" type="button" data-tab-target="nodes">节点</button>
<button class="tab-button" type="button" data-tab-target="config">配置</button>
<button class="tab-button" type="button" data-tab-target="download">下载</button>
<button class="tab-button" type="button" data-tab-target="logs">日志</button>
</nav>
<section class="tab-panel" data-tab-panel="nodes">
{% include "partials/nodes_tab.html" %}
</section>
<section class="tab-panel" data-tab-panel="config">
{% include "partials/config_tab.html" %}
</section>
<section class="tab-panel" data-tab-panel="download">
{% include "partials/download_tab.html" %}
</section>
<section class="tab-panel" data-tab-panel="logs">
{% include "partials/logs_tab.html" %}
</section>
</main>
<script id="initial-nodes" type="application/json">{{ nodes | tojson }}</script>
<script id="selected-node-id" type="application/json">{{ selected_id | tojson }}</script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
@@ -1,20 +0,0 @@
<div class="grid gap-6 rounded-3xl border border-zinc-800 bg-zinc-900 p-6">
<div>
<h2 class="text-xl font-medium">配置设置</h2>
<p class="mt-1 text-sm text-zinc-500">通过界面保存 settings.toml。</p>
</div>
<form id="settings-form" class="grid content-start gap-5">
{% include "configs/core.html" %}
{% include "configs/inbounds.html" %}
{% include "configs/routing.html" %}
{% include "configs/transparent.html" %}
{% include "configs/dns.html" %}
<div id="config-message" class="hidden rounded-2xl border px-4 py-3 text-sm"></div>
<div id="config-actions" class="config-actions" aria-label="配置修改操作">
<button class="config-action-button config-action-save" type="submit" aria-label="保存设置" title="保存设置"></button>
<button id="config-reset-button" class="config-action-button config-action-reset" type="button" aria-label="撤回修改" title="撤回修改">×</button>
</div>
</form>
</div>
@@ -1,89 +0,0 @@
<div class="grid gap-6 rounded-3xl border border-zinc-800 bg-zinc-900 p-6 lg:grid-cols-[1.05fr_0.95fr]">
<div class="grid content-start gap-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 class="text-xl font-medium">资源下载</h2>
<p class="mt-1 text-sm text-zinc-500">xray / geoip.dat / geosite.dat</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-3">
{% for name, exists in status.files.items() %}
<div class="rounded-2xl border border-zinc-800 bg-zinc-950/60 px-4 py-3" data-asset-name="{{ name }}">
<div class="font-mono text-sm">{{ name }}</div>
{% if exists %}
<div class="mt-2 text-sm text-emerald-300" data-asset-state>存在</div>
{% else %}
<div class="mt-2 text-sm text-red-300" data-asset-state>缺失</div>
{% endif %}
</div>
{% endfor %}
</div>
<form id="asset-form" class="grid gap-4">
<input id="download-target" name="target" type="hidden" value="all" />
<label class="grid gap-2">
<span class="text-sm text-zinc-400">保存目录</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="directory" value="{{ form.directory }}" />
</label>
<label class="grid gap-2">
<span class="text-sm text-zinc-400">Xray 版本</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="version" value="{{ form.version }}" />
</label>
<label class="grid gap-2">
<span class="text-sm text-zinc-400">下载代理</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="proxy_url" placeholder="例如 http://user:pass@127.0.0.1:20172;空则使用系统代理" value="{{ form.proxy_url }}" />
</label>
<label class="grid gap-2">
<span class="text-sm text-zinc-400">Release Zip 地址</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="archive_url" placeholder="{{ official_url }}" value="{{ form.archive_url }}" />
</label>
<label class="grid gap-2">
<span class="text-sm text-zinc-400">geoip.dat 地址</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="geoip_url" placeholder="空则使用 zip 内置 geoip.dat" value="{{ form.geoip_url }}" />
</label>
<label class="grid gap-2">
<span class="text-sm text-zinc-400">geosite.dat 地址</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="geosite_url" placeholder="空则使用 zip 内置 geosite.dat" value="{{ form.geosite_url }}" />
</label>
<label class="flex items-center gap-3 rounded-2xl border border-zinc-800 bg-zinc-950/60 px-4 py-3">
<input class="h-4 w-4 accent-zinc-100" name="force" type="checkbox" />
<span class="text-sm text-zinc-300">重新下载并覆盖已有文件</span>
</label>
<div class="grid gap-3 pt-2 sm:grid-cols-2 xl:grid-cols-4">
<button class="download-button rounded-2xl bg-zinc-100 px-5 py-3 text-sm font-medium text-zinc-950 hover:bg-white" data-download-target="all" type="submit">下载所有</button>
<button class="download-button rounded-2xl border border-zinc-700 bg-zinc-800 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-700" data-download-target="xray" type="submit">下载 xray</button>
<button class="download-button rounded-2xl border border-zinc-700 bg-zinc-800 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-700" data-download-target="geoip" type="submit">下载 geoip</button>
<button class="download-button rounded-2xl border border-zinc-700 bg-zinc-800 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-700" data-download-target="geosite" type="submit">下载 geosite</button>
</div>
<button id="cancel-button" class="hidden rounded-2xl border border-red-500/30 bg-red-500/10 px-5 py-3 text-sm font-medium text-red-300 hover:bg-red-500/20" type="button">停止下载</button>
</form>
</div>
<aside class="grid content-start gap-5 rounded-3xl border border-zinc-800 bg-zinc-950/50 p-5">
<div class="rounded-2xl border border-zinc-800 bg-zinc-950 p-4">
<div class="mb-3 flex items-center justify-between gap-4">
<span class="text-sm text-zinc-400">当前进度</span>
<span id="progress-badge" class="rounded-full border border-zinc-700 px-3 py-1 text-sm text-zinc-400">等待操作</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-zinc-800">
<div id="main-progress-bar" class="h-full w-0 rounded-full bg-zinc-100"></div>
</div>
<div id="main-progress-text" class="mt-3 font-mono text-xs text-zinc-500">0%</div>
</div>
<div id="progress-panel" class="grid gap-3">
<div class="rounded-2xl border border-zinc-800 bg-zinc-950/60 px-4 py-3">
<div class="text-sm text-zinc-400">等待点击下载按钮。</div>
</div>
</div>
</aside>
</div>
@@ -1,22 +0,0 @@
<div class="grid gap-6 rounded-3xl border border-zinc-800 bg-zinc-900 p-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 class="text-xl font-medium">运行日志</h2>
<p class="mt-1 text-sm text-zinc-500">Xray stdout / stderr,日志文件:{{ log_path }}</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm text-zinc-400">
<select id="log-format-select" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 text-sm font-medium text-zinc-100 outline-none focus:border-emerald-500">
<option value="compact" selected>解析优化日志</option>
<option value="raw">原始日志</option>
</select>
</label>
<button id="refresh-logs-button" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-800" type="button">刷新日志</button>
<button id="clear-logs-button" class="rounded-2xl border border-red-500/30 bg-red-500/10 px-5 py-3 text-sm font-medium text-red-300 hover:bg-red-500/20" type="button">清除日志</button>
</div>
</div>
<div class="h-[34rem] overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950/80">
<pre id="xray-log-content" class="h-full overflow-auto p-4 font-mono text-xs leading-5 text-zinc-300">{{ log_content or "暂无日志。" }}</pre>
</div>
</div>
@@ -1,23 +0,0 @@
<div class="grid gap-6 rounded-3xl border border-zinc-800 bg-zinc-900 p-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 class="text-xl font-medium">节点管理</h2>
<p class="mt-1 text-sm text-zinc-500">导入、选择和删除用于生成配置的节点。</p>
</div>
<span id="node-count" class="rounded-full border border-zinc-800 px-3 py-1 text-sm text-zinc-400">0 个节点</span>
</div>
<form id="node-import-form" class="grid gap-4">
<label class="grid gap-2">
<span class="text-sm text-zinc-400">节点链接</span>
<textarea class="min-h-36 rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 font-mono text-sm outline-none focus:border-zinc-400" name="links" placeholder="每行一个 vless:// vmess:// trojan:// ss:// 链接"></textarea>
</label>
<div class="flex flex-wrap items-center justify-between gap-3">
<button class="rounded-2xl bg-zinc-100 px-5 py-3 text-sm font-medium text-zinc-950 hover:bg-white" type="submit">导入节点</button>
<span id="selected-node-label" class="rounded-full border border-zinc-800 px-3 py-1 text-sm text-zinc-400">未选择节点</span>
</div>
</form>
<div id="node-message" class="hidden rounded-2xl border px-4 py-3 text-sm"></div>
<div id="node-list" class="grid gap-3"></div>
</div>
-233
View File
@@ -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/<job_id>")
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/<job_id>/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"
-257
View File
@@ -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
-170
View File
@@ -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)
-29
View File
@@ -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" \
.
-72
View File
@@ -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}"
-212
View File
@@ -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": "",
}
-71
View File
@@ -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"
-305
View File
@@ -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)
-208
View File
@@ -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",
}
-458
View File
@@ -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)
-119
View File
@@ -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
-138
View File
@@ -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
-12
View File
@@ -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"
-890
View File
@@ -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
Generated
-197
View File
@@ -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" },
]