Compare commits
3 Commits
@@ -37,7 +37,7 @@ flowchart LR
|
||||
| `data/settings.toml` | 保存配置页设置。 |
|
||||
| `data/download.toml` | 保存下载页设置。 |
|
||||
| `data/config.json` | 生成给 Xray 使用的配置。 |
|
||||
| `data/xray/` | 保存 `xray`、`geoip.dat`、`geosite.dat`。 |
|
||||
| `data/xray/` | 保存 `xray` / `xray.exe`、`geoip.dat`、`geosite.dat`。 |
|
||||
| `data/transparent/` | 保存透明代理脚本、nftables 配置和 `tinytun.yaml`。 |
|
||||
| `data/xray.log` | 保存 Xray 输出和 pyxray 运行日志。 |
|
||||
|
||||
@@ -82,6 +82,35 @@ http://<host-ip>:8080
|
||||
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
|
||||
```
|
||||
|
||||
## 透明代理建议
|
||||
|
||||
| 场景 | 建议 |
|
||||
@@ -133,5 +162,6 @@ http://<host-ip>:8080
|
||||
| 路由 | [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) |
|
||||
|
||||
@@ -24,6 +24,7 @@ UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列
|
||||
| 路由 | [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` 下载设置。 |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| 设置 | UI | 默认值 | 可选值 | 作用 | 什么时候修改 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `directory` | 下载页 | `data/xray`;Docker 中通常为 `/config/xray` | 路径 | Xray 资源保存目录。 | Docker 部署保持 `/config/xray`;本机运行可用默认值。 |
|
||||
| `version` | 下载页 | `v26.5.9` | Xray release tag | 官方 release 版本。 | 需要固定或升级 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 数据源时修改。 |
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
| 文件 | 作用 |
|
||||
| --- | --- |
|
||||
| `xray` | Xray 可执行文件。 |
|
||||
| `xray` / `xray.exe` | Xray 可执行文件;Windows 下为 `xray.exe`,其它平台为 `xray`。 |
|
||||
| `geoip.dat` | IP 地理库,用于 `geoip:*` 规则。 |
|
||||
| `geosite.dat` | 域名分类库,用于 `geosite:*` 规则。 |
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| `target = all` | 确保三个必需文件都存在。 |
|
||||
| 首次无 `download.toml` | 尝试在 5 秒内获取 Xray-core 最新 release tag;失败则使用内置回退版本。 |
|
||||
| `force = false` 且文件存在 | 跳过已有文件。 |
|
||||
| `force = true` | 覆盖目标文件。 |
|
||||
| `archive_url` 为空 | 按当前平台选择官方 release zip;Windows x64 使用 `Xray-windows-64.zip`,Linux x64 使用 `Xray-linux-64.zip`。 |
|
||||
| `geoip_url` / `geosite_url` 非空 | 对应 dat 文件使用自定义 URL,优先于 release zip 内置版本。 |
|
||||
| Docker 部署 | 资源仍由 Web 下载页处理,不在 Dockerfile 中下载。 |
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# 透明代理 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` | 当前配置实际生成出的清理脚本。 |
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pyxray"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
description = "A lightweight Linux xray control plane."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
+98
-5
@@ -1,30 +1,123 @@
|
||||
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 in (None, "web"):
|
||||
run_web(args.host, args.port, args.xray_dir)
|
||||
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="0.0.0.0", help="监听地址,默认 0.0.0.0")
|
||||
parser.add_argument("--port", default=8000, type=int, help="监听端口,默认 8000")
|
||||
parser.add_argument("--xray-dir", default="data/xray", help="Xray 资源目录,默认 data/xray")
|
||||
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__":
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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))
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
import tomlkit
|
||||
|
||||
from pyxray.libs.xray_assets import DEFAULT_VERSION
|
||||
from pyxray.libs.xray_assets import default_xray_version
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -16,7 +16,7 @@ class XrayAssetSettings:
|
||||
"""Xray 资源下载页面的可持久化设置。"""
|
||||
|
||||
directory: str = "data/xray"
|
||||
version: str = DEFAULT_VERSION
|
||||
version: str = ""
|
||||
archive_url: str = ""
|
||||
geoip_url: str = ""
|
||||
geosite_url: str = ""
|
||||
@@ -51,7 +51,7 @@ class XrayAssetSettingsStore:
|
||||
|
||||
def load(self) -> XrayAssetSettings:
|
||||
if not self.path.exists():
|
||||
return XrayAssetSettings(directory=self.default_directory)
|
||||
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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
@@ -12,13 +14,17 @@ 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_FILES = ("xray", "geoip.dat", "geosite.dat")
|
||||
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)
|
||||
@@ -59,10 +65,57 @@ class XrayAssetStatus:
|
||||
return tuple(name for name, exists in self.files.items() if not exists)
|
||||
|
||||
|
||||
def official_archive_url(version: str = DEFAULT_VERSION, archive_name: str = DEFAULT_ARCHIVE_NAME) -> str:
|
||||
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}"
|
||||
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:
|
||||
@@ -71,7 +124,7 @@ def check_xray_assets(directory: str | Path) -> XrayAssetStatus:
|
||||
directory = Path(directory)
|
||||
return XrayAssetStatus(
|
||||
directory=directory,
|
||||
files={name: (directory / name).exists() for name in REQUIRED_FILES},
|
||||
files={name: (directory / name).exists() for name in required_files()},
|
||||
)
|
||||
|
||||
|
||||
@@ -116,17 +169,18 @@ def ensure_xray_assets(
|
||||
if target not in ASSET_TARGETS:
|
||||
raise ValueError(f"unsupported xray asset target: {target}")
|
||||
|
||||
xray = directory / "xray"
|
||||
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"
|
||||
if name == xray_name
|
||||
or (name == "geoip.dat" and geoip_url is None)
|
||||
or (name == "geosite.dat" and geosite_url is None)
|
||||
}
|
||||
@@ -174,6 +228,11 @@ def download_bytes(url: str, *, proxy_url: str | None = None) -> bytes:
|
||||
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,
|
||||
@@ -250,9 +309,9 @@ def _requested_files(target: str) -> tuple[str, ...]:
|
||||
"""把用户选择的下载目标转换为实际文件名。"""
|
||||
|
||||
if target == "all":
|
||||
return REQUIRED_FILES
|
||||
return required_files()
|
||||
if target == "xray":
|
||||
return ("xray",)
|
||||
return (xray_executable_name(),)
|
||||
if target == "geoip":
|
||||
return ("geoip.dat",)
|
||||
if target == "geosite":
|
||||
|
||||
@@ -31,6 +31,8 @@ def generate_xray_config(node: Node, settings: XrayConfigSettings | None = None)
|
||||
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))
|
||||
@@ -157,9 +159,12 @@ def _transparent_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]:
|
||||
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": settings.inbounds.inbound_sniffing.split(","),
|
||||
"destOverride": dest_override,
|
||||
"domainsExcluded": _split_lines(settings.inbounds.domains_excluded),
|
||||
"routeOnly": settings.inbounds.route_only,
|
||||
}
|
||||
@@ -207,6 +212,9 @@ def _dns_inbound_tags(settings: XrayConfigSettings) -> list[str]:
|
||||
|
||||
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)
|
||||
@@ -230,6 +238,16 @@ def _build_dns(settings: XrayConfigSettings, node: Node) -> dict[str, Any]:
|
||||
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
|
||||
|
||||
@@ -126,6 +126,7 @@ class DnsSettings:
|
||||
)
|
||||
antipollution: str = "closed"
|
||||
special_mode: str = "none"
|
||||
fakedns_domains: str = "geosite:geolocation-!cn"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import asdict
|
||||
|
||||
from flask import Blueprint, Flask, current_app, render_template
|
||||
|
||||
from pyxray.libs.xray_assets import DEFAULT_VERSION, check_xray_assets, official_archive_url
|
||||
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
|
||||
@@ -42,7 +42,7 @@ def index(): # noqa: ANN202
|
||||
status=asdict(status),
|
||||
ready=status.ready,
|
||||
missing=status.missing,
|
||||
official_url=official_archive_url(form["version"] or DEFAULT_VERSION),
|
||||
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 "",
|
||||
|
||||
+7
-13
@@ -11,6 +11,7 @@ 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
|
||||
@@ -28,13 +29,13 @@ def create_app(
|
||||
"""创建 pyxray Web 应用。"""
|
||||
|
||||
app = Flask(__name__)
|
||||
data_dir = Path(default_data_dir) if default_data_dir is not None else _default_data_dir(default_xray_dir)
|
||||
config_path = data_dir / "config.json"
|
||||
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, default_xray_dir, data_dir / "download.toml")
|
||||
register_nodes(app, data_dir / "nodes.toml")
|
||||
register_xray_config(app, data_dir / "settings.toml", config_path)
|
||||
register_xray_service(app, xray_dir=default_xray_dir, config_path=config_path, log_path=data_dir / "xray.log")
|
||||
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
|
||||
@@ -51,13 +52,6 @@ def run_web(host: str, port: int, default_xray_dir: str | Path = "data/xray") ->
|
||||
create_app(default_xray_dir).run(host=host, port=port)
|
||||
|
||||
|
||||
def _default_data_dir(default_xray_dir: str | Path) -> Path:
|
||||
"""根据资源目录推导默认数据目录。"""
|
||||
|
||||
path = Path(default_xray_dir)
|
||||
return path.parent if path.name == "xray" else path
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
</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 }}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
)
|
||||
@@ -78,7 +79,7 @@ def default_asset_form(directory: str) -> dict[str, str]:
|
||||
|
||||
return {
|
||||
"directory": directory,
|
||||
"version": DEFAULT_VERSION,
|
||||
"version": default_xray_version(),
|
||||
"archive_url": "",
|
||||
"geoip_url": "",
|
||||
"geosite_url": "",
|
||||
@@ -105,7 +106,7 @@ def _form_values() -> dict[str, str]:
|
||||
def _settings_from_form(form: dict[str, str]) -> XrayAssetSettings:
|
||||
return XrayAssetSettings(
|
||||
directory=form["directory"],
|
||||
version=form["version"] or DEFAULT_VERSION,
|
||||
version=form["version"] or default_xray_version(),
|
||||
archive_url=form["archive_url"],
|
||||
geoip_url=form["geoip_url"],
|
||||
geosite_url=form["geosite_url"],
|
||||
|
||||
@@ -203,6 +203,7 @@ def _settings_from_request() -> XrayConfigSettings:
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,17 @@ from urllib.response import addinfourl
|
||||
|
||||
import pytest
|
||||
|
||||
from pyxray.libs.xray_assets import DEFAULT_VERSION, download_bytes, download_bytes_stream, ensure_xray_assets, official_archive_url
|
||||
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:
|
||||
@@ -19,13 +29,46 @@ def _zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
|
||||
def test_official_archive_url_defaults_to_xray_core_v26_5_9() -> None:
|
||||
assert DEFAULT_VERSION == "v26.5.9"
|
||||
assert official_archive_url() == "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-64.zip"
|
||||
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": b"bin",
|
||||
xray_name: b"bin",
|
||||
"geoip.dat": b"geoip",
|
||||
"geosite.dat": b"geosite",
|
||||
"README.md": b"ignored",
|
||||
@@ -42,13 +85,13 @@ def test_ensure_xray_assets_extracts_official_archive_files(tmp_path) -> None:
|
||||
assert result.ready is True
|
||||
assert result.downloaded == ("archive",)
|
||||
assert calls == [official_archive_url()]
|
||||
assert (tmp_path / "xray").read_bytes() == b"bin"
|
||||
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({"xray": b"bin", "geoip.dat": b"geoip", "geosite.dat": b"geosite"})
|
||||
archive = _zip_bytes({required_files()[0]: b"bin", "geoip.dat": b"geoip", "geosite.dat": b"geosite"})
|
||||
calls = []
|
||||
|
||||
def downloader(url: str) -> bytes:
|
||||
@@ -61,7 +104,8 @@ def test_version_and_archive_url_can_be_overridden(tmp_path) -> None: # noqa: A
|
||||
|
||||
|
||||
def test_dat_urls_override_archive_dat_files(tmp_path) -> None: # noqa: ANN001
|
||||
archive = _zip_bytes({"xray": b"bin", "geoip.dat": b"old-geoip", "geosite.dat": b"old-geosite"})
|
||||
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",
|
||||
@@ -76,13 +120,14 @@ def test_dat_urls_override_archive_dat_files(tmp_path) -> None: # noqa: ANN001
|
||||
)
|
||||
|
||||
assert result.downloaded == ("archive", "geoip.dat", "geosite.dat")
|
||||
assert (tmp_path / "xray").read_bytes() == b"bin"
|
||||
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
|
||||
(tmp_path / "xray").write_bytes(b"bin")
|
||||
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")
|
||||
|
||||
@@ -90,7 +135,7 @@ def test_existing_files_skip_download(tmp_path) -> None: # noqa: ANN001
|
||||
|
||||
assert result.ready is True
|
||||
assert result.downloaded == ()
|
||||
assert result.skipped == ("xray", "geoip.dat", "geosite.dat")
|
||||
assert result.skipped == (xray_name, "geoip.dat", "geosite.dat")
|
||||
|
||||
|
||||
def test_force_redownloads_selected_existing_file(tmp_path) -> None: # noqa: ANN001
|
||||
@@ -116,7 +161,7 @@ def test_force_redownloads_selected_existing_file(tmp_path) -> None: # noqa: AN
|
||||
|
||||
|
||||
def test_missing_required_file_raises_after_bad_archive(tmp_path) -> None: # noqa: ANN001
|
||||
archive = _zip_bytes({"xray": b"bin", "geoip.dat": b"geoip"})
|
||||
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)
|
||||
|
||||
@@ -89,6 +89,8 @@ def test_settings_defaults_match_v2raya_core_values() -> None:
|
||||
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"),
|
||||
@@ -403,6 +405,30 @@ def test_generate_dns_rules_and_dns_routing() -> None:
|
||||
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"
|
||||
|
||||
@@ -2,8 +2,12 @@ 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
|
||||
@@ -58,9 +62,7 @@ def test_transparent_runtime_auto_backend_falls_back_to_nft_when_iptables_setup_
|
||||
|
||||
|
||||
def test_xray_service_manager_reports_inbound_port_conflict(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
xray.chmod(0o755)
|
||||
_write_fake_xray(tmp_path)
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
@@ -101,3 +103,17 @@ def _executor(commands: list[list[str]], failures: set[str] | None = None):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
@@ -3,16 +3,18 @@ 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
|
||||
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").write_bytes(b"bin")
|
||||
(tmp_path / xray_executable_name()).write_bytes(b"bin")
|
||||
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
@@ -42,12 +44,13 @@ def test_ensure_api_uses_form_values(monkeypatch, tmp_path: Path) -> None: # no
|
||||
captured["directory"] = directory
|
||||
captured["options"] = options
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
(Path(directory) / "xray").write_bytes(b"bin")
|
||||
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=Path(directory) / "xray",
|
||||
xray=xray,
|
||||
geoip=Path(directory) / "geoip.dat",
|
||||
geosite=Path(directory) / "geosite.dat",
|
||||
downloaded=("archive",),
|
||||
@@ -121,6 +124,17 @@ def test_asset_settings_api_persists_download_form_values(tmp_path: Path) -> Non
|
||||
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)
|
||||
@@ -131,12 +145,13 @@ def test_job_records_real_download_percent(monkeypatch, tmp_path: Path) -> None:
|
||||
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)
|
||||
(Path(directory) / "xray").write_bytes(b"bin")
|
||||
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=Path(directory) / "xray",
|
||||
xray=xray,
|
||||
geoip=Path(directory) / "geoip.dat",
|
||||
geosite=Path(directory) / "geosite.dat",
|
||||
downloaded=("archive",),
|
||||
@@ -173,9 +188,7 @@ def test_cancel_job_api_marks_job_cancel_requested(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_xray_service_api_starts_stops_and_reads_logs(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho xray-started\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -199,9 +212,7 @@ def test_xray_service_api_starts_stops_and_reads_logs(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_xray_service_restores_desired_running_state_on_app_start(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho restored-start\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -228,9 +239,7 @@ def test_xray_service_restores_desired_running_state_on_app_start(tmp_path: Path
|
||||
|
||||
|
||||
def test_xray_service_log_forwarder_flushes_line_output_quickly(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho first-line\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -247,9 +256,7 @@ def test_xray_service_log_forwarder_flushes_line_output_quickly(tmp_path: Path)
|
||||
|
||||
|
||||
def test_xray_service_start_regenerates_config_from_saved_settings(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -268,9 +275,7 @@ def test_xray_service_start_regenerates_config_from_saved_settings(tmp_path: Pat
|
||||
|
||||
|
||||
def test_xray_service_applies_transparent_rules_on_start_and_cleans_on_stop(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_write_fake_xray(tmp_path)
|
||||
app = create_app(tmp_path)
|
||||
commands: list[str] = []
|
||||
app.extensions["pyxray_transparent_runtime"].executor = _recording_executor(commands)
|
||||
@@ -307,9 +312,7 @@ def test_xray_service_applies_transparent_rules_on_start_and_cleans_on_stop(tmp_
|
||||
|
||||
|
||||
def test_xray_service_rolls_back_when_transparent_setup_fails(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_write_fake_xray(tmp_path)
|
||||
app = create_app(tmp_path)
|
||||
commands: list[str] = []
|
||||
app.extensions["pyxray_transparent_runtime"].executor = _recording_executor(
|
||||
@@ -344,9 +347,7 @@ def test_xray_service_rolls_back_when_transparent_setup_fails(tmp_path: Path) ->
|
||||
|
||||
|
||||
def test_xray_service_shutdown_stops_managed_process(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -366,9 +367,7 @@ def test_xray_service_uses_absolute_paths_when_app_created_with_relative_xray_di
|
||||
monkeypatch.chdir(tmp_path)
|
||||
xray_dir = tmp_path / "data" / "xray"
|
||||
xray_dir.mkdir(parents=True)
|
||||
xray = xray_dir / "xray"
|
||||
xray.write_text("#!/bin/sh\necho relative-started\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
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")})
|
||||
@@ -385,9 +384,7 @@ def test_xray_service_uses_absolute_paths_when_app_created_with_relative_xray_di
|
||||
|
||||
|
||||
def test_xray_service_api_reports_missing_config(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_write_fake_xray(tmp_path)
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
|
||||
@@ -398,9 +395,7 @@ def test_xray_service_api_reports_missing_config(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_xray_service_api_records_immediate_start_failure_output(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho 'bind: permission denied' >&2\nexit 23\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -488,10 +483,8 @@ def test_xray_service_prefers_persisted_download_directory(tmp_path: Path) -> No
|
||||
preferred_dir = tmp_path / "download-xray"
|
||||
default_dir.mkdir()
|
||||
preferred_dir.mkdir()
|
||||
(default_dir / "xray").write_text("#!/bin/sh\necho default-xray\nsleep 30\n", encoding="utf-8")
|
||||
(preferred_dir / "xray").write_text("#!/bin/sh\necho preferred-xray\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(default_dir / "xray", 0o755)
|
||||
os.chmod(preferred_dir / "xray", 0o755)
|
||||
_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")})
|
||||
@@ -503,9 +496,9 @@ def test_xray_service_prefers_persisted_download_directory(tmp_path: Path) -> No
|
||||
client.post("/api/xray/service/stop")
|
||||
logs = client.get("/api/xray/service/logs").get_json()["content"]
|
||||
|
||||
assert started["xray"] == str(preferred_dir / "xray")
|
||||
assert started["xray"] == str(preferred_xray)
|
||||
assert started["xray_dir"] == str(preferred_dir)
|
||||
assert str(preferred_dir / "xray") in logs
|
||||
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:
|
||||
@@ -513,8 +506,7 @@ def test_xray_service_falls_back_to_default_directory_when_saved_directory_has_n
|
||||
preferred_dir = tmp_path / "download-xray"
|
||||
default_dir.mkdir()
|
||||
preferred_dir.mkdir()
|
||||
(default_dir / "xray").write_text("#!/bin/sh\necho default-xray\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(default_dir / "xray", 0o755)
|
||||
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")})
|
||||
@@ -526,10 +518,10 @@ def test_xray_service_falls_back_to_default_directory_when_saved_directory_has_n
|
||||
client.post("/api/xray/service/stop")
|
||||
logs = client.get("/api/xray/service/logs").get_json()["content"]
|
||||
|
||||
assert started["xray"] == str(default_dir / "xray")
|
||||
assert started["xray"] == str(default_xray)
|
||||
assert started["xray_dir"] == str(default_dir)
|
||||
assert started["fallback_xray_dir"] == str(default_dir)
|
||||
assert str(default_dir / "xray") in logs
|
||||
assert str(default_xray) in logs
|
||||
|
||||
|
||||
def test_nodes_api_imports_lists_selects_and_deletes_node(tmp_path: Path) -> None:
|
||||
@@ -554,9 +546,7 @@ def test_nodes_api_imports_lists_selects_and_deletes_node(tmp_path: Path) -> Non
|
||||
|
||||
|
||||
def test_selecting_node_restarts_running_xray(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho started-$@\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")])})
|
||||
@@ -603,9 +593,7 @@ def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) ->
|
||||
|
||||
|
||||
def test_saving_settings_restarts_running_xray(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho settings-started\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
_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")})
|
||||
@@ -667,7 +655,8 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
|
||||
"dns.query_strategy": "UseIPv4",
|
||||
"dns.local_dns_listen": "on",
|
||||
"dns.antipollution": "closed",
|
||||
"dns.special_mode": "none",
|
||||
"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",
|
||||
@@ -691,6 +680,8 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
|
||||
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"])
|
||||
@@ -699,6 +690,8 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
|
||||
]
|
||||
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:
|
||||
@@ -833,6 +826,51 @@ def _ss_link(password: str, name: str) -> str:
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user