From 90e1e9a72509ffaf80a0600a4247300c688dfb48 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 27 May 2026 00:57:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=BA=E7=A1=80=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 29 + Dockerfile | 44 + README.md | 143 ++++ TODO.md | 15 + docs/config.md | 754 ++++++++++++++++++ pyproject.toml | 30 + pyxray/__init__.py | 5 + pyxray/cli.py | 31 + pyxray/libs/__init__.py | 1 + pyxray/libs/nodes/__init__.py | 21 + pyxray/libs/nodes/common.py | 27 + pyxray/libs/nodes/errors.py | 13 + pyxray/libs/nodes/importer.py | 43 + pyxray/libs/nodes/manager.py | 110 +++ pyxray/libs/nodes/model.py | 61 ++ pyxray/libs/nodes/normalize.py | 205 +++++ pyxray/libs/nodes/parser.py | 28 + pyxray/libs/nodes/parsers/__init__.py | 11 + pyxray/libs/nodes/parsers/shadowsocks.py | 92 +++ pyxray/libs/nodes/parsers/trojan.py | 42 + pyxray/libs/nodes/parsers/vless.py | 60 ++ pyxray/libs/nodes/parsers/vmess.py | 118 +++ pyxray/libs/nodes/store.py | 92 +++ pyxray/libs/xray_asset_settings.py | 75 ++ pyxray/libs/xray_assets.py | 248 ++++++ pyxray/libs/xray_config/__init__.py | 22 + pyxray/libs/xray_config/generator.py | 424 ++++++++++ pyxray/libs/xray_config/outbound.py | 212 +++++ pyxray/libs/xray_config/settings.py | 234 ++++++ pyxray/libs/xray_config/store.py | 73 ++ pyxray/libs/xray_config/tinytun_config.py | 253 ++++++ pyxray/libs/xray_config/transparent_rules.py | 512 ++++++++++++ pyxray/libs/xray_runtime.py | 171 ++++ pyxray/web/__init__.py | 1 + pyxray/web/dashboard.py | 54 ++ pyxray/web/jobs.py | 72 ++ pyxray/web/nodes.py | 77 ++ pyxray/web/server.py | 69 ++ pyxray/web/static/css/app.css | 183 +++++ pyxray/web/static/js/app.js | 490 ++++++++++++ pyxray/web/templates/configs/core.html | 22 + pyxray/web/templates/configs/dns.html | 51 ++ pyxray/web/templates/configs/inbounds.html | 49 ++ pyxray/web/templates/configs/routing.html | 18 + pyxray/web/templates/configs/transparent.html | 51 ++ pyxray/web/templates/index.html | 63 ++ pyxray/web/templates/partials/config_tab.html | 20 + .../web/templates/partials/download_tab.html | 89 +++ pyxray/web/templates/partials/logs_tab.html | 16 + pyxray/web/templates/partials/nodes_tab.html | 23 + pyxray/web/xray_assets.py | 228 ++++++ pyxray/web/xray_config.py | 225 ++++++ pyxray/web/xray_service.py | 62 ++ tests/libs/test_node_manager.py | 72 ++ tests/libs/test_nodes.py | 212 +++++ tests/libs/test_tinytun_config.py | 71 ++ tests/libs/test_transparent_rules.py | 105 +++ tests/libs/test_xray_assets.py | 163 ++++ tests/libs/test_xray_config.py | 376 +++++++++ tests/web/test_xray_assets_web.py | 622 +++++++++++++++ uv.lock | 197 +++++ 61 files changed, 7880 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 docs/config.md create mode 100644 pyproject.toml create mode 100644 pyxray/__init__.py create mode 100644 pyxray/cli.py create mode 100644 pyxray/libs/__init__.py create mode 100644 pyxray/libs/nodes/__init__.py create mode 100644 pyxray/libs/nodes/common.py create mode 100644 pyxray/libs/nodes/errors.py create mode 100644 pyxray/libs/nodes/importer.py create mode 100644 pyxray/libs/nodes/manager.py create mode 100644 pyxray/libs/nodes/model.py create mode 100644 pyxray/libs/nodes/normalize.py create mode 100644 pyxray/libs/nodes/parser.py create mode 100644 pyxray/libs/nodes/parsers/__init__.py create mode 100644 pyxray/libs/nodes/parsers/shadowsocks.py create mode 100644 pyxray/libs/nodes/parsers/trojan.py create mode 100644 pyxray/libs/nodes/parsers/vless.py create mode 100644 pyxray/libs/nodes/parsers/vmess.py create mode 100644 pyxray/libs/nodes/store.py create mode 100644 pyxray/libs/xray_asset_settings.py create mode 100644 pyxray/libs/xray_assets.py create mode 100644 pyxray/libs/xray_config/__init__.py create mode 100644 pyxray/libs/xray_config/generator.py create mode 100644 pyxray/libs/xray_config/outbound.py create mode 100644 pyxray/libs/xray_config/settings.py create mode 100644 pyxray/libs/xray_config/store.py create mode 100644 pyxray/libs/xray_config/tinytun_config.py create mode 100644 pyxray/libs/xray_config/transparent_rules.py create mode 100644 pyxray/libs/xray_runtime.py create mode 100644 pyxray/web/__init__.py create mode 100644 pyxray/web/dashboard.py create mode 100644 pyxray/web/jobs.py create mode 100644 pyxray/web/nodes.py create mode 100644 pyxray/web/server.py create mode 100644 pyxray/web/static/css/app.css create mode 100644 pyxray/web/static/js/app.js create mode 100644 pyxray/web/templates/configs/core.html create mode 100644 pyxray/web/templates/configs/dns.html create mode 100644 pyxray/web/templates/configs/inbounds.html create mode 100644 pyxray/web/templates/configs/routing.html create mode 100644 pyxray/web/templates/configs/transparent.html create mode 100644 pyxray/web/templates/index.html create mode 100644 pyxray/web/templates/partials/config_tab.html create mode 100644 pyxray/web/templates/partials/download_tab.html create mode 100644 pyxray/web/templates/partials/logs_tab.html create mode 100644 pyxray/web/templates/partials/nodes_tab.html create mode 100644 pyxray/web/xray_assets.py create mode 100644 pyxray/web/xray_config.py create mode 100644 pyxray/web/xray_service.py create mode 100644 tests/libs/test_node_manager.py create mode 100644 tests/libs/test_nodes.py create mode 100644 tests/libs/test_tinytun_config.py create mode 100644 tests/libs/test_transparent_rules.py create mode 100644 tests/libs/test_xray_assets.py create mode 100644 tests/libs/test_xray_config.py create mode 100644 tests/web/test_xray_assets_web.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54869fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pyre/ +.coverage +htmlcov/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Local runtime data +nodes.toml +state.toml +settings.toml +runtime.toml +config.json +xray.log +xray/ +*.tmp + +.v2rayA +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fb572f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim + +WORKDIR /app + +ARG XRAY_VERSION="" +ARG TARGETARCH +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}" + +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 iproute2 iptables ca-certificates curl unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN if [ -n "$XRAY_VERSION" ]; then \ + case "$TARGETARCH" in \ + amd64|"") XRAY_ARCH="64" ;; \ + arm64) XRAY_ARCH="arm64-v8a" ;; \ + arm) XRAY_ARCH="arm32-v7a" ;; \ + *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/Xray-linux-${XRAY_ARCH}.zip" -o /tmp/xray.zip; \ + unzip /tmp/xray.zip xray geoip.dat geosite.dat -d /usr/local/share/xray; \ + install -m 0755 /usr/local/share/xray/xray /usr/local/bin/xray; \ + rm -f /tmp/xray.zip; \ + fi + +COPY pyproject.toml uv.lock README.md ./ +COPY pyxray ./pyxray + +RUN uv sync --frozen --no-dev + +VOLUME ["/config"] + +EXPOSE 8080 + +CMD ["/app/.venv/bin/pyxray", "--nodes-file", "/config/nodes.toml", "--state-file", "/config/state.toml", "--runtime-file", "/config/runtime.toml", "web", "--host", "0.0.0.0", "--port", "8080", "--config-file", "/config/config.json", "--log-file", "/config/xray.log"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e45a5c0 --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# pyxray + +`pyxray` is a lightweight Xray resource and node-link toolkit. + +Current stage: + +- Download and check Xray assets. +- Import and normalize proxy node links. +- Manage local nodes and selected node. +- Generate Xray config from selected node and settings. +- Run a Chinese, light, single-page Web UI. + +Out of scope for the current stage: + +- Subscriptions. +- Authentication. + +## Xray 配置生成设置 + +这些设置由 `pyxray.libs.xray_config.settings.XrayConfigSettings` 定义,并可通过 `XrayConfigSettingsStore` 保存为 `settings.toml`。 + +### `[core]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ----------------- | --------- | --------------------------------------------------------- | ------------------------------------------------------------ | +| `log_level` | `info` | `trace` / `debug` / `info` / `warn` / `warning` / `error` | 生成 Xray `log`;`trace/debug` 会映射为 Xray `debug`。 | +| `tcp_fast_open` | `default` | `default` / `yes` / `no` | 非默认时写入 outbound `streamSettings.sockopt.tcpFastOpen`。 | +| `mux_enabled` | `false` | `bool` | 是否给主代理 outbound 启用 `mux`。 | +| `mux_concurrency` | `8` | `1-1024` | `mux.concurrency`。 | +| `ss_backend` | `""` | `string` | 预留 Shadowsocks 后端设置;当前 Xray JSON 生成不使用。 | +| `trojan_backend` | `""` | `string` | 预留 Trojan 后端设置;当前 Xray JSON 生成不使用。 | + +### `[inbounds]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ------------------ | ------------------- | ---------------------------------------- | ------------------------------------------------------- | +| `listen` | `127.0.0.1` | IP 字符串 | 普通 socks/http/rule/vmess 入站监听地址。 | +| `port_sharing` | `false` | `bool` | 为 `true` 时普通入站监听 `0.0.0.0`,等价于端口共享。 | +| `socks_port` | `20170` | `0-65535` | 普通 SOCKS 入站端口,`0` 表示不生成。 | +| `http_port` | `20171` | `0-65535` | 普通 HTTP 入站端口,`0` 表示不生成。 | +| `rule_socks_port` | `0` | `0-65535` | 规则 SOCKS 入站端口,流量走 `[routing]` 规则。 | +| `rule_http_port` | `20172` | `0-65535` | 规则 HTTP 入站端口,流量走 `[routing]` 规则。 | +| `vmess_port` | `0` | `0-65535` | 额外 VMess 入站端口,`0` 表示不生成。 | +| `inbound_sniffing` | `http,tls,quic` | `disable` / `http,tls` / `http,tls,quic` | 控制入站 `sniffing.enabled` 和 `destOverride`。 | +| `route_only` | `false` | `bool` | 写入 `sniffing.routeOnly`。 | +| `domains_excluded` | `""` | 换行分隔字符串 | 写入 `sniffing.domainsExcluded`。 | +| `api.port` | `0` | `0-65535` | Xray API 入站端口,`0` 表示不生成 API。 | +| `api.services` | `["LoggerService"]` | 字符串数组 | Xray `api.services`;生成时会确保包含 `LoggerService`。 | +| `custom` | `[]` | 自定义入站列表 | 额外生成 socks/http 入站。 | + +#### `[[inbounds.custom]]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ---------- | ------ | ---------------- | ---------------- | +| `tag` | 无 | 非空字符串 | 自定义入站 tag。 | +| `protocol` | 无 | `socks` / `http` | 自定义入站协议。 | +| `port` | 无 | `1-65535` | 自定义入站端口。 | + +### `[routing]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| -------------- | ----------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `mode` | `whitelist` | `whitelist` / `gfwlist` / `custom` / `routingA` / `proxy` / `direct` / `block` | 控制 rule 入站流量的路由生成方式。 | +| `default_rule` | `proxy` | `direct` / `proxy` / `block` | `custom` / `routingA` 等模式的兜底出口。 | +| `routing_a` | `""` | RoutingA 风格字符串 | 解析 `default:`、`domain(...) -> outbound`、`ip(...) -> outbound` 规则。 | +| `custom_rules` | `[]` | 自定义规则列表 | `mode = "custom"` 时生成规则。 | + +#### `[[routing.custom_rules]]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ------------ | -------- | ---------------------------- | ----------------------------------------- | +| `filename` | `""` | 字符串 | 非空时生成 `ext::`。 | +| `tags` | `[]` | 字符串数组 | geosite/geoip/tag 列表。 | +| `match_type` | `domain` | `domain` / `ip` | 写入 Xray rule 的 `domain` 或 `ip` 字段。 | +| `rule_type` | `proxy` | `direct` / `proxy` / `block` | 命中后的出口。 | + +### `[transparent]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ---------------------------- | ----------------------------- | --------------------------------------------------- | --------------------------------------------------- | +| `mode` | `close` | `close` / `proxy` / `whitelist` / `gfwlist` / `pac` | 是否生成透明代理入站,以及透明代理流量走什么规则。 | +| `type` | `redirect` | `redirect` / `tproxy` / `system_proxy` / `tun` | 透明代理入站类型。 | +| `port` | `52345` | `0-65535` | `redirect/tproxy/system_proxy/tun` 主透明代理端口。 | +| `socks_port` | `52306` | `0-65535` | `system_proxy` 模式额外 SOCKS 入站端口。 | +| `ipforward` | `false` | `bool` | 系统 IP forward 设置;当前不写入 Xray JSON。 | +| `tproxy_excluded_interfaces` | `docker*,veth*,wg*,ppp*,br-*` | 字符串 | 系统 tproxy 规则排除接口;当前不写入 Xray JSON。 | +| `tproxy_white_country_codes` | `[]` | 字符串数组 | tproxy 白名单国家/地区;当前不写入 Xray JSON。 | +| `tproxy_white_custom_ips` | `[]` | 字符串数组 | tproxy 自定义白名单 IP;当前不写入 Xray JSON。 | +| `tun_bypass_interfaces` | `""` | 字符串 | TUN 绕过接口;当前不写入 Xray JSON。 | +| `tun_auto_route` | `true` | `bool` | TUN 自动路由设置;当前不写入 Xray JSON。 | +| `tun_route_shell_type` | `""` | 字符串 | TUN 路由脚本类型;当前不写入 Xray JSON。 | +| `tun_route_shell_path` | `""` | 字符串 | TUN 路由脚本路径;当前不写入 Xray JSON。 | +| `tun_setup_script` | `""` | 字符串 | TUN 启动脚本;当前不写入 Xray JSON。 | +| `tun_teardown_script` | `""` | 字符串 | TUN 关闭脚本;当前不写入 Xray JSON。 | +| `tun_process_backend` | `""` | 字符串 | TUN 进程匹配后端;当前不写入 Xray JSON。 | +| `tun_exclude_processes` | `""` | 字符串 | TUN 排除进程列表;当前不写入 Xray JSON。 | + +### `[dns]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ------------------ | ------------------------------------------------------------ | ----------------------------------------------------- | ------------------------------------------------- | +| `query_strategy` | `""` | `UseIP` / `UseIPv4` / `UseIPv6` / 空 | 非空时写入 `dns.queryStrategy`。 | +| `disable_fallback` | `false` | `bool` | 为 `true` 时写入 `dns.disableFallback`。 | +| `local_dns_listen` | `true` | `bool` | 是否生成本地 `dns-in` dokodemo-door 入站。 | +| `hosts` | `{ "courier.push.apple.com": ["1-courier.push.apple.com"] }` | 字典 | 写入 `dns.hosts`。 | +| `rules` | 见下方默认规则 | DNS 规则列表 | 生成 `dns.servers` 和 DNS 服务器自身的 routing。 | +| `antipollution` | `closed` | `closed` / `none` / `dnsforward` / `doh` / `advanced` | DNS 防污染预留设置;当前不直接改变 Xray JSON。 | +| `special_mode` | `none` | `none` / `supervisor` / `fakedns` | 特殊 DNS 模式预留设置;当前不直接改变 Xray JSON。 | + +#### 默认 `[[dns.rules]]` + +| server | domains | outbound | 作用 | +| ----------- | ----------------- | -------- | -------------------- | +| `localhost` | `geosite:private` | `direct` | 私有域名走本地 DNS。 | +| `223.5.5.5` | `geosite:cn` | `direct` | 中国域名走国内 DNS。 | +| `8.8.8.8` | `""` | `proxy` | 兜底 DNS 走代理。 | + +#### `[[dns.rules]]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ---------- | -------- | -------------------------------------------------- | ----------------------------------------------- | +| `server` | 无 | DNS 地址字符串 | 支持 `localhost`、IP、`host:port`、DoH URL 等。 | +| `domains` | `""` | 换行分隔字符串 | 非空时作为该 DNS server 的匹配域名列表。 | +| `outbound` | `direct` | `direct` / `proxy` / `block` / 自定义 outbound tag | DNS 服务器自身连接使用的出口。 | + +### `[[outbounds]]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ---------------- | -------------------------------------- | --------------- | --------------------------------------------- | +| `tag` | `proxy` | 字符串 | 出站组 tag。当前单节点配置默认使用 `proxy`。 | +| `probe_url` | `https://www.gstatic.com/generate_204` | URL 字符串 | 出站组观测 URL,预留给 balancer/observatory。 | +| `probe_interval` | `60s` | duration 字符串 | 出站组观测间隔,预留给 balancer/observatory。 | +| `type` | `leastping` | 字符串 | 出站组策略类型,预留给 balancer/observatory。 | + +### `[auto_update]` + +| 设置 | 默认值 | 可选值 / 类型 | 作用 | +| ---------------------------------------- | -------- | --------------------------------------------------- | ------------------------------------------------ | +| `gfwlist_auto_update_mode` | `none` | `none` / `auto_update` / `auto_update_at_intervals` | GFWList 更新策略;不写入 Xray JSON。 | +| `gfwlist_auto_update_interval_hour` | `0` | 整数 | GFWList 定时更新间隔小时数。 | +| `subscription_auto_update_mode` | `none` | `none` / `auto_update` / `auto_update_at_intervals` | 订阅更新策略;当前订阅不在实现范围。 | +| `subscription_auto_update_interval_hour` | `0` | 整数 | 订阅定时更新间隔小时数。 | +| `proxy_mode_when_subscribe` | `direct` | `direct` / `proxy` / `pac` | 订阅更新时偏好的连接模式;当前订阅不在实现范围。 | diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0242d72 --- /dev/null +++ b/TODO.md @@ -0,0 +1,15 @@ +1. Xray 自动下载 / 检查 +2. 节点链接导入与标准化 +3. 节点管理与选择 +4. Xray 配置生成 + - [x] 定义 `settings.toml` 结构:core / inbounds / routing / transparent / dns / outbound settings。 + - [x] 实现设置读写与默认值:使用 TOML 保存所有配置生成相关参数。 + - [x] 实现基础配置模型:log / inbounds / outbounds / routing / dns。 + - [x] 实现选中节点到 Xray outbound 的转换。 + - [x] 实现 inbound 生成:socks / http / rule socks / rule http / custom inbounds / sniffing。 + - [x] 实现 routing 生成:proxy / direct / whitelist / gfwlist / custom / RoutingA。 + - [x] 实现 DNS 生成:dns rules / dns hosts / dns outbound / dns routing。 + - [x] 实现透明代理配置生成:close / proxy / whitelist / gfwlist / pac;redirect / tproxy / system_proxy / tun。 + - [x] 实现 mux / tcpFastOpen / logLevel 等 outbound sockopt 和运行参数。 + - [x] 增加完整配置生成测试:最小配置、规则端口、DNS、透明代理、各协议节点。 +5. Xray 运行控制 diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..546759a --- /dev/null +++ b/docs/config.md @@ -0,0 +1,754 @@ +# pyxray 配置说明 + +本文档说明当前 Web UI 已整理的配置项:核心配置、入站端口、路由、透明代理、DNS。 + +当前 UI 没有展示的配置项,如果本文明确写了隐藏默认值,表示保存配置时会按该默认值写入 `settings.toml`。 + +配置页使用改动确认模式: + +- 修改任意配置项后,右下角会出现两个圆形按钮。 +- `✓` 表示保存设置。 +- `×` 表示撤回当前未保存修改,恢复到页面加载时的配置值。 + +## 核心配置 + +### 日志等级 + +控制生成的 Xray `log.loglevel`。 + +可选值: + +- `trace` +- `debug` +- `info` +- `warn` +- `warning` +- `error` + +默认值: + +```text +info +``` + +注意事项: + +- `trace` / `debug` 适合排查问题,但日志量更大。 +- 日常使用建议保持 `info`。 +- 只想看错误时可以改成 `error`。 + +### Mux 并发数 + +控制 Xray outbound 的 `mux` 设置。 + +UI 可选值: + +```text +0, 1, 2, 4, 8, 16, 32, 64 +``` + +含义: + +- `0` 表示关闭 Mux。 +- 非 `0` 表示启用 Mux,并把该值作为 `mux.concurrency`。 + +默认值: + +```text +0 +``` + +保存后的实际含义: + +```text +0 -> mux_enabled = false +8 -> mux_enabled = true, mux_concurrency = 8 +16 -> mux_enabled = true, mux_concurrency = 16 +``` + +注意事项: + +- Mux 会把多个连接复用到更少的底层连接中。 +- 某些节点或网络环境下 Mux 可能提升体验,也可能导致兼容性问题。 +- 不确定时建议用 `0` 关闭。 + +### 隐藏默认项 + +当前 UI 不显示 TCP Fast Open。 + +保存时固定为: + +```text +tcp_fast_open = "default" +``` + +含义: + +- `default` 表示不主动覆盖 Xray / 系统默认行为。 +- TCP Fast Open 是 TCP 握手阶段携带首包数据的优化,是否有效取决于系统和网络路径。 + +## 入站端口 + +入站端口表示 Xray 在本机监听哪些入口,让应用把流量交给 Xray。 + +当前 UI 只保留“规则代理入口”,普通 SOCKS/HTTP、VMess、API、自定义入站都隐藏。 + +### 监听地址 + +控制规则代理入口监听在哪个地址。 + +可选值: + +```text +127.0.0.1 +0.0.0.0 +``` + +含义: + +- `127.0.0.1` 只允许本机访问。 +- `0.0.0.0` 允许局域网设备访问。 + +默认值: + +```text +127.0.0.1 +``` + +注意事项: + +- 如果只给本机程序使用,建议选择 `127.0.0.1`。 +- 如果要让其它设备连接这台机器的代理端口,才选择 `0.0.0.0`。 +- 选择 `0.0.0.0` 时需要注意防火墙和局域网安全。 + +### 规则 SOCKS 端口 + +生成带路由规则的 SOCKS5 入站。 + +默认值: + +```text +20170 +``` + +含义: + +- 应用连接该 SOCKS5 端口后,流量会按路由配置决定走 `proxy`、`direct` 或 `block`。 +- 端口设为 `0` 表示不生成该入站。 + +### 规则 HTTP 端口 + +生成带路由规则的 HTTP 代理入站。 + +默认值: + +```text +20172 +``` + +含义: + +- 应用连接该 HTTP 代理端口后,流量会按路由配置决定走 `proxy`、`direct` 或 `block`。 +- 端口设为 `0` 表示不生成该入站。 + +### Sniffing + +控制 Xray 是否从连接中识别目标域名。 + +可选值: + +```text +disable +http,tls +http,tls,quic +``` + +默认值: + +```text +http,tls,quic +``` + +含义: + +- `disable` 表示关闭嗅探。 +- `http,tls` 表示识别 HTTP Host 和 TLS SNI。 +- `http,tls,quic` 表示额外识别 QUIC。 + +注意事项: + +- 路由规则经常依赖域名匹配,开启 Sniffing 后更容易按域名正确分流。 +- 如果遇到特定服务兼容性问题,可以尝试降低到 `http,tls` 或关闭。 + +### Sniffing 仅用于路由 + +控制嗅探结果是否只用于路由判断。 + +可选值: + +```text +关闭 +开启 +``` + +默认值: + +```text +关闭 +``` + +含义: + +- `开启` 表示嗅探出的域名只用于路由,不改写实际连接目标。 +- `关闭` 表示按 Xray 默认 sniffing 行为处理。 + +注意事项: + +- `开启` 通常更保守,兼容性更好。 +- 如果只希望 Sniffing 帮助规则匹配,可以开启。 + +### 隐藏默认项 + +当前 UI 隐藏以下入站设置,并在保存时固定为默认值: + +```text +socks_port = 0 +http_port = 0 +vmess_port = 0 +api.port = 0 +port_sharing = false +domains_excluded = "" +custom = [] +``` + +含义: + +- 普通 SOCKS/HTTP 入站不生成,只生成规则 SOCKS/HTTP 入站。 +- VMess 入站不生成。 +- Xray API 入站不生成。 +- 端口共享开关不再单独存在,是否允许局域网访问由“监听地址”决定。 +- 排除嗅探域名暂不开放 UI。 +- 自定义入站暂不开放 UI。 + +## 路由 + +路由决定进入规则代理端口或透明代理入口的流量最终走哪个 outbound。 + +当前执行顺序: + +```text +1. 先匹配自定义规则 +2. 再匹配路由模式 +3. 最后默认走 proxy +``` + +### 路由模式 + +可选值: + +```text +whitelist +gfwlist +proxy +direct +block +``` + +默认值: + +```text +whitelist +``` + +含义: + +- `whitelist` 表示白名单模式,常见目标是国内和私有地址直连,其它走代理。 +- `gfwlist` 表示 GFWList 模式,命中规则的目标走代理,其它直连。 +- `proxy` 表示全部走代理。 +- `direct` 表示全部直连。 +- `block` 表示全部阻断。 + +注意事项: + +- 想简单全部走代理,选择 `proxy`。 +- 想国内直连、国外代理,选择 `whitelist`。 +- 想更保守地只代理规则命中的目标,选择 `gfwlist`。 + +### 自定义规则 + +自定义规则会优先于路由模式匹配。 + +语法: + +```text +domain(...)->proxy +domain(...)->direct +domain(...)->block +ip(...)->proxy +ip(...)->direct +ip(...)->block +``` + +示例: + +```text +domain(domain:example.com)->direct +domain(geosite:google)->proxy +ip(geoip:cn)->direct +``` + +含义: + +- `domain(...)` 表示按域名规则匹配。 +- `ip(...)` 表示按 IP 规则匹配。 +- `->proxy` 表示命中后走代理。 +- `->direct` 表示命中后直连。 +- `->block` 表示命中后阻断。 + +注意事项: + +- 一行一条规则。 +- 空行和以 `#` 开头的行会被忽略。 +- 自定义规则只负责前置匹配,不再支持 `default:`。 +- 默认兜底固定为 `proxy`,不在 UI 中显示。 + +### 隐藏默认项 + +当前 UI 不显示默认规则。 + +保存时固定为: + +```text +default_rule = "proxy" +``` + +含义: + +- 自定义规则和路由模式都没有命中时,最终走 `proxy`。 + +当前 UI 也不显示旧的 `custom` / `routingA` 模式。 + +## DNS + +DNS 设置控制 Xray 内置 DNS 模块如何解析域名,以及是否提供本地 DNS 入口。 + +### 查询策略 + +控制生成的 Xray `dns.queryStrategy`。 + +可选值: + +```text +默认 +UseIP +UseIPv4 +UseIPv6 +``` + +默认值: + +```text +UseIPv4 +``` + +含义: + +- `默认` 表示不写入 `queryStrategy`,交给 Xray 默认行为。 +- `UseIP` 表示允许返回 IP,具体 IPv4 / IPv6 由 Xray 和系统环境决定。 +- `UseIPv4` 表示优先使用 IPv4 解析结果。 +- `UseIPv6` 表示优先使用 IPv6 解析结果。 + +注意事项: + +- 当前 pyxray 默认使用 `UseIPv4`,避免 IPv6 网络不可用时出现连接失败。 +- 如果运行环境明确支持 IPv6,可以改成 `UseIP` 或 `UseIPv6`。 + +### 防污染模式 + +当前 UI 保留该字段,但 pyxray 暂未把它接入 Xray JSON 生成逻辑。 +交叉核对当前 `.v2rayA` 后,旧的 `antiPollution.GetExternalDNS` 和 `DropSpoofing.GetSetupCommands` 已不在透明代理 setup 路径中生效,因此这里先保持为保存项,不生成额外规则。 + +可选值: + +```text +closed +none +dnsforward +doh +advanced +``` + +默认值: + +```text +closed +``` + +含义: + +- `closed` 表示关闭防污染策略。 +- `none` 表示不使用额外防污染策略。 +- `dnsforward` 表示预期转发 DNS 请求,由程序接收 DNS 后再按规则转发到上游。 +- `doh` 表示预期使用 DNS-over-HTTPS,减少传统 UDP DNS 被劫持或污染的概率。 +- `advanced` 表示预留高级自定义 DNS 防污染策略。 + +注意事项: + +- 当前阶段该字段只保存到 `settings.toml`。 +- 真正生效的是下方 DNS 规则、查询策略、禁用 fallback、本地 DNS 监听。 + +### 特殊模式 + +当前 UI 保留该字段,但 pyxray 暂未把它接入 Xray JSON 生成逻辑。 +当前 `.v2rayA` 代码里原 `specialMode` 的 supervisor / fakedns 相关逻辑已经移除,只保留 redirect 透明代理所需的本地 DNS 监听辅助函数。 + +可选值: + +```text +none +supervisor +fakedns +``` + +默认值: + +```text +none +``` + +含义: + +- `none` 表示不启用特殊 DNS 模式。 +- `supervisor` 表示预期监控 DNS 污染,并结合 sniffing 识别出的域名修正连接行为。 +- `fakedns` 表示预期使用 FakeDNS,为域名返回保留网段假 IP,再由代理反查假 IP 对应的原始域名。 + +注意事项: + +- `fakedns` 通常需要配套 FakeDNS 地址池、透明代理或 TUN 路由逻辑。 +- 因为当前 `.v2rayA` 已移除 `supervisor` / `fakedns` 生成路径,pyxray 也不会为这些选项生成额外配置。 + +### 禁用 fallback + +控制是否生成 Xray `dns.disableFallback`。 + +可选值: + +```text +关闭 +开启 +``` + +默认值: + +```text +关闭 +``` + +含义: + +- `关闭` 表示允许 Xray 在需要时使用 fallback DNS。 +- `开启` 表示生成 `disableFallback = true`,DNS 解析更严格地按配置规则执行。 + +注意事项: + +- 开启后 DNS 行为更可控。 +- 如果某个 DNS 服务器解析失败,可能不会自动退到其它 DNS,容错更低。 + +### 监听本地 DNS + +控制是否生成本地 UDP 53 DNS 入站。 + +可选值: + +```text +关闭 +开启 +``` + +默认值: + +```text +开启 +``` + +含义: + +- `开启` 时允许生成 `dns-in`,让本机 DNS 请求交给 Xray。 +- `关闭` 时 Xray 仍可在内部解析域名,但不会额外提供本地 DNS 入口。 + +注意事项: + +- 本地 DNS 监听只有在透明代理开启且类型为 `redirect` 时才生成;透明代理关闭、`tproxy`、`system_proxy`、`tun` 都不会生成。 +- 非局域网共享时监听 `127.2.0.17:53/udp`;启用局域网共享时,会额外保留 `0.0.0.0:53/udp` 给局域网设备使用,并增加 `dns-in-local` 处理本机 DNS。 +- 监听 53 端口可能需要权限,且端口不能被其它 DNS 服务占用。 + +### DNS 规则 + +DNS 规则一行一条,格式: + +```text +server|domains|outbound +``` + +默认值: + +```text +localhost|geosite:private|direct +223.5.5.5|geosite:cn|direct +8.8.8.8||proxy +``` + +含义: + +- `server` 表示 DNS 服务器,例如 `localhost`、`223.5.5.5`、`8.8.8.8`、`https://dns.google/dns-query`。 +- `domains` 表示该 DNS 服务器负责解析哪些域名规则,空表示默认 DNS。 +- `outbound` 表示访问该 DNS 服务器本身时走哪个出口,常用 `direct` 或 `proxy`。 + +默认规则含义: + +```text +私有域名 -> localhost -> direct +国内域名 -> 223.5.5.5 -> direct +其它域名 -> 8.8.8.8 -> proxy +``` + +### 隐藏默认项 + +当前 UI 不显示 DNS hosts。 + +保存和生成配置时保留默认值: + +```text +hosts."courier.push.apple.com" = ["1-courier.push.apple.com"] +``` + +含义: + +- `hosts` 类似 `/etc/hosts`,用于在 DNS 模块里固定或改写某些域名解析结果。 +- 当前保留该默认项主要用于兼容 Apple Push 相关域名。 + +当前 UI 也不显示出站组与自动更新设置。 + +保存时固定为: + +```text +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" +``` + +含义: + +- 当前 pyxray 只使用手动导入并选中的节点生成 `proxy` outbound。 +- GFWList 资源更新由“下载”页面负责,不在配置页提供自动更新策略。 +- 当前不关注订阅相关设置和逻辑。 + +## 透明代理 + +透明代理由两部分组成: + +```text +Xray transparent inbound + 系统流量劫持规则 +``` + +当前 pyxray 已经能生成 Xray transparent inbound,并能生成宿主机透明代理系统规则脚本。 + +系统规则脚本当前覆盖: + +- `redirect` 的 legacy iptables 规则。 +- `redirect` 的 nftables 表配置和加载命令。 +- `tproxy` 的 legacy iptables 规则。 +- `tproxy` 的 nftables 表配置和加载命令。 +- `system_proxy` 的 HTTP/SOCKS 代理入口说明脚本。 + +注意事项: + +- 当前只生成脚本,不自动执行系统命令。 +- 生成规则参考 `.v2rayA/service/core/iptables` 的链名、mark、路由表和端口语义。 +- 点击“生成配置”时,会在 `config.json` 同级目录下生成 `transparent/` 目录。 +- `transparent/ip-forward-apply.sh` 用于按配置打开或关闭 Linux IP Forward。 +- `transparent/resolv-hijack-setup.sh` 用于把 `/etc/resolv.conf` 指向 `127.2.0.17`。 +- `transparent/resolv-hijack-cleanup.sh` 用于恢复 v2rayA 风格的兜底 DNS。 +- `transparent/transparent-iptables-setup.sh` 用于安装 legacy iptables 规则。 +- `transparent/transparent-iptables-cleanup.sh` 用于清理 legacy iptables 规则。 +- `transparent/transparent-nft-setup.sh` 用于加载 nftables 规则。 +- `transparent/transparent-nft-cleanup.sh` 用于清理 nftables 规则。 +- `transparent/v2raya.nft` 是 nftables 表配置;仅在当前透明代理类型需要 nftables 表时生成。 +- `transparent/tinytun.yaml` 是 TinyTun 配置;仅在 `type = tun` 且透明代理未关闭时生成。 + +### 模式 + +控制透明代理入口进来的流量怎么走路由。 + +可选值: + +```text +close +proxy +whitelist +gfwlist +pac +``` + +默认值: + +```text +close +``` + +含义: + +- `close` 表示关闭透明代理,不生成 transparent inbound。 +- `proxy` 表示透明代理流量全部走代理。 +- `whitelist` 表示透明代理流量按白名单模式分流。 +- `gfwlist` 表示透明代理流量按 GFWList 模式分流。 +- `pac` 表示透明代理流量跟随“路由”配置中的路由模式。 + +### 类型 + +控制透明代理用哪种方式接收系统转发来的流量。 + +可选值: + +```text +redirect +tproxy +system_proxy +tun +``` + +默认值: + +```text +redirect +``` + +含义: + +- `redirect` 使用 NAT REDIRECT,适合 TCP,Docker 友好。 +- `tproxy` 使用 TPROXY + fwmark + policy routing,支持 TCP/UDP,但规则复杂且 Docker 不友好。 +- `system_proxy` 生成 HTTP/SOCKS 代理入口,给系统代理或应用显式使用,不是真正的内核透明代理。 +- `tun` 表示生成 TinyTun 配置,由 TinyTun 创建 TUN 设备和系统路由。 + +注意事项: + +- 当前建议优先使用 `redirect`。 +- 如果需要 UDP 能力,后续可以考虑 `tproxy`,但要接受更复杂的系统规则。 +- Docker 容器透明代理优先考虑 `redirect`。 + +### 透明代理端口 + +默认值: + +```text +52345 +``` + +含义: + +- `redirect` / `tproxy` 时,系统规则会把流量导到这个端口,Xray 的 `dokodemo-door` 在这里接收流量。 +- `system_proxy` 时,这个端口作为 HTTP 代理端口。 +- `tun` 时,当前 pyxray 里仍是占位性质。 + +注意事项: + +- 应用通常不会主动连接这个端口,除非是 `system_proxy`。 +- 该端口不能被其它进程占用。 + +### 系统代理 SOCKS 端口 + +默认值: + +```text +52306 +``` + +含义: + +- 只有 `type = system_proxy` 时有意义。 +- 用作显式 SOCKS5 代理入口。 + +示例: + +```text +应用 -> 127.0.0.1:52306 SOCKS -> Xray -> proxy/direct +``` + +### 启用 IP Forward + +控制是否预期打开 Linux IP 转发能力。 + +默认值: + +```text +关闭 +``` + +含义: + +- 开启后,这台机器才能作为网关转发其它设备或容器的流量。 +- 如果只代理本机程序,一般不需要开启。 + +注意事项: + +- 当前 pyxray 只保存该字段,还没有执行系统命令打开 IP Forward。 +- 后续实现系统规则时,可能对应 `net.ipv4.ip_forward=1`。 + +### TUN 自动路由 + +控制 TUN 模式下是否预期自动添加系统路由。 + +默认值: + +```text +开启 +``` + +含义: + +- 开启后,程序应自动添加路由,把系统流量导入 TUN 虚拟网卡。 +- 关闭后,需要用户自己配置路由。 + +注意事项: + +- 当前 pyxray 会把该字段写入 `tinytun.yaml` 的 `tun.auto_route`。 +- 生成的 TinyTun 默认 TUN 地址参考 v2rayA:`198.18.0.1/32` 和 `fd00::1/128`。 +- 生成的 TinyTun SOCKS5 上游固定为 `127.0.0.1:52345`,对应 Xray 为 TUN 流量准备的本地 SOCKS 入站。 +- 当前 pyxray 只生成 `tinytun.yaml`,不会自动启动 TinyTun 进程。 + +### TPROXY 排除接口 + +默认值: + +```text +docker*,veth*,wg*,ppp*,br-* +``` + +含义: + +- 只对 `type = tproxy` 有意义。 +- 生成 TPROXY 系统规则时,这些接口进来的流量会被跳过。 +- 常用于避免 Docker、veth、WireGuard、PPP、bridge 接口被错误劫持。 + +注意事项: + +- 当前 UI 在 `type != tproxy` 时会禁用该输入。 +- 生成 TPROXY 系统规则脚本时会使用该字段。 + +### 当前阶段限制 + +当前透明代理配置能生成 Xray JSON 侧的 inbound / routing,也能生成 IP Forward、DNS 劫持、redirect / tproxy 系统规则脚本。 + +尚未实现: + +- TUN 设备创建。 +- TUN 进程启动和生命周期管理。 +- Docker 容器透明代理规则。 +- 根据宿主机环境自动选择 iptables-legacy / iptables-nft。 +- 自动执行或回滚生成的系统规则脚本。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8624983 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "pyxray" +version = "0.1.0" +description = "A lightweight Linux xray control plane." +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "flask>=3.1.2", + "tomlkit>=0.13.3", +] + +[project.scripts] +pyxray = "pyxray.cli:main" + +[dependency-groups] +dev = [ + "pytest>=8.4.2", +] + +[build-system] +requires = ["uv_build>=0.9.18,<0.10.0"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.uv.build-backend] +module-name = "pyxray" +module-root = "" diff --git a/pyxray/__init__.py b/pyxray/__init__.py new file mode 100644 index 0000000..57f8a53 --- /dev/null +++ b/pyxray/__init__.py @@ -0,0 +1,5 @@ +"""pyxray package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/pyxray/cli.py b/pyxray/cli.py new file mode 100644 index 0000000..1df19b5 --- /dev/null +++ b/pyxray/cli.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import argparse + +from pyxray.web.server import run_web + + +def main(argv: list[str] | None = None) -> None: + """pyxray 命令行入口。""" + + parser = argparse.ArgumentParser(prog="pyxray") + subparsers = parser.add_subparsers(dest="command") + _add_web_parser(subparsers) + + args = parser.parse_args(argv) + if args.command in (None, "web"): + run_web(args.host, args.port, args.xray_dir) + + +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.set_defaults(command="web") + + +if __name__ == "__main__": + main() diff --git a/pyxray/libs/__init__.py b/pyxray/libs/__init__.py new file mode 100644 index 0000000..61f5515 --- /dev/null +++ b/pyxray/libs/__init__.py @@ -0,0 +1 @@ +"""Small reusable building blocks for pyxray.""" diff --git a/pyxray/libs/nodes/__init__.py b/pyxray/libs/nodes/__init__.py new file mode 100644 index 0000000..92b6f9a --- /dev/null +++ b/pyxray/libs/nodes/__init__.py @@ -0,0 +1,21 @@ +from pyxray.libs.nodes.errors import InvalidNodeLinkError, NodeLinkError, UnsupportedNodeLinkError +from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links +from pyxray.libs.nodes.manager import AddNodeResult, NodeManager +from pyxray.libs.nodes.model import Node, ParsedNode +from pyxray.libs.nodes.parser import parse_node_link +from pyxray.libs.nodes.store import NodeStore, NodeStoreData + +__all__ = [ + "AddNodeResult", + "ImportNodeResult", + "InvalidNodeLinkError", + "Node", + "NodeLinkError", + "NodeManager", + "NodeStore", + "NodeStoreData", + "ParsedNode", + "UnsupportedNodeLinkError", + "import_node_links", + "parse_node_link", +] diff --git a/pyxray/libs/nodes/common.py b/pyxray/libs/nodes/common.py new file mode 100644 index 0000000..273fa0c --- /dev/null +++ b/pyxray/libs/nodes/common.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import base64 +from urllib.parse import unquote + + +def first_value(params: dict[str, list[str]], key: str, default: str = "") -> str: + """从 query 参数里取第一个值。""" + values = params.get(key) + if not values: + return default + return values[0] + + +def decode_base64_text(value: str) -> str: + """兼容 URL-safe 和标准 Base64 的文本解码。""" + padded = value + "=" * (-len(value) % 4) + try: + return base64.urlsafe_b64decode(padded.encode()).decode() + except Exception: + return base64.b64decode(padded.encode()).decode() + + +def display_name(fragment: str, fallback: str) -> str: + """从链接 fragment 取节点名;没有名称时使用服务器地址。""" + name = unquote(fragment).strip() + return name or fallback diff --git a/pyxray/libs/nodes/errors.py b/pyxray/libs/nodes/errors.py new file mode 100644 index 0000000..ad94e69 --- /dev/null +++ b/pyxray/libs/nodes/errors.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +class NodeLinkError(Exception): + """节点链接处理错误基类。""" + + +class UnsupportedNodeLinkError(NodeLinkError): + """链接协议当前不支持。""" + + +class InvalidNodeLinkError(NodeLinkError): + """链接协议支持,但内容格式无效。""" diff --git a/pyxray/libs/nodes/importer.py b/pyxray/libs/nodes/importer.py new file mode 100644 index 0000000..0064553 --- /dev/null +++ b/pyxray/libs/nodes/importer.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from pyxray.libs.nodes.errors import NodeLinkError +from pyxray.libs.nodes.model import Node +from pyxray.libs.nodes.parser import parse_node_link + + +@dataclass(frozen=True, slots=True) +class ImportNodeResult: + """批量导入单条链接的处理结果。""" + + link: str + node: Node | None = None + error: str | None = None + + @property + def ok(self) -> bool: + """当前链接是否成功解析。""" + return self.node is not None + + +def import_node_links(text: str) -> list[ImportNodeResult]: + """从多行文本里解析节点链接,不处理订阅分组和远程拉取。""" + results: list[ImportNodeResult] = [] + for link in extract_node_links(text): + try: + results.append(ImportNodeResult(link=link, node=parse_node_link(link))) + except NodeLinkError as exc: + results.append(ImportNodeResult(link=link, error=str(exc))) + return results + + +def extract_node_links(text: str) -> list[str]: + """从用户输入中提取候选节点链接。""" + links: list[str] = [] + for line in text.replace("\r\n", "\n").split("\n"): + clean = line.strip() + if not clean or clean.startswith("#"): + continue + links.append(clean) + return links diff --git a/pyxray/libs/nodes/manager.py b/pyxray/libs/nodes/manager.py new file mode 100644 index 0000000..9ec652b --- /dev/null +++ b/pyxray/libs/nodes/manager.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from pyxray.libs.nodes.importer import ImportNodeResult, import_node_links +from pyxray.libs.nodes.model import Node, utc_now +from pyxray.libs.nodes.parser import parse_node_link +from pyxray.libs.nodes.store import NodeStore, NodeStoreData + + +@dataclass(slots=True) +class AddNodeResult: + """添加节点后的结果。""" + + node: Node + created: bool + + +class NodeManager: + """管理节点列表和当前选中节点。""" + + def __init__(self, store: NodeStore) -> None: + self.store = store + + def list_nodes(self) -> list[Node]: + """返回当前保存的全部节点。""" + return self.store.load().nodes + + def get_node(self, node_id: str) -> Node | None: + """按节点 ID 查找节点。""" + for node in self.store.load().nodes: + if node.id == node_id: + return node + return None + + def add_link(self, link: str) -> AddNodeResult: + """解析并添加单条节点链接。""" + return self.add_node(parse_node_link(link)) + + def add_node(self, node: Node) -> AddNodeResult: + """添加或更新节点;ID 已存在时保留原创建时间。""" + data = self.store.load() + nodes: list[Node] = [] + created = True + now = utc_now() + + for current in data.nodes: + if current.id != node.id: + nodes.append(current) + continue + node.created_at = current.created_at + node.updated_at = now + nodes.append(node) + created = False + + if created: + node.created_at = now + node.updated_at = now + nodes.append(node) + + self.store.save(NodeStoreData(nodes=nodes, selected_id=data.selected_id)) + return AddNodeResult(node=node, created=created) + + def import_links(self, text: str) -> list[ImportNodeResult]: + """批量导入节点链接;解析失败的链接会保留错误结果。""" + results = import_node_links(text) + for result in results: + if result.node is not None: + self.add_node(result.node) + return results + + def remove_node(self, node_id: str) -> bool: + """删除节点;如果删除的是当前选中节点,则同时清空选择。""" + data = self.store.load() + nodes = [node for node in data.nodes if node.id != node_id] + removed = len(nodes) != len(data.nodes) + if not removed: + return False + + selected_id = "" if data.selected_id == node_id else data.selected_id + self.store.save(NodeStoreData(nodes=nodes, selected_id=selected_id)) + return True + + def select_node(self, node_id: str) -> Node: + """选择一个已存在节点。""" + data = self.store.load() + for node in data.nodes: + if node.id == node_id: + self.store.save(NodeStoreData(nodes=data.nodes, selected_id=node_id)) + return node + raise ValueError(f"node does not exist: {node_id}") + + def clear_selection(self) -> None: + """清空当前选中节点。""" + data = self.store.load() + self.store.save(NodeStoreData(nodes=data.nodes, selected_id="")) + + def get_selected_node(self) -> Node | None: + """返回当前选中节点;没有选择或节点已不存在时返回 None。""" + data = self.store.load() + if not data.selected_id: + return None + for node in data.nodes: + if node.id == data.selected_id: + return node + return None + + def selected_id(self) -> str: + """返回当前选中节点 ID。""" + return self.store.load().selected_id diff --git a/pyxray/libs/nodes/model.py b/pyxray/libs/nodes/model.py new file mode 100644 index 0000000..8103b04 --- /dev/null +++ b/pyxray/libs/nodes/model.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +def utc_now() -> str: + """返回标准 UTC 时间字符串。""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +@dataclass(slots=True) +class ParsedNode: + """协议解析后的节点数据,尚未补默认值和生成指纹。""" + + protocol: str + name: str + server: str + port: int + raw_link: str + settings: dict[str, Any] = field(default_factory=dict) + transport: dict[str, Any] = field(default_factory=dict) + security: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class Node: + """标准化后的节点数据。""" + + id: str + name: str + protocol: str + server: str + port: int + raw_link: str + canonical_link: str + fingerprint: str + settings: dict[str, Any] = field(default_factory=dict) + transport: dict[str, Any] = field(default_factory=dict) + security: dict[str, Any] = field(default_factory=dict) + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + + def to_dict(self) -> dict[str, Any]: + """转换成普通字典,便于后续 API 或持久化使用。""" + return { + "id": self.id, + "name": self.name, + "protocol": self.protocol, + "server": self.server, + "port": self.port, + "raw_link": self.raw_link, + "canonical_link": self.canonical_link, + "fingerprint": self.fingerprint, + "settings": dict(self.settings), + "transport": dict(self.transport), + "security": dict(self.security), + "created_at": self.created_at, + "updated_at": self.updated_at, + } diff --git a/pyxray/libs/nodes/normalize.py b/pyxray/libs/nodes/normalize.py new file mode 100644 index 0000000..98481ac --- /dev/null +++ b/pyxray/libs/nodes/normalize.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from urllib.parse import quote, urlencode + +from pyxray.libs.nodes.model import Node, ParsedNode + + +TRANSPORT_ALIASES = { + "websocket": "ws", + "h2": "http", + "kcp": "mkcp", +} + + +def normalize_node(parsed: ParsedNode) -> Node: + """把协议解析结果转换成统一节点结构。""" + protocol = parsed.protocol.lower() + transport = normalize_transport(parsed.transport) + security = normalize_security(parsed.security) + node = Node( + id="", + name=parsed.name or parsed.server, + protocol=protocol, + server=parsed.server, + port=parsed.port, + raw_link=parsed.raw_link, + canonical_link="", + fingerprint="", + settings=clean_dict(parsed.settings), + transport=transport, + security=security, + ) + node.canonical_link = build_canonical_link(node) + node.fingerprint = node_fingerprint(node) + node.id = node_id(node.protocol, node.fingerprint) + return node + + +def normalize_transport(transport: dict) -> dict: + """统一传输层字段和默认值。""" + network = str(transport.get("network") or "tcp").lower() + network = TRANSPORT_ALIASES.get(network, network) + normalized = {"network": network, **{k: v for k, v in transport.items() if k != "network"}} + normalized.setdefault("header_type", "none") + if network == "xhttp" and not normalized.get("xhttp_mode"): + normalized["xhttp_mode"] = "auto" + return clean_dict(normalized, keep_empty={"host", "path"}) + + +def normalize_security(security: dict) -> dict: + """统一安全层字段和默认值。""" + security_type = str(security.get("type") or "none").lower() + normalized = {"type": security_type, **{k: v for k, v in security.items() if k != "type"}} + return clean_dict(normalized) + + +def node_fingerprint(node: Node) -> str: + """根据真实连接参数生成稳定指纹;节点名称不参与计算。""" + payload = { + "protocol": node.protocol, + "server": node.server, + "port": node.port, + "settings": node.settings, + "transport": node.transport, + "security": node.security, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def node_id(protocol: str, fingerprint: str) -> str: + """由协议和指纹生成短节点 ID。""" + return f"{protocol}_{fingerprint[:12]}" + + +def build_canonical_link(node: Node) -> str: + """生成稳定分享链接,用于展示或去重排查。""" + if node.protocol == "vless": + return canonical_vless(node) + if node.protocol == "vmess": + return canonical_vmess(node) + if node.protocol == "trojan": + return canonical_trojan(node) + if node.protocol == "trojan-go": + return canonical_trojan_go(node) + if node.protocol == "shadowsocks": + return canonical_shadowsocks(node) + return node.raw_link + + +def canonical_vless(node: Node) -> str: + query = { + "type": node.transport.get("network"), + "security": node.security.get("type"), + "encryption": node.settings.get("encryption", "none"), + "flow": node.settings.get("flow"), + "headerType": node.transport.get("header_type"), + "host": node.transport.get("host"), + "path": node.transport.get("path"), + "serviceName": node.transport.get("service_name"), + "seed": node.transport.get("seed"), + "sni": node.security.get("server_name"), + "alpn": node.security.get("alpn"), + "fp": node.security.get("fingerprint"), + "pbk": node.security.get("public_key"), + "sid": node.security.get("short_id"), + "spx": node.security.get("spider_x"), + "allowInsecure": bool_query(node.security.get("allow_insecure")), + "key": node.transport.get("key"), + "quicSecurity": node.transport.get("quic_security"), + "xhttpMode": node.transport.get("xhttp_mode"), + "maxEarlyData": node.transport.get("max_early_data"), + "earlyDataHeaderName": node.transport.get("early_data_header_name"), + "multiMode": node.transport.get("multi_mode"), + "idleTimeout": node.transport.get("idle_timeout"), + "healthCheckTimeout": node.transport.get("health_check_timeout"), + "permitWithoutStream": node.transport.get("permit_without_stream"), + "initialWindowsSize": node.transport.get("initial_windows_size"), + } + return url_with_query("vless", node.settings["uuid"], node, query) + + +def canonical_vmess(node: Node) -> str: + payload = { + "v": "2", + "ps": node.name, + "add": node.server, + "port": str(node.port), + "id": node.settings["uuid"], + "aid": str(node.settings.get("alter_id", 0)), + "scy": node.settings.get("security", "auto"), + "net": node.transport.get("network"), + "type": node.transport.get("header_type", "none"), + "host": node.transport.get("host", ""), + "path": node.transport.get("path", ""), + "tls": node.security.get("type", "none"), + "sni": node.security.get("server_name", ""), + "alpn": node.security.get("alpn", ""), + "fp": node.security.get("fingerprint", ""), + } + encoded = base64.urlsafe_b64encode(json.dumps(clean_dict(payload), separators=(",", ":")).encode()).decode() + return "vmess://" + encoded.rstrip("=") + + +def canonical_trojan(node: Node) -> str: + query = { + "type": node.transport.get("network"), + "host": node.transport.get("host"), + "path": node.transport.get("path"), + "serviceName": node.transport.get("service_name"), + "sni": node.security.get("server_name"), + "alpn": node.security.get("alpn"), + "allowInsecure": bool_query(node.security.get("allow_insecure")), + } + return url_with_query("trojan", node.settings["password"], node, query) + + +def canonical_trojan_go(node: Node) -> str: + query = { + "type": node.transport.get("network"), + "host": node.transport.get("host"), + "path": node.transport.get("path"), + "serviceName": node.transport.get("service_name"), + "sni": node.security.get("server_name"), + "encryption": node.settings.get("encryption"), + } + return url_with_query("trojan-go", node.settings["password"], node, query) + + +def canonical_shadowsocks(node: Node) -> str: + user = f"{node.settings['method']}:{node.settings['password']}" + encoded_user = base64.urlsafe_b64encode(user.encode()).decode().rstrip("=") + return url_with_query("ss", encoded_user, node, {"plugin": node.settings.get("plugin")}, quote_username=False) + + +def url_with_query(scheme: str, username: str, node: Node, query: dict, *, quote_username: bool = True) -> str: + clean_query = {key: value for key, value in query.items() if value not in (None, "", "none")} + query_text = urlencode(clean_query) + user = quote(str(username), safe="") if quote_username else str(username) + url = f"{scheme}://{user}@{node.server}:{node.port}" + if query_text: + url += "?" + query_text + if node.name: + url += "#" + quote(node.name) + return url + + +def bool_query(value: object) -> str: + """把布尔值转换成分享链接里的 query 表达。""" + if value is True: + return "true" + return "" + + +def clean_dict(values: dict, *, keep_empty: set[str] | None = None) -> dict: + """移除空值,减少标准节点中的噪声字段。""" + keep_empty = keep_empty or set() + return { + key: value + for key, value in values.items() + if value is not None and (value != "" or key in keep_empty) + } diff --git a/pyxray/libs/nodes/parser.py b/pyxray/libs/nodes/parser.py new file mode 100644 index 0000000..2a4fa57 --- /dev/null +++ b/pyxray/libs/nodes/parser.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from urllib.parse import urlparse + +from pyxray.libs.nodes.errors import UnsupportedNodeLinkError +from pyxray.libs.nodes.model import Node, ParsedNode +from pyxray.libs.nodes.normalize import normalize_node +from pyxray.libs.nodes.parsers import parse_shadowsocks, parse_trojan, parse_vless, parse_vmess + + +def parse_node_link(link: str) -> Node: + """解析单个节点链接,并返回标准化后的节点。""" + return normalize_node(parse_raw_node_link(link)) + + +def parse_raw_node_link(link: str) -> ParsedNode: + """解析单个节点链接,不补默认值,不生成指纹。""" + clean = link.strip() + scheme = urlparse(clean).scheme.lower() + if scheme == "vless": + return parse_vless(clean) + if scheme == "vmess": + return parse_vmess(clean) + if scheme in {"trojan", "trojan-go"}: + return parse_trojan(clean) + if scheme in {"ss", "shadowsocks"}: + return parse_shadowsocks(clean) + raise UnsupportedNodeLinkError(f"unsupported node link scheme: {scheme or ''}") diff --git a/pyxray/libs/nodes/parsers/__init__.py b/pyxray/libs/nodes/parsers/__init__.py new file mode 100644 index 0000000..cc603c6 --- /dev/null +++ b/pyxray/libs/nodes/parsers/__init__.py @@ -0,0 +1,11 @@ +from pyxray.libs.nodes.parsers.shadowsocks import parse_shadowsocks +from pyxray.libs.nodes.parsers.trojan import parse_trojan +from pyxray.libs.nodes.parsers.vless import parse_vless +from pyxray.libs.nodes.parsers.vmess import parse_vmess + +__all__ = [ + "parse_shadowsocks", + "parse_trojan", + "parse_vless", + "parse_vmess", +] diff --git a/pyxray/libs/nodes/parsers/shadowsocks.py b/pyxray/libs/nodes/parsers/shadowsocks.py new file mode 100644 index 0000000..fdad136 --- /dev/null +++ b/pyxray/libs/nodes/parsers/shadowsocks.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from pyxray.libs.nodes.common import decode_base64_text, display_name, first_value +from pyxray.libs.nodes.errors import InvalidNodeLinkError +from pyxray.libs.nodes.model import ParsedNode + + +def parse_shadowsocks(link: str) -> ParsedNode: + """解析 Shadowsocks SIP002 分享链接。""" + parsed = normalize_shadowsocks_url(link) + user = parsed.username or "" + if ":" not in user: + try: + user = decode_base64_text(user) + except Exception as exc: + raise InvalidNodeLinkError("invalid shadowsocks userinfo") from exc + if ":" not in user or not parsed.hostname or not parsed.port: + raise InvalidNodeLinkError("shadowsocks link must include method, password, host and port") + + method, password = user.split(":", 1) + params = parse_qs(parsed.query, keep_blank_values=True) + plugin = first_value(params, "plugin") + return ParsedNode( + protocol="shadowsocks", + name=display_name(parsed.fragment, parsed.hostname), + server=parsed.hostname, + port=parsed.port, + raw_link=link, + settings={ + "method": method.lower(), + "password": password, + "plugin": plugin, + "plugin_options": parse_sip003_plugin(plugin), + }, + transport={"network": "tcp"}, + security={"type": "none"}, + ) + + +def normalize_shadowsocks_url(link: str): + """兼容整段 base64 的旧 SS 链接格式。""" + parsed = urlparse(link) + if parsed.hostname: + return parsed + + body = link[5:] + fragment = "" + if "#" in body: + body, fragment = body.split("#", 1) + try: + decoded = decode_base64_text(body) + except Exception as exc: + raise InvalidNodeLinkError("invalid shadowsocks base64 payload") from exc + normalized = "ss://" + decoded + if fragment: + normalized += "#" + fragment + return urlparse(normalized) + + +def parse_sip003_plugin(plugin: str) -> dict: + """解析 SIP003 plugin 参数,保留原始值并提供结构化字段。""" + if not plugin: + return {} + name, _, opts_text = plugin.partition(";") + if name in {"obfs-local", "simpleobfs"}: + name = "simple-obfs" + options = { + "name": name, + "raw": plugin, + "tls": "", + "obfs": "", + "host": "", + "path": "", + "impl": "", + } + for item in opts_text.split(";"): + if not item: + continue + key, _, value = item.partition("=") + if key == "tls": + options["tls"] = "tls" + elif key in {"obfs", "mode"}: + options["obfs"] = value + elif key in {"obfs-host", "host"}: + options["host"] = value + elif key in {"obfs-path", "obfs-uri", "path"}: + options["path"] = value if value.startswith("/") else f"/{value}" + elif key == "impl": + options["impl"] = value + return options diff --git a/pyxray/libs/nodes/parsers/trojan.py b/pyxray/libs/nodes/parsers/trojan.py new file mode 100644 index 0000000..ceb6111 --- /dev/null +++ b/pyxray/libs/nodes/parsers/trojan.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from pyxray.libs.nodes.common import display_name, first_value +from pyxray.libs.nodes.errors import InvalidNodeLinkError +from pyxray.libs.nodes.model import ParsedNode + + +def parse_trojan(link: str) -> ParsedNode: + """解析 Trojan 和 Trojan-Go 分享链接。""" + parsed = urlparse(link) + if not parsed.username or not parsed.hostname or not parsed.port: + raise InvalidNodeLinkError("trojan link must include password, host and port") + + params = parse_qs(parsed.query, keep_blank_values=True) + network = first_value(params, "type", "tcp") or "tcp" + server_name = first_value(params, "peer") or first_value(params, "sni") or parsed.hostname + is_trojan_go = parsed.scheme.lower() == "trojan-go" + return ParsedNode( + protocol="trojan-go" if is_trojan_go else "trojan", + name=display_name(parsed.fragment, parsed.hostname), + server=parsed.hostname, + port=parsed.port, + raw_link=link, + settings={ + "password": parsed.username, + "encryption": first_value(params, "encryption") if is_trojan_go else "", + }, + transport={ + "network": network, + "host": first_value(params, "host"), + "path": first_value(params, "path"), + "service_name": first_value(params, "serviceName"), + }, + security={ + "type": "tls", + "server_name": server_name, + "alpn": first_value(params, "alpn"), + "allow_insecure": False if is_trojan_go else first_value(params, "allowInsecure").lower() in {"1", "true"}, + }, + ) diff --git a/pyxray/libs/nodes/parsers/vless.py b/pyxray/libs/nodes/parsers/vless.py new file mode 100644 index 0000000..e259093 --- /dev/null +++ b/pyxray/libs/nodes/parsers/vless.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from pyxray.libs.nodes.common import display_name, first_value +from pyxray.libs.nodes.errors import InvalidNodeLinkError +from pyxray.libs.nodes.model import ParsedNode + + +def parse_vless(link: str) -> ParsedNode: + """解析 VLESS 分享链接。""" + parsed = urlparse(link) + if not parsed.username or not parsed.hostname or not parsed.port: + raise InvalidNodeLinkError("vless link must include uuid, host and port") + + params = parse_qs(parsed.query, keep_blank_values=True) + network = first_value(params, "type", "tcp") or "tcp" + security_type = first_value(params, "security", "none") or "none" + transport = { + "network": network, + "header_type": first_value(params, "headerType", "none") or "none", + "host": first_value(params, "host"), + "path": first_value(params, "path"), + "service_name": first_value(params, "serviceName"), + "seed": first_value(params, "seed"), + "key": first_value(params, "key"), + "quic_security": first_value(params, "quicSecurity"), + "xhttp_mode": first_value(params, "xhttpMode"), + "max_early_data": first_value(params, "maxEarlyData"), + "early_data_header_name": first_value(params, "earlyDataHeaderName"), + "multi_mode": first_value(params, "multiMode"), + "idle_timeout": first_value(params, "idleTimeout"), + "health_check_timeout": first_value(params, "healthCheckTimeout"), + "permit_without_stream": first_value(params, "permitWithoutStream"), + "initial_windows_size": first_value(params, "initialWindowsSize"), + } + security = { + "type": security_type, + "server_name": first_value(params, "sni"), + "fingerprint": first_value(params, "fp"), + "alpn": first_value(params, "alpn"), + "allow_insecure": first_value(params, "allowInsecure").lower() in {"1", "true"}, + "public_key": first_value(params, "pbk"), + "short_id": first_value(params, "sid"), + "spider_x": first_value(params, "spx"), + } + return ParsedNode( + protocol="vless", + name=display_name(parsed.fragment, parsed.hostname), + server=parsed.hostname, + port=parsed.port, + raw_link=link, + settings={ + "uuid": parsed.username, + "flow": first_value(params, "flow"), + "encryption": first_value(params, "encryption", "none") or "none", + }, + transport=transport, + security=security, + ) diff --git a/pyxray/libs/nodes/parsers/vmess.py b/pyxray/libs/nodes/parsers/vmess.py new file mode 100644 index 0000000..1d26d32 --- /dev/null +++ b/pyxray/libs/nodes/parsers/vmess.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +import re +from urllib.parse import parse_qs, urlparse + +from pyxray.libs.nodes.common import decode_base64_text, first_value +from pyxray.libs.nodes.errors import InvalidNodeLinkError +from pyxray.libs.nodes.model import ParsedNode + + +def parse_vmess(link: str) -> ParsedNode: + """解析 VMess Base64 JSON 分享链接。""" + try: + data = json.loads(decode_base64_text(link[8:])) + except Exception as exc: + return parse_legacy_vmess(link, exc) + + server = str(data.get("add") or "") + port = int(data.get("port") or 0) + uuid = str(data.get("id") or "") + if not server or not port or not uuid: + raise InvalidNodeLinkError("vmess link must include id, add and port") + + host = str(data.get("host") or "") + path = str(data.get("path") or "") + if host.startswith("/") and not path: + path = host + host = "" + + return ParsedNode( + protocol="vmess", + name=str(data.get("ps") or server), + server=server, + port=port, + raw_link=link, + settings={ + "uuid": uuid, + "alter_id": int(data.get("aid") or 0), + "security": str(data.get("scy") or "auto"), + }, + transport={ + "network": str(data.get("net") or "tcp"), + "header_type": str(data.get("type") or "none"), + "host": host, + "path": path, + }, + security={ + "type": str(data.get("tls") or "none"), + "server_name": str(data.get("sni") or ""), + "alpn": str(data.get("alpn") or ""), + "fingerprint": str(data.get("fp") or data.get("fingerprint") or ""), + "allow_insecure": _bool(data.get("allowInsecure")), + }, + ) + + +def parse_legacy_vmess(link: str, original_error: Exception) -> ParsedNode: + """兼容旧式 VMess 链接。""" + parsed = urlparse(link) + encoded = link[8:].split("?", 1)[0] + try: + decoded = decode_base64_text(encoded) + except Exception as exc: + raise InvalidNodeLinkError("unsupported vmess link format") from original_error or exc + + match = re.match(r".*:(.+)@(.+):(\d+)$", decoded) + if match is None: + raise InvalidNodeLinkError("unsupported vmess link format") from original_error + + params = parse_qs(parsed.query, keep_blank_values=True) + name = first_value(params, "remarks") or first_value(params, "remark") + network = first_value(params, "obfs") + host = first_value(params, "obfsParam") + path = first_value(params, "path") + if network in {"kcp", "mkcp"} and host: + try: + path = str(json.loads(host).get("seed") or "") + host = "" + except Exception: + pass + if network == "websocket": + network = "ws" + alter_id = first_value(params, "alterId") or first_value(params, "aid") or "0" + vmess_security = first_value(params, "scy") or first_value(params, "security") or "auto" + tls = "tls" if first_value(params, "tls") == "1" else "none" + return ParsedNode( + protocol="vmess", + name=name or match.group(2), + server=match.group(2), + port=int(match.group(3)), + raw_link=link, + settings={ + "uuid": match.group(1), + "alter_id": int(alter_id), + "security": vmess_security, + }, + transport={ + "network": network or "tcp", + "header_type": "none", + "host": host, + "path": path, + }, + security={ + "type": tls, + "server_name": first_value(params, "sni"), + "allow_insecure": False, + }, + ) + + +def _bool(value: object) -> bool: + """兼容字符串和布尔值形式的开关参数。""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"1", "true"} + return False diff --git a/pyxray/libs/nodes/store.py b/pyxray/libs/nodes/store.py new file mode 100644 index 0000000..a60848d --- /dev/null +++ b/pyxray/libs/nodes/store.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import tomlkit + +from pyxray.libs.nodes.model import Node + + +@dataclass(slots=True) +class NodeStoreData: + """节点 TOML 文件中的完整状态。""" + + nodes: list[Node] = field(default_factory=list) + selected_id: str = "" + + +class NodeStore: + """负责节点 TOML 文件读写,不处理节点解析和业务选择逻辑。""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def load(self) -> NodeStoreData: + """读取节点文件;文件不存在时返回空状态。""" + if not self.path.exists(): + return NodeStoreData() + + raw = tomlkit.parse(self.path.read_text(encoding="utf-8")) + nodes = [_node_from_dict(item) for item in raw.get("nodes", [])] + selected_id = str(raw.get("selected_id") or "") + return NodeStoreData(nodes=nodes, selected_id=selected_id) + + def save(self, data: NodeStoreData) -> None: + """原子写入节点文件。""" + self.path.parent.mkdir(parents=True, exist_ok=True) + content = dump_nodes_toml(data) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=self.path.parent, + prefix=f".{self.path.name}.", + suffix=".tmp", + delete=False, + ) as temp: + temp.write(content) + temp_path = Path(temp.name) + os.replace(temp_path, self.path) + + +def dump_nodes_toml(data: NodeStoreData) -> str: + """把节点状态输出为稳定 TOML 文本。""" + document = tomlkit.document() + document["selected_id"] = data.selected_id + + nodes = tomlkit.aot() + for node in data.nodes: + table = tomlkit.table() + for key, value in node.to_dict().items(): + table[key] = _toml_table(value) if isinstance(value, dict) else value + nodes.append(table) + document["nodes"] = nodes + return tomlkit.dumps(document) + + +def _node_from_dict(values: dict[str, Any]) -> Node: + return Node( + id=str(values["id"]), + name=str(values["name"]), + protocol=str(values["protocol"]), + server=str(values["server"]), + port=int(values["port"]), + raw_link=str(values["raw_link"]), + canonical_link=str(values["canonical_link"]), + fingerprint=str(values["fingerprint"]), + settings=dict(values.get("settings", {})), + transport=dict(values.get("transport", {})), + security=dict(values.get("security", {})), + created_at=str(values["created_at"]), + updated_at=str(values["updated_at"]), + ) + + +def _toml_table(values: dict[str, Any]): + table = tomlkit.table() + for key, value in values.items(): + table[key] = _toml_table(value) if isinstance(value, dict) else value + return table diff --git a/pyxray/libs/xray_asset_settings.py b/pyxray/libs/xray_asset_settings.py new file mode 100644 index 0000000..529ed34 --- /dev/null +++ b/pyxray/libs/xray_asset_settings.py @@ -0,0 +1,75 @@ +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_VERSION + + +@dataclass(slots=True) +class XrayAssetSettings: + """Xray 资源下载页面的可持久化设置。""" + + directory: str = "data/xray" + version: str = DEFAULT_VERSION + 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) + values = dict(tomlkit.parse(self.path.read_text(encoding="utf-8"))) + if "directory" not in values: + values["directory"] = self.default_directory + return XrayAssetSettings.from_dict(values) + + def save(self, settings: XrayAssetSettings) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + document = tomlkit.document() + for key, value in settings.to_dict().items(): + document[key] = value + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=self.path.parent, + prefix=f".{self.path.name}.", + suffix=".tmp", + delete=False, + ) as temp: + temp.write(tomlkit.dumps(document)) + temp_path = Path(temp.name) + os.replace(temp_path, self.path) diff --git a/pyxray/libs/xray_assets.py b/pyxray/libs/xray_assets.py new file mode 100644 index 0000000..e53e671 --- /dev/null +++ b/pyxray/libs/xray_assets.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import os +import stat +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" +DEFAULT_VERSION = "v26.5.9" +DEFAULT_ARCHIVE_NAME = "Xray-linux-64.zip" +REQUIRED_FILES = ("xray", "geoip.dat", "geosite.dat") +ASSET_TARGETS = ("all", "xray", "geoip", "geosite") + +Downloader = Callable[[str], bytes] +DownloadProgress = Callable[[str, int, int | 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 official_archive_url(version: str = DEFAULT_VERSION, archive_name: str = DEFAULT_ARCHIVE_NAME) -> str: + """返回官方 Xray-core release zip 下载地址。""" + + return f"{OFFICIAL_RELEASE_BASE}/{version}/{archive_name}" + + +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" + geoip = directory / "geoip.dat" + geosite = directory / "geosite.dat" + downloaded: list[str] = [] + skipped: list[str] = [] + + requested = _requested_files(target) + archive_names = { + name + for name in requested + if name == "xray" + 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 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 = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}) + ) + return opener.open(url) + return urllib.request.urlopen(url) # noqa: S310 + + +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",) + if target == "geoip": + return ("geoip.dat",) + if target == "geosite": + return ("geosite.dat",) + raise ValueError(f"unsupported xray asset target: {target}") + + +def _write_file(path: Path, data: bytes) -> None: + """先写临时文件再替换,避免半写入文件被误用。""" + + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def _make_executable(path: Path) -> None: + """确保 xray 二进制具备基础执行权限。""" + + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) diff --git a/pyxray/libs/xray_config/__init__.py b/pyxray/libs/xray_config/__init__.py new file mode 100644 index 0000000..7cf2a2b --- /dev/null +++ b/pyxray/libs/xray_config/__init__.py @@ -0,0 +1,22 @@ +from pyxray.libs.xray_config.generator import generate_xray_config +from pyxray.libs.xray_config.settings import XrayConfigSettings +from pyxray.libs.xray_config.store import XrayConfigSettingsStore +from pyxray.libs.xray_config.tinytun_config import generate_tinytun_config, write_tinytun_config_file +from pyxray.libs.xray_config.transparent_rules import ( + TransparentRuleFiles, + TransparentRuleSet, + generate_transparent_rules, + write_transparent_rule_files, +) + +__all__ = [ + "TransparentRuleFiles", + "TransparentRuleSet", + "XrayConfigSettings", + "XrayConfigSettingsStore", + "generate_tinytun_config", + "generate_transparent_rules", + "generate_xray_config", + "write_tinytun_config_file", + "write_transparent_rule_files", +] diff --git a/pyxray/libs/xray_config/generator.py b/pyxray/libs/xray_config/generator.py new file mode 100644 index 0000000..9d098f4 --- /dev/null +++ b/pyxray/libs/xray_config/generator.py @@ -0,0 +1,424 @@ +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(), + block_outbound(), + dns_outbound(), + ], + "routing": { + "domainStrategy": "IPOnDemand", + "domainMatcher": "mph", + "rules": [], + }, + "dns": _build_dns(settings, node), + } + log = _build_log(settings) + if log: + config["log"] = log + config["routing"]["rules"].extend(_build_dns_routing(settings)) + config["routing"]["rules"].append({"type": "field", "inboundTag": _dns_inbound_tags(settings), "outboundTag": "direct"}) + 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), + _socks_inbound(settings.inbounds.rule_socks_port, listen, "rule-socks", settings), + _http_inbound(settings.inbounds.rule_http_port, listen, "rule-http", 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": {"auth": "noauth", "udp": True}, "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 _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 _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 + return [ + _with_sniffing( + { + "listen": "127.0.0.1", + "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 + inbound["sniffing"] = { + "enabled": True, + "destOverride": settings.inbounds.inbound_sniffing.split(","), + "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] = [] + 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 _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 = [tag for tag, port in (("rule-http", settings.inbounds.rule_http_port), ("rule-socks", settings.inbounds.rule_socks_port)) if port > 0] + if not tags: + return [] + mode = settings.routing.mode + return _routing_rules_by_mode( + mode, + tags, + default=settings.routing.default_rule, + custom_rules=settings.routing.custom_rules, + routing_a=settings.routing.routing_a, + ) + + +def _build_transparent_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]: + if settings.transparent.mode == "close": + return [] + tags = ["transparent"] + if settings.transparent.type == "system_proxy": + tags.append("transparent-socks") + if settings.transparent.mode == "pac": + return _routing_rules_by_mode( + settings.routing.mode, + tags, + default=settings.routing.default_rule, + custom_rules=settings.routing.custom_rules, + routing_a=settings.routing.routing_a, + ) + return _routing_rules_by_mode( + settings.transparent.mode, + tags, + default=settings.routing.default_rule, + custom_rules=settings.routing.custom_rules, + routing_a=settings.routing.routing_a, + ) + + +def _routing_rules_by_mode( + mode: str, + inbounds: list[str], + *, + default: str, + custom_rules: list[Any], + routing_a: str, +) -> list[dict[str, Any]]: + rules = _parse_routing_a(inbounds, routing_a) + if mode == "whitelist": + rules.extend([ + {"type": "field", "inboundTag": inbounds, "domain": ["domain:push-apple.com.akadns.net", "domain:push.apple.com"], "outboundTag": "direct"}, + {"type": "field", "inboundTag": inbounds, "domain": ["geosite:geolocation-!cn"], "outboundTag": "proxy"}, + {"type": "field", "inboundTag": inbounds, "domain": ["geosite:google"], "outboundTag": "proxy"}, + {"type": "field", "inboundTag": inbounds, "domain": ["geosite:cn"], "outboundTag": "direct"}, + {"type": "field", "inboundTag": inbounds, "ip": ["geoip:hk", "geoip:mo"], "outboundTag": "proxy"}, + {"type": "field", "inboundTag": inbounds, "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"}, + ]) + rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) + return rules + if mode == "gfwlist": + rules.extend([ + {"type": "field", "inboundTag": inbounds, "domain": ["geosite:geolocation-!cn"], "outboundTag": "proxy"}, + { + "type": "field", + "inboundTag": inbounds, + "ip": [ + "91.105.192.0/23", + "91.108.4.0/22", + "91.108.8.0/21", + "91.108.16.0/21", + "91.108.56.0/22", + "95.161.64.0/20", + "149.154.160.0/20", + "185.76.151.0/24", + "2001:67c:4e8::/48", + "2001:b28:f23c::/47", + "2001:b28:f23f::/48", + "2a0a:f280:203::/48", + ], + "outboundTag": "proxy", + }, + {"type": "field", "inboundTag": inbounds, "outboundTag": "direct"}, + ]) + return rules + if mode == "custom": + for item in custom_rules: + target = {"direct": "direct", "proxy": "proxy", "block": "block"}[item.rule_type] + values = [f"ext:{item.filename}:{tag}" if item.filename else tag for tag in item.tags] + rule: dict[str, Any] = {"type": "field", "inboundTag": inbounds, "outboundTag": target} + rule["domain" if item.match_type == "domain" else "ip"] = values + rules.append(rule) + rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) + return rules + if mode == "routingA": + rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": default}) + return rules + rules.append({"type": "field", "inboundTag": inbounds, "outboundTag": mode if mode in {"proxy", "direct", "block"} else default}) + return rules + + +def _parse_routing_a(inbounds: list[str], text: str = "") -> list[dict[str, Any]]: + rules: list[dict[str, Any]] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "->" not in line or "(" not in line or ")" not in line: + continue + matcher, outbound = [item.strip() for item in line.split("->", 1)] + name, _, rest = matcher.partition("(") + values = [item.strip() for item in rest.rsplit(")", 1)[0].split(",") if item.strip()] + if name not in {"domain", "ip"} or not values: + continue + rule: dict[str, Any] = {"type": "field", "inboundTag": inbounds, "outboundTag": outbound} + rule["domain" if name == "domain" else "ip"] = values + rules.append(rule) + return rules + + +def _default_routing_a() -> str: + return """default: proxy +domain(domain:mail.qq.com)->direct +domain(geosite:google-scholar)->proxy +domain(geosite:category-scholar-!cn, geosite:category-scholar-cn)->direct +domain(geosite:geolocation-!cn, geosite:google)->proxy +domain(geosite:cn)->direct +ip(geoip:hk,geoip:mo)->proxy +ip(geoip:private, geoip:cn)->direct""" + + +def _build_selected_node_whitelist(node: Node) -> list[dict[str, Any]]: + key = "ip" if _is_ip(node.server) else "domain" + return [{"type": "field", key: [node.server], "port": str(node.port), "outboundTag": "direct"}] + + +def _build_api(settings: XrayConfigSettings) -> dict[str, Any] | None: + port = settings.inbounds.api.port + if port <= 0: + return None + services = list(dict.fromkeys([*settings.inbounds.api.services, "LoggerService"])) + return { + "api": {"tag": "api-out", "services": services}, + "inbound": { + "port": port, + "protocol": "dokodemo-door", + "listen": "127.0.0.1", + "settings": {"address": "127.0.0.1"}, + "tag": "api-in", + }, + "routing": {"type": "field", "inboundTag": ["api-in"], "outboundTag": "api-out"}, + } + + +def _domains_to_lookup(settings: XrayConfigSettings, node: Node) -> list[str]: + domains = [] if _is_ip(node.server) else [node.server] + for rule in settings.dns.rules: + host, _ = _parse_dns_addr(rule.server) + if host not in {"", "localhost"} and not _is_ip(host): + domains.append(host) + return list(dict.fromkeys(domains)) + + +def _parse_dns_addr(value: str) -> tuple[str, int]: + if "://" in value: + host = value.split("://", 1)[1].split("/", 1)[0] + else: + host = value + if host.startswith("[") and "]" in host: + raw_host, _, raw_port = host[1:].partition("]:") + return raw_host, int(raw_port or 53) + if host.count(":") == 1: + raw_host, raw_port = host.rsplit(":", 1) + if raw_port.isdigit(): + return raw_host, int(raw_port) + return host, 53 + + +def _split_lines(value: str) -> list[str]: + return [line.strip() for line in value.splitlines() if line.strip()] + + +def _is_ip(value: str) -> bool: + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False diff --git a/pyxray/libs/xray_config/outbound.py b/pyxray/libs/xray_config/outbound.py new file mode 100644 index 0000000..4035d47 --- /dev/null +++ b/pyxray/libs/xray_config/outbound.py @@ -0,0 +1,212 @@ +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) + return outbound + + +def direct_outbound() -> dict[str, Any]: + """生成 v2rayA 使用的 direct outbound。""" + return {"tag": "direct", "protocol": "freedom", "settings": {"domainStrategy": "UseIP"}} + + +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 _split_csv(value: object) -> list[str]: + if not value: + return [] + if isinstance(value, list): + return [str(item) for item in value if str(item)] + return [item.strip() for item in str(value).split(",") if item.strip()] + + +def _int_or_none(value: object) -> int | None: + if value in (None, ""): + return None + return int(value) + + +def _bool(value: object) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"1", "true", "yes"} + return bool(value) + + +def _clean(value: dict[str, Any]) -> dict[str, Any]: + return { + key: item + for key, item in value.items() + if item is not None and item != "" and item != [] and (not isinstance(item, dict) or item) + } diff --git a/pyxray/libs/xray_config/settings.py b/pyxray/libs/xray_config/settings.py new file mode 100644 index 0000000..b587bcf --- /dev/null +++ b/pyxray/libs/xray_config/settings.py @@ -0,0 +1,234 @@ +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 + 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 + tproxy_excluded_interfaces: str = "docker*,veth*,wg*,ppp*,br-*" + 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" + + +@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") + _validate_choice(settings.routing.mode, {"whitelist", "gfwlist", "custom", "routingA", "proxy", "direct", "block"}, "routing.mode") + _validate_choice(settings.routing.default_rule, {"direct", "proxy", "block"}, "routing.default_rule") + _validate_choice(settings.transparent.mode, {"close", "proxy", "whitelist", "gfwlist", "pac"}, "transparent.mode") + _validate_choice(settings.transparent.type, {"redirect", "tproxy", "system_proxy", "tun"}, "transparent.type") + _validate_choice(settings.dns.antipollution, {"closed", "none", "dnsforward", "doh", "advanced"}, "dns.antipollution") + _validate_choice(settings.dns.special_mode, {"none", "supervisor", "fakedns"}, "dns.special_mode") + for inbound in settings.inbounds.custom: + _validate_choice(inbound.protocol, {"socks", "http"}, f"custom inbound {inbound.tag}.protocol") + if not inbound.tag: + raise ValueError("custom inbound tag must not be empty") + _validate_port(inbound.port, f"custom inbound {inbound.tag}.port", allow_zero=False) + for name, port in { + "inbounds.socks_port": settings.inbounds.socks_port, + "inbounds.http_port": settings.inbounds.http_port, + "inbounds.rule_socks_port": settings.inbounds.rule_socks_port, + "inbounds.rule_http_port": settings.inbounds.rule_http_port, + "inbounds.vmess_port": settings.inbounds.vmess_port, + "inbounds.api.port": settings.inbounds.api.port, + "transparent.port": settings.transparent.port, + "transparent.socks_port": settings.transparent.socks_port, + }.items(): + _validate_port(port, name) + + +def _validate_choice(value: str, choices: set[str], name: str) -> None: + if value not in choices: + raise ValueError(f"{name} must be one of: {', '.join(sorted(choices))}") + + +def _validate_port(value: int, name: str, *, allow_zero: bool = True) -> None: + minimum = 0 if allow_zero else 1 + if not minimum <= value <= 65535: + raise ValueError(f"{name} must be between {minimum} and 65535") diff --git a/pyxray/libs/xray_config/store.py b/pyxray/libs/xray_config/store.py new file mode 100644 index 0000000..f3774b3 --- /dev/null +++ b/pyxray/libs/xray_config/store.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Any + +import tomlkit + +from pyxray.libs.xray_config.settings import XrayConfigSettings, validate_settings + + +class XrayConfigSettingsStore: + """负责 settings.toml 的读写。""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def load(self) -> XrayConfigSettings: + """读取设置;文件不存在时返回默认值。""" + if not self.path.exists(): + return XrayConfigSettings() + raw = tomlkit.parse(self.path.read_text(encoding="utf-8")) + settings = XrayConfigSettings.from_dict(dict(raw)) + validate_settings(settings) + return settings + + def save(self, settings: XrayConfigSettings) -> None: + """原子写入设置。""" + validate_settings(settings) + self.path.parent.mkdir(parents=True, exist_ok=True) + content = dump_settings_toml(settings) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=self.path.parent, + prefix=f".{self.path.name}.", + suffix=".tmp", + delete=False, + ) as temp: + temp.write(content) + temp_path = Path(temp.name) + os.replace(temp_path, self.path) + + +def dump_settings_toml(settings: XrayConfigSettings) -> str: + """输出稳定的 settings.toml。""" + document = tomlkit.document() + values = settings.to_dict() + for key in ("core", "inbounds", "routing", "transparent", "dns", "auto_update"): + document[key] = _toml_value(values[key]) + document["outbounds"] = _toml_value(values["outbounds"]) + return tomlkit.dumps(document) + + +def _toml_value(value: Any): + if isinstance(value, list): + if not value or not isinstance(value[0], dict): + return value + array = tomlkit.aot() + for item in value: + array.append(_toml_table(item)) + return array + if isinstance(value, dict): + return _toml_table(value) + return value + + +def _toml_table(values: dict[str, Any]): + table = tomlkit.table() + for key, value in values.items(): + table[key] = _toml_value(value) + return table diff --git a/pyxray/libs/xray_config/tinytun_config.py b/pyxray/libs/xray_config/tinytun_config.py new file mode 100644 index 0000000..6b198a3 --- /dev/null +++ b/pyxray/libs/xray_config/tinytun_config.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import ipaddress +from pathlib import Path +from typing import Any + +from pyxray.libs.nodes.model import Node +from pyxray.libs.xray_config.settings import DnsRuleSettings, XrayConfigSettings + + +TUN_IPV4 = "198.18.0.1" +TUN_NETMASK = "255.255.255.255" +TUN_IPV6 = "fd00::1" +TUN_IPV6_PREFIX = 128 +TUN_SOCKS_PORT = 52345 + +DEFAULT_SKIP_NETWORKS = [ + "127.0.0.0/8", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10", +] + + +def generate_tinytun_config( + node: Node, + settings: XrayConfigSettings | None = None, + *, + geosite_file: str = "geosite.dat", +) -> dict[str, Any]: + """生成 TinyTun YAML 对应的普通字典。 + + 结构交叉参考 `.v2rayA/service/core/v2ray/tinytun_enabled.go`。 + TinyTun 负责 TUN 数据面和 DNS 路由;Xray 只需要提供本地 SOCKS5 入口。 + """ + + settings = settings or XrayConfigSettings() + return { + "log": {"loglevel": settings.core.log_level, "hide_timestamp": False}, + "tun": { + "name": "tun0", + "ip": TUN_IPV4, + "netmask": TUN_NETMASK, + "ipv6_mode": "auto", + "ipv6": TUN_IPV6, + "ipv6_prefix": TUN_IPV6_PREFIX, + "auto_route": settings.transparent.tun_auto_route, + "mtu": 1500, + }, + "socks5": {"name": "proxy", "address": f"127.0.0.1:{TUN_SOCKS_PORT}"}, + "dns": _tinytun_dns(settings, geosite_file=geosite_file), + "filtering": { + "skip_ips": _skip_ips(node), + "skip_networks": _skip_networks(settings), + "block_ports": [22, 23, 25, 110, 143], + "allow_ports": [80, 443, 53], + "exclude_processes": _exclude_processes(settings), + }, + "route": {"auto_detect_interface": True}, + } + + +def write_tinytun_config_file( + node: Node, + settings: XrayConfigSettings, + output_dir: str | Path, + *, + geosite_file: str = "geosite.dat", +) -> Path | None: + """当透明代理类型为 TUN 时写出 `tinytun.yaml`。""" + + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + path = output / "tinytun.yaml" + if settings.transparent.mode == "close" or settings.transparent.type != "tun": + if path.exists(): + path.unlink() + return None + path.write_text(_dump_yaml(generate_tinytun_config(node, settings, geosite_file=geosite_file)), encoding="utf-8") + path.chmod(0o600) + return path + + +def _tinytun_dns(settings: XrayConfigSettings, *, geosite_file: str) -> dict[str, Any]: + direct_servers: list[str] = [] + proxy_servers: list[str] = [] + routing_rules: list[str] = [] + fallback_group = "proxy" + + for rule in settings.dns.rules: + upstream = _dns_upstream(rule) + server = _normalize_dns_server(rule.server) + if upstream == "direct" and server: + direct_servers.append(server) + elif upstream == "proxy" and server: + proxy_servers.append(server) + + domains = [line.strip() for line in rule.domains.splitlines() if line.strip()] + action = "reject" if upstream == "block" else upstream + for domain in domains: + converted = _domain_pattern_to_tinytun_rule(domain, geosite_file, action) + if converted: + routing_rules.append(converted) + if not domains: + if upstream in {"direct", "proxy"}: + fallback_group = upstream + + if not direct_servers: + direct_servers = ["223.5.5.5:53", "114.114.114.114:53"] + if not proxy_servers: + proxy_servers = ["8.8.8.8:53", "1.1.1.1:53"] + + return { + "groups": [ + {"name": "direct", "servers": _dedupe(direct_servers), "strategy": "concurrent", "upstream": "direct", "protocol": "udp"}, + {"name": "proxy", "servers": _dedupe(proxy_servers), "strategy": "concurrent", "upstream": "proxy", "protocol": "udp"}, + ], + "listen_port": 53, + "timeout_ms": 5000, + "hijack": {"enabled": False, "mark": 1, "table_id": 100, "capture_tcp": True}, + "routing": { + "rules": _dedupe(routing_rules), + "fallback_group": fallback_group, + "geosite_file": geosite_file, + "enable_cache": True, + "cache_capacity": 4096, + }, + } + + +def _dns_upstream(rule: DnsRuleSettings) -> str: + if rule.outbound == "direct": + return "direct" + if rule.outbound == "block": + return "block" + return "proxy" + + +def _normalize_dns_server(server: str) -> str: + if server in {"", "localhost", "fakedns"} or "://" in server: + return "" + if server.startswith("[") and "]" in server: + host, _, raw_port = server[1:].partition("]:") + return f"{host}:{raw_port or '53'}" if _is_ip(host) else "" + if server.count(":") == 1: + host, port = server.rsplit(":", 1) + return f"{host}:{port}" if _is_ip(host) and port.isdigit() else "" + return f"{server}:53" if _is_ip(server) else "" + + +def _domain_pattern_to_tinytun_rule(pattern: str, geosite_file: str, action: str) -> str: + if pattern.startswith("geosite:"): + condition = pattern if geosite_file else "" + elif pattern.startswith("full:"): + condition = "domain:" + pattern.removeprefix("full:") + elif pattern.startswith("domain:"): + condition = "suffix:" + pattern.removeprefix("domain:") + elif pattern.startswith("suffix:"): + condition = "suffix:" + pattern.removeprefix("suffix:") + elif pattern.startswith("keyword:"): + condition = "keyword:" + pattern.removeprefix("keyword:") + elif pattern.startswith("regexp:"): + condition = "regex:" + pattern.removeprefix("regexp:") + elif _is_domain_like(pattern): + condition = "suffix:" + pattern + else: + condition = "" + return f"match({condition}),{action}" if condition else "" + + +def _skip_ips(node: Node) -> list[str]: + values = ["127.0.0.1", "::1", TUN_IPV4] + if _is_ip(node.server): + values.append(node.server) + return _dedupe(values) + + +def _skip_networks(settings: XrayConfigSettings) -> list[str]: + configured = [item.strip() for item in settings.transparent.tun_bypass_interfaces.replace("\n", ",").split(",") if item.strip()] + return _dedupe([*DEFAULT_SKIP_NETWORKS, *configured]) + + +def _exclude_processes(settings: XrayConfigSettings) -> list[str]: + raw = settings.transparent.tun_exclude_processes + values = [item.strip() for item in raw.replace(";", ",").replace("\n", ",").split(",") if item.strip()] + return _dedupe([Path(item).name for item in values if Path(item).name]) + + +def _dump_yaml(value: Any, indent: int = 0) -> str: + lines = _yaml_lines(value, indent) + return "\n".join(lines) + "\n" + + +def _yaml_lines(value: Any, indent: int = 0) -> list[str]: + pad = " " * indent + if isinstance(value, dict): + lines: list[str] = [] + for key, item in value.items(): + if item is None or item == []: + continue + if isinstance(item, dict): + lines.append(f"{pad}{key}:") + lines.extend(_yaml_lines(item, indent + 2)) + elif isinstance(item, list): + lines.append(f"{pad}{key}:") + lines.extend(_yaml_lines(item, indent + 2)) + else: + lines.append(f"{pad}{key}: {_yaml_scalar(item)}") + return lines + if isinstance(value, list): + lines = [] + for item in value: + if isinstance(item, dict): + lines.append(f"{pad}-") + lines.extend(_yaml_lines(item, indent + 2)) + else: + lines.append(f"{pad}- {_yaml_scalar(item)}") + return lines + return [f"{pad}{_yaml_scalar(value)}"] + + +def _yaml_scalar(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + text = str(value) + if text == "" or any(char in text for char in [": ", "#", "{", "}", "[", "]", ",", "&", "*", "!", "|", ">", "'", '"']): + return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' + return text + + +def _is_ip(value: str) -> bool: + try: + ipaddress.ip_address(value) + return True + except ValueError: + return False + + +def _is_domain_like(value: str) -> bool: + return bool(value and "/" not in value and not _is_ip(value)) + + +def _dedupe(values: list[str]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + if value and value not in seen: + seen.add(value) + result.append(value) + return result diff --git a/pyxray/libs/xray_config/transparent_rules.py b/pyxray/libs/xray_config/transparent_rules.py new file mode 100644 index 0000000..ebadb55 --- /dev/null +++ b/pyxray/libs/xray_config/transparent_rules.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +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", +) -> TransparentRuleSet: + """生成透明代理系统侧规则。 + + 规则结构交叉参考 `.v2rayA/service/core/iptables`: + - redirect 使用 nat 表 TP_OUT / TP_PRE / TP_RULE,TCP REDIRECT 到透明代理端口。 + - tproxy 使用 mangle 表、fwmark `0x40/0xc0`、table `100` 和 TPROXY 目标。 + - system_proxy 不改内核转发规则,只提供桌面系统代理设置入口的占位脚本。 + + 该函数只生成脚本,不执行命令,方便 Web UI 展示、写文件和测试。 + """ + + settings = settings or XrayConfigSettings() + if settings.transparent.mode == "close": + return TransparentRuleSet(backend=backend, mode="close", setup="", cleanup="") + if backend not in {"iptables", "nft"}: + raise ValueError("backend must be one of: iptables, nft") + if settings.transparent.type == "redirect": + return _redirect_rules(settings, backend=backend, ipv6=ipv6, nftables_path=nftables_path) + if settings.transparent.type == "tproxy": + return _tproxy_rules(settings, backend=backend, ipv6=ipv6, nftables_path=nftables_path) + 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, +) -> 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) + nft_rules = generate_transparent_rules(settings, backend="nft", ipv6=ipv6, nftables_path=str(nftables_path)) + + 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], + "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 -w 2 -t nat -A TP_PRE -j TP_RULE", + "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=_script(cleanup)) + + +def _tproxy_rules(settings: XrayConfigSettings, *, backend: str, ipv6: bool, nftables_path: str) -> TransparentRuleSet: + if backend == "nft": + table = _tproxy_nft_table(settings, ipv6=ipv6) + 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 -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", + "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", + 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", + *[f"iptables -w 2 -t mangle -A TP_RULE -d {cidr} -j RETURN" for cidr in settings.transparent.tproxy_white_custom_ips], + "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=_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 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)} +{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 + {nfproto} meta l4proto tcp jump tp_rule + }} + + chain tp_out {{ + type nat hook output priority -105 + {nfproto} meta l4proto tcp jump tp_rule + }} +}}""" + + +def _tproxy_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)) + whitelist_returns = "\n".join(f" ip daddr {cidr} return" for cidr in settings.transparent.tproxy_white_custom_ips) + return f"""table inet v2raya {{ + 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 + meta l4proto {{ tcp, udp }} fib saddr type local fib daddr type != local jump tp_rule + }} + + chain tp_pre {{ + iifname "lo" mark & 0xc0 != 0x40 return + meta l4proto {{ tcp, udp }} fib saddr type != local fib daddr type != local jump tp_rule + 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 @interface return +{whitelist_returns} + {'ip6 daddr @interface6 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]: + return [item.strip() for item in settings.transparent.tproxy_excluded_interfaces.split(",") if item.strip()] + + +def _iptables_interface(value: str) -> str: + return value.replace("*", "+") + + +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 _write_script(path: Path, content: str) -> None: + path.write_text("#!/bin/sh\nset -eu\n" + (content.strip() + "\n" if content.strip() else ""), encoding="utf-8") + path.chmod(0o755) diff --git a/pyxray/libs/xray_runtime.py b/pyxray/libs/xray_runtime.py new file mode 100644 index 0000000..710cd68 --- /dev/null +++ b/pyxray/libs/xray_runtime.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import os +import signal +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, + ) -> 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.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._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._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._terminate_process(process) + + 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) -> None: + self.log_path.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).isoformat() + detail = f" {xray_path}" if xray_path is not None else "" + with self.log_path.open("ab") as log: + log.write(f"\n[{timestamp}] pyxray {action} xray{detail}\n".encode()) + + 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)) + return _tail_lines(file.read().decode("utf-8", errors="replace"), 1000) + + +def _tail_lines(content: str, line_count: int) -> str: + lines = content.splitlines() + return "\n".join(lines[-line_count:]) diff --git a/pyxray/web/__init__.py b/pyxray/web/__init__.py new file mode 100644 index 0000000..29240bb --- /dev/null +++ b/pyxray/web/__init__.py @@ -0,0 +1 @@ +"""Flask web UI for pyxray.""" diff --git a/pyxray/web/dashboard.py b/pyxray/web/dashboard.py new file mode 100644 index 0000000..8d7232f --- /dev/null +++ b/pyxray/web/dashboard.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +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_config.store import dump_settings_toml +from pyxray.web.nodes import get_node_manager +from pyxray.libs.xray_runtime import read_log_tail +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() + 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_VERSION), + nodes=nodes, + selected_id=selected_id, + 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=read_log_tail(log_path), + ) diff --git a/pyxray/web/jobs.py b/pyxray/web/jobs.py new file mode 100644 index 0000000..15e1612 --- /dev/null +++ b/pyxray/web/jobs.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import threading +import uuid +from typing import Any, Callable + +from flask import Flask + + +Job = dict[str, Any] +Worker = Callable[[Job], None] + + +class JobStore: + """线程内存任务表,用于 Web 页面轮询后台任务状态。""" + + def __init__(self, *, run_sync: bool = False) -> None: + self.run_sync = run_sync + self._jobs: dict[str, Job] = {} + self._lock = threading.Lock() + + def start(self, worker: Worker, payload: dict[str, Any] | None = None) -> Job: + """创建任务并启动 worker。""" + + job: Job = { + "id": uuid.uuid4().hex, + "state": "running", + "payload": payload or {}, + "steps": [], + "status": None, + "error": None, + } + with self._lock: + self._jobs[job["id"]] = job + if self.run_sync: + worker(job) + else: + threading.Thread(target=worker, args=(job,), daemon=True).start() + return job + + def get(self, job_id: str) -> Job | None: + """按 ID 获取任务。""" + + with self._lock: + return self._jobs.get(job_id) + + def cancel(self, job_id: str) -> Job | None: + """标记任务需要取消。""" + + with self._lock: + job = self._jobs.get(job_id) + if job is not None: + job["cancel_requested"] = True + if job.get("state") == "running": + job["state"] = "cancelled" + job["error"] = "cancelled" + job["steps"].append({"name": "任务已停止", "state": "bad", "detail": "用户已请求停止下载任务"}) + return job + + +def init_job_store(app: Flask, *, run_sync: bool = False) -> JobStore: + """给 Flask app 初始化通用任务表。""" + + store = JobStore(run_sync=run_sync) + app.extensions["pyxray_jobs"] = store + return store + + +def get_job_store(app: Flask) -> JobStore: + """从 Flask app 取出通用任务表。""" + + return app.extensions["pyxray_jobs"] diff --git a/pyxray/web/nodes.py b/pyxray/web/nodes.py new file mode 100644 index 0000000..8242638 --- /dev/null +++ b/pyxray/web/nodes.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +from flask import Blueprint, Flask, current_app, jsonify, request + +from pyxray.libs.nodes import NodeManager, NodeStore + + +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) + 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 + return jsonify({"node": node.to_dict()}) + + +@blueprint.delete("/") +def delete_node_api(node_id: str): # noqa: ANN202 + """删除节点。""" + + removed = get_node_manager(current_app).remove_node(node_id) + return jsonify({"removed": removed}) diff --git a/pyxray/web/server.py b/pyxray/web/server.py new file mode 100644 index 0000000..e92c334 --- /dev/null +++ b/pyxray/web/server.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import atexit +import signal +import sys +from pathlib import Path + +from flask import Flask + +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__) + 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" + 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") + _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 服务。""" + + 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 _bind_xray_lifecycle(app: Flask) -> None: + """确保 pyxray 进程退出时关闭它启动的 Xray。""" + + service = app.extensions["pyxray_xray_service"] + + def shutdown() -> None: + service.shutdown() + + atexit.register(shutdown) + if signal.getsignal(signal.SIGTERM) == signal.SIG_DFL: + signal.signal(signal.SIGTERM, lambda signum, frame: _shutdown_and_exit(shutdown, signum)) + if signal.getsignal(signal.SIGINT) == signal.default_int_handler: + signal.signal(signal.SIGINT, lambda signum, frame: _shutdown_and_exit(shutdown, signum)) + + +def _shutdown_and_exit(shutdown, signum: int) -> None: # noqa: ANN001 + shutdown() + sys.exit(128 + signum) diff --git a/pyxray/web/static/css/app.css b/pyxray/web/static/css/app.css new file mode 100644 index 0000000..ba53d6e --- /dev/null +++ b/pyxray/web/static/css/app.css @@ -0,0 +1,183 @@ +.tab-nav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tab-button { + border-radius: 1.25rem; + border: 1px solid transparent; + color: rgb(161 161 170); + padding: 0.75rem 1.25rem; + transition: + background-color 150ms ease, + border-color 150ms ease, + color 150ms ease; +} + +.tab-button:hover { + background: rgb(39 39 42); + color: rgb(244 244 245); +} + +.tab-button.is-active { + background: rgb(244 244 245); + border-color: rgb(244 244 245); + color: rgb(9 9 11); +} + +.tab-panel { + display: none; +} + +.tab-panel.is-active { + display: block; +} + +.config-card { + border: 1px solid rgb(39 39 42); + border-radius: 1.5rem; + background: rgb(9 9 11 / 0.45); + padding: 1rem; +} + +.config-card-title { + color: rgb(244 244 245); + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.config-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.config-grid-two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.config-grid-three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 640px) { + .config-grid-two, + .config-grid-three { + grid-template-columns: 1fr; + } +} + +.config-field { + display: grid; + gap: 0.5rem; +} + +.config-field span, +.config-check span { + color: rgb(161 161 170); + font-size: 0.875rem; +} + +.config-field small { + color: rgb(113 113 122); + font-size: 0.75rem; + line-height: 1.25rem; +} + +.config-field input, +.config-field select, +.config-field textarea { + background: rgb(9 9 11); + border: 1px solid rgb(63 63 70); + border-radius: 1rem; + color: rgb(244 244 245); + outline: none; + padding: 0.75rem 1rem; +} + +.config-field textarea { + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 0.75rem; + line-height: 1.5rem; + min-height: 8rem; +} + +.config-field input:focus, +.config-field select:focus, +.config-field textarea:focus { + border-color: rgb(161 161 170); +} + +.config-check { + align-items: center; + border: 1px solid rgb(39 39 42); + border-radius: 1rem; + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; +} + +.config-check input { + accent-color: rgb(244 244 245); + height: 1rem; + width: 1rem; +} + +.config-field-inline-check > span { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.config-field-inline-check input[type="checkbox"] { + accent-color: rgb(244 244 245); + height: 1rem; + width: 1rem; +} + +.config-actions { + bottom: 1.5rem; + display: flex; + gap: 0.75rem; + opacity: 0; + pointer-events: none; + position: fixed; + right: 1.5rem; + transform: translateY(0.75rem); + transition: + opacity 150ms ease, + transform 150ms ease; + z-index: 30; +} + +.config-actions.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.config-action-button { + align-items: center; + border-radius: 9999px; + border: 1px solid rgb(63 63 70); + box-shadow: 0 1.25rem 2.5rem rgb(0 0 0 / 0.35); + display: inline-flex; + font-size: 1.5rem; + font-weight: 700; + height: 3.25rem; + justify-content: center; + line-height: 1; + width: 3.25rem; +} + +.config-action-save { + background: rgb(244 244 245); + color: rgb(9 9 11); +} + +.config-action-reset { + background: rgb(24 24 27); + color: rgb(244 244 245); +} diff --git a/pyxray/web/static/js/app.js b/pyxray/web/static/js/app.js new file mode 100644 index 0000000..613d749 --- /dev/null +++ b/pyxray/web/static/js/app.js @@ -0,0 +1,490 @@ +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 serviceState = document.querySelector("#xray-service-state"); +const refreshLogsButton = document.querySelector("#refresh-logs-button"); +const clearLogsButton = document.querySelector("#clear-logs-button"); +const logContent = document.querySelector("#xray-log-content"); +const activeJobKey = "pyxray.activeAssetJobId"; +const activeFormKey = "pyxray.activeAssetForm"; +const activeTabKey = "pyxray.activeTab"; +let activeJobId = 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(); + 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(); + } +}); + +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(); + 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"); + if (!typeSelect || !excludedInput) return; + + const update = () => { + const disabled = typeSelect.value !== "tproxy"; + excludedInput.disabled = disabled; + excludedInput.classList.toggle("opacity-50", disabled); + }; + + typeSelect.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) { + serviceState.textContent = error.message; + serviceState.className = "rounded-full border border-red-500/30 bg-red-500/10 px-4 py-2 font-mono text-xs 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); + 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 ? "停止 Xray" : "启动 Xray"; + 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"; + serviceState.textContent = status.running ? `pid: ${status.pid}` : "xray: stopped"; + serviceState.className = "rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400"; +} + +async function refreshLogs() { + if (!logContent) return; + const response = await fetch("/api/xray/service/logs"); + const payload = await response.json(); + if (!response.ok) { + logContent.textContent = payload.error || `HTTP ${response.status}`; + return; + } + logContent.textContent = payload.content || "暂无日志。"; + 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}`); + logContent.textContent = "暂无日志。"; + } catch (error) { + logContent.textContent = error.message; + } finally { + clearLogsButton.disabled = false; + } +} + +function updateSettingsActions() { + configActions.classList.toggle("is-visible", serializeForm(settingsForm) !== settingsSnapshot); +} + +function serializeForm(targetForm) { + return JSON.stringify(Array.from(new FormData(targetForm).entries()).sort(([left], [right]) => left.localeCompare(right))); +} + +async function refreshNodes() { + const response = await fetch("/api/nodes"); + const payload = await response.json(); + nodes = payload.nodes; + selectedNodeId = payload.selected_id; + renderNodes(); +} + +function renderNodes() { + nodeCount.textContent = `${nodes.length} 个节点`; + const selected = nodes.find((node) => node.id === selectedNodeId); + selectedNodeLabel.textContent = selected ? `${selected.name} / ${selected.protocol}` : "未选择节点"; + selectedNodeLabel.className = selected + ? "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-sm text-emerald-300" + : "rounded-full border border-zinc-800 px-3 py-1 text-sm text-zinc-400"; + if (!nodes.length) { + nodeList.innerHTML = `
还没有导入节点。
`; + return; + } + nodeList.innerHTML = nodes.map((node) => { + const selectedClass = node.id === selectedNodeId ? "border-emerald-500/40 bg-emerald-500/10" : "border-zinc-800 bg-zinc-950/60"; + const selectedText = node.id === selectedNodeId ? "当前选择" : "选择"; + return ` +
+
+
+
${escapeHtml(node.name)}
+
${escapeHtml(node.protocol)}://${escapeHtml(node.server)}:${node.port}
+
+
+ + +
+
+
+ `; + }).join(""); +} + +async function pollJob(jobId) { + while (true) { + const response = await fetch(`/api/xray/assets/jobs/${jobId}`); + const job = await response.json(); + if (!response.ok) { + if (response.status === 404) { + localStorage.removeItem(activeJobKey); + localStorage.removeItem(activeFormKey); + } + throw new Error(job.error || `HTTP ${response.status}`); + } + + renderSteps(job.steps, job.state); + if (job.status) renderStatus(job.status.files); + + if (job.state === "done") { + localStorage.removeItem(activeJobKey); + localStorage.removeItem(activeFormKey); + finish("已完成", "done"); + return; + } + if (job.state === "bad") { + localStorage.removeItem(activeJobKey); + localStorage.removeItem(activeFormKey); + finish("失败", "bad"); + return; + } + if (job.state === "cancelled") { + localStorage.removeItem(activeJobKey); + localStorage.removeItem(activeFormKey); + finish("已停止", "bad"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} + +function renderSteps(steps, state) { + const activeDownload = [...steps].reverse().find((step) => step.name === "下载资源" && step.state === "running" && step.percent !== null); + const percent = activeDownload ? activeDownload.percent : (steps.length === 0 ? 0 : Math.min(95, steps.length * 18)); + const finalPercent = state === "done" ? 100 : percent; + mainProgressBar.style.width = `${finalPercent}%`; + mainProgressBar.className = `h-full rounded-full ${state === "bad" ? "bg-red-400" : "bg-zinc-100"}`; + mainProgressText.textContent = `${finalPercent}%`; + panel.innerHTML = steps.length ? steps.map(stepCard).join("") : stepCard({ + name: "等待任务状态", + state: "running", + detail: "任务已创建,正在等待后端开始执行。", + }); +} + +function stepCard(step) { + const bad = step.state === "bad"; + const running = step.state === "running"; + const status = bad ? "失败" : running ? "进行中" : "完成"; + const color = bad ? "text-red-300" : running ? "text-amber-300" : "text-emerald-300"; + const border = bad ? "border-red-500/30 bg-red-500/10" : "border-zinc-800 bg-zinc-950/60"; + const downloadHint = step.name === "下载资源" && step.percent !== null && step.percent !== undefined + ? `

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

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

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

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

核心

+
+ + + +
+
diff --git a/pyxray/web/templates/configs/dns.html b/pyxray/web/templates/configs/dns.html new file mode 100644 index 0000000..94d1058 --- /dev/null +++ b/pyxray/web/templates/configs/dns.html @@ -0,0 +1,51 @@ +
+

DNS

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

入站端口

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

路由

+
+ +
+ + +
diff --git a/pyxray/web/templates/configs/transparent.html b/pyxray/web/templates/configs/transparent.html new file mode 100644 index 0000000..4c5110d --- /dev/null +++ b/pyxray/web/templates/configs/transparent.html @@ -0,0 +1,51 @@ +
+

透明代理

+
+ + +
+
+ + +
+
+ + +
+ +
diff --git a/pyxray/web/templates/index.html b/pyxray/web/templates/index.html new file mode 100644 index 0000000..4abafa1 --- /dev/null +++ b/pyxray/web/templates/index.html @@ -0,0 +1,63 @@ + + + + + + pyxray + + + + +
+
+
+

pyxray

+

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

+
+
+ +
+ {{ "pid: " ~ service_status.pid if service_status.running else "xray: stopped" }} +
+
+ config: {{ config_path }} +
+
+
+ + + +
+ {% include "partials/nodes_tab.html" %} +
+ +
+ {% include "partials/config_tab.html" %} +
+ +
+ {% include "partials/download_tab.html" %} +
+ +
+ {% include "partials/logs_tab.html" %} +
+
+ + + + + + diff --git a/pyxray/web/templates/partials/config_tab.html b/pyxray/web/templates/partials/config_tab.html new file mode 100644 index 0000000..9805e09 --- /dev/null +++ b/pyxray/web/templates/partials/config_tab.html @@ -0,0 +1,20 @@ +
+
+

配置设置

+

通过界面保存 settings.toml。

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

资源下载

+

xray / geoip.dat / geosite.dat

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

运行日志

+

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

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

节点管理

+

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

+
+ 0 个节点 +
+ +
+ +
+ + 未选择节点 +
+
+ + +
+
diff --git a/pyxray/web/xray_assets.py b/pyxray/web/xray_assets.py new file mode 100644 index 0000000..71e7b21 --- /dev/null +++ b/pyxray/web/xray_assets.py @@ -0,0 +1,228 @@ +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, + download_bytes_stream, + ensure_xray_assets, +) +from pyxray.libs.xray_asset_settings import XrayAssetSettings, XrayAssetSettingsStore +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)) + 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) + return jsonify(settings.to_dict()) + + +@blueprint.get("/api/xray/assets/jobs/") +def job_api(job_id: str): # noqa: ANN202 + job = get_job_store(current_app).get(job_id) + if job is None: + return jsonify({"error": "job not found"}), 404 + return jsonify(job) + + +@blueprint.post("/api/xray/assets/jobs//cancel") +def cancel_job_api(job_id: str): # noqa: ANN202 + job = get_job_store(current_app).cancel(job_id) + if job is None: + return jsonify({"error": "job not found"}), 404 + return jsonify({"job_id": job_id, "cancel_requested": True}) + + +def default_asset_form(directory: str) -> dict[str, str]: + """默认表单值。""" + + return { + "directory": directory, + "version": DEFAULT_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_VERSION, + archive_url=form["archive_url"], + geoip_url=form["geoip_url"], + geosite_url=form["geosite_url"], + proxy_url=form["proxy_url"], + target=form["target"] or "all", + force=form["force"] == "on", + ) + + +def _run_asset_job(job: Job, form: dict[str, str]) -> None: + """后台执行 Xray 资源补齐,并记录可轮询的步骤状态。""" + + def add_step(name: str, state: str, detail: str = "") -> None: + job["steps"].append({"name": name, "state": state, "detail": detail}) + + def check_cancelled() -> None: + if job.get("cancel_requested"): + raise DownloadCancelled() + + def tracked_download(url: str) -> bytes: + check_cancelled() + step = { + "name": "下载资源", + "state": "running", + "detail": url, + "url": url, + "received": 0, + "total": None, + "percent": None, + } + job["steps"].append(step) + + def progress(progress_url: str, received: int, total: int | None) -> None: + check_cancelled() + percent = int(received * 100 / total) if total else None + step.update( + { + "detail": _download_detail(progress_url, received, total), + "received": received, + "total": total, + "percent": percent, + } + ) + + data = download_bytes_stream(url, progress, proxy_url=form["proxy_url"] or None) + step.update( + { + "state": "done", + "detail": _download_detail(url, len(data), len(data)), + "received": len(data), + "total": len(data), + "percent": 100, + } + ) + return data + + try: + check_cancelled() + before = check_xray_assets(form["directory"]) + missing = ", ".join(before.missing) if before.missing else "无缺失文件" + add_step("检查本地文件", "done", missing) + + assets = ensure_xray_assets( + form["directory"], + version=form["version"], + archive_url=form["archive_url"] or None, + geoip_url=form["geoip_url"] or None, + geosite_url=form["geosite_url"] or None, + proxy_url=form["proxy_url"] or None, + target=form["target"] or "all", + force=form["force"] == "on", + downloader=tracked_download, + ) + downloaded = ", ".join(assets.downloaded) if assets.downloaded else "无需下载,文件已存在" + skipped = ", ".join(assets.skipped) if assets.skipped else "无" + add_step("解压 / 写入文件", "done", downloaded) + check_cancelled() + add_step("已有文件跳过", "done", skipped) + add_step("校验目标文件", "done", f"本次目标:{form['target'] or 'all'}") + if assets.xray.exists(): + add_step("设置执行权限", "done", str(assets.xray)) + + status = check_xray_assets(form["directory"]) + job["status"] = { + "directory": str(status.directory), + "files": status.files, + "ready": status.ready, + "missing": status.missing, + } + if job.get("state") != "cancelled": + job["state"] = "done" + except DownloadCancelled: + if not any(step.get("name") == "任务已停止" for step in job["steps"]): + add_step("任务已停止", "bad", "用户已请求停止下载任务") + job["error"] = "cancelled" + job["state"] = "cancelled" + except Exception as exc: # noqa: BLE001 + add_step("执行失败", "bad", str(exc)) + job["error"] = str(exc) + job["state"] = "bad" + + +class DownloadCancelled(Exception): + """用户请求取消下载任务。""" + + +def _download_detail(url: str, received: int, total: int | None) -> str: + """格式化下载进度文案。""" + + if total: + return f"{url} - {_format_bytes(received)} / {_format_bytes(total)}" + return f"{url} - {_format_bytes(received)}" + + +def _format_bytes(value: int) -> str: + """把字节数格式化为短文本。""" + + units = ("B", "KB", "MB", "GB") + size = float(value) + for unit in units: + if size < 1024 or unit == units[-1]: + return f"{size:.1f} {unit}" if unit != "B" else f"{value} B" + size /= 1024 + return f"{value} B" diff --git a/pyxray/web/xray_config.py b/pyxray/web/xray_config.py new file mode 100644 index 0000000..5670237 --- /dev/null +++ b/pyxray/web/xray_config.py @@ -0,0 +1,225 @@ +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.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 + return jsonify({"settings_toml": dump_settings_toml(settings)}) + + +@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 + 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" + transparent_files = write_transparent_rule_files(settings, transparent_dir) + 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.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.tproxy_excluded_interfaces = form.get( + "transparent.tproxy_excluded_interfaces", + settings.transparent.tproxy_excluded_interfaces, + ) + 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.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 _int(name: str, default: int) -> int: + raw = request.form.get(name, "").strip() + return default if raw == "" else int(raw) + + +def _lines(name: str) -> list[str]: + raw = request.form.get(name, "") + normalized = raw.replace(",", "\n") + return [line.strip() for line in normalized.splitlines() if line.strip()] + + +def _split_table(name: str, columns: int) -> list[list[str]]: + rows: list[list[str]] = [] + for line in request.form.get(name, "").splitlines(): + if not line.strip(): + continue + parts = [part.strip() for part in line.split("|")] + if len(parts) != columns: + raise ValueError(f"{name} 每行需要 {columns} 列,使用 | 分隔") + rows.append(parts) + return rows diff --git a/pyxray/web/xray_service.py b/pyxray/web/xray_service.py new file mode 100644 index 0000000..9f75e2a --- /dev/null +++ b/pyxray/web/xray_service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path + +from flask import Blueprint, Flask, current_app, jsonify + +from pyxray.libs.xray_runtime import XrayServiceManager, read_log_tail +from pyxray.web.xray_assets import get_asset_settings_store +from pyxray.web.xray_config import generate_current_xray_config + + +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.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, + ) + app.register_blueprint(blueprint) + + +def get_xray_service(app: Flask) -> XrayServiceManager: + return app.extensions["pyxray_xray_service"] + + +@blueprint.get("") +def status_api(): # noqa: ANN202 + return jsonify(get_xray_service(current_app).status()) + + +@blueprint.post("/start") +def start_api(): # noqa: ANN202 + try: + generate_current_xray_config(current_app) + return jsonify(get_xray_service(current_app).start()) + except Exception as exc: # noqa: BLE001 + return jsonify({"error": str(exc), "status": get_xray_service(current_app).status()}), 400 + + +@blueprint.post("/stop") +def stop_api(): # noqa: ANN202 + return jsonify(get_xray_service(current_app).stop()) + + +@blueprint.get("/logs") +def logs_api(): # noqa: ANN202 + path = current_app.config["XRAY_LOG_PATH"] + return jsonify({"path": path, "content": read_log_tail(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": ""}) diff --git a/tests/libs/test_node_manager.py b/tests/libs/test_node_manager.py new file mode 100644 index 0000000..cd7f9eb --- /dev/null +++ b/tests/libs/test_node_manager.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import base64 + +import pytest + +from pyxray.libs.nodes import NodeManager, NodeStore, parse_node_link + + +def test_node_store_saves_nodes_and_selected_id_as_toml(tmp_path) -> None: + manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) + result = manager.add_link(_ss_link("secret", "ss-node")) + + selected = manager.select_node(result.node.id) + reloaded = NodeManager(NodeStore(tmp_path / "nodes.toml")) + + assert selected.id == result.node.id + assert reloaded.selected_id() == result.node.id + assert reloaded.get_selected_node() is not None + assert reloaded.get_selected_node().name == "ss-node" + assert "selected_id" in (tmp_path / "nodes.toml").read_text(encoding="utf-8") + assert "[[nodes]]" in (tmp_path / "nodes.toml").read_text(encoding="utf-8") + + +def test_node_manager_updates_existing_node_by_id_and_keeps_created_at(tmp_path) -> None: + manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) + node = parse_node_link(_ss_link("secret", "first-name")) + created = manager.add_node(node) + + same_node = parse_node_link(_ss_link("secret", "second-name")) + updated = manager.add_node(same_node) + nodes = manager.list_nodes() + + assert created.created is True + assert updated.created is False + assert len(nodes) == 1 + assert nodes[0].name == "second-name" + assert nodes[0].created_at == created.node.created_at + assert nodes[0].updated_at >= nodes[0].created_at + + +def test_node_manager_removes_selected_node_and_clears_selection(tmp_path) -> None: + manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) + result = manager.add_link(_ss_link("secret", "ss-node")) + manager.select_node(result.node.id) + + assert manager.remove_node(result.node.id) is True + + assert manager.list_nodes() == [] + assert manager.selected_id() == "" + assert manager.get_selected_node() is None + + +def test_node_manager_rejects_unknown_selection(tmp_path) -> None: + manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) + + with pytest.raises(ValueError, match="node does not exist"): + manager.select_node("missing") + + +def test_node_manager_imports_valid_links_and_returns_invalid_results(tmp_path) -> None: + manager = NodeManager(NodeStore(tmp_path / "nodes.toml")) + results = manager.import_links(f"bad://example\n{_ss_link('secret', 'ss-node')}\n") + + assert [result.ok for result in results] == [False, True] + assert len(manager.list_nodes()) == 1 + assert manager.list_nodes()[0].name == "ss-node" + + +def _ss_link(password: str, name: str) -> str: + user = base64.urlsafe_b64encode(f"chacha20-ietf-poly1305:{password}".encode()).decode().rstrip("=") + return f"ss://{user}@ss.example.net:8388#{name}" diff --git a/tests/libs/test_nodes.py b/tests/libs/test_nodes.py new file mode 100644 index 0000000..1978b32 --- /dev/null +++ b/tests/libs/test_nodes.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import base64 +import json + +from pyxray.libs.nodes import import_node_links, parse_node_link + + +def test_parse_vless_reality_link_normalizes_node() -> None: + node = parse_node_link( + "vless://00000000-0000-4000-8000-000000000001@example.com:443" + "?type=tcp&security=reality&encryption=none&flow=xtls-rprx-vision" + "&sni=www.example.com&fp=chrome&pbk=public-key&sid=abcd&spx=%2F#hk" + ) + + assert node.protocol == "vless" + assert node.name == "hk" + assert node.server == "example.com" + assert node.port == 443 + assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000001" + assert node.settings["flow"] == "xtls-rprx-vision" + assert node.transport == { + "network": "tcp", + "header_type": "none", + "host": "", + "path": "", + } + assert node.security["type"] == "reality" + assert node.security["server_name"] == "www.example.com" + assert node.security["public_key"] == "public-key" + assert node.security["short_id"] == "abcd" + assert node.fingerprint + assert node.id.startswith("vless_") + assert node.canonical_link.startswith("vless://") + + +def test_parse_trojan_ws_tls_link_normalizes_node() -> None: + node = parse_node_link( + "trojan://secret@example.org:443" + "?type=ws&host=cdn.example.org&path=%2Fws&sni=edge.example.org&allowInsecure=1#trojan" + ) + + assert node.protocol == "trojan" + assert node.name == "trojan" + assert node.server == "example.org" + assert node.port == 443 + assert node.settings["password"] == "secret" + assert node.transport["network"] == "ws" + assert node.transport["path"] == "/ws" + assert node.transport["host"] == "cdn.example.org" + assert node.security["type"] == "tls" + assert node.security["server_name"] == "edge.example.org" + assert node.security["allow_insecure"] is True + + +def test_parse_vmess_base64_json_link_normalizes_node() -> None: + payload = { + "v": "2", + "ps": "vmess-node", + "add": "vmess.example.net", + "port": "443", + "id": "00000000-0000-4000-8000-000000000002", + "aid": "0", + "scy": "auto", + "net": "websocket", + "type": "none", + "host": "cdn.example.net", + "path": "/ray", + "tls": "tls", + "sni": "sni.example.net", + } + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + + node = parse_node_link(f"vmess://{encoded}") + + assert node.protocol == "vmess" + assert node.name == "vmess-node" + assert node.server == "vmess.example.net" + assert node.port == 443 + assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000002" + assert node.transport["network"] == "ws" + assert node.security["type"] == "tls" + + +def test_parse_shadowsocks_sip002_link_normalizes_node() -> None: + user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") + + node = parse_node_link(f"ss://{user}@ss.example.net:8388#ss-node") + + assert node.protocol == "shadowsocks" + assert node.name == "ss-node" + assert node.server == "ss.example.net" + assert node.port == 8388 + assert node.settings["method"] == "chacha20-ietf-poly1305" + assert node.settings["password"] == "secret" + assert node.security["type"] == "none" + + +def test_import_node_links_parses_multiline_input_without_subscription_groups() -> None: + user = base64.urlsafe_b64encode(b"aes-128-gcm:secret").decode().rstrip("=") + results = import_node_links( + "\n" + "# comment\n" + "bad://example\n" + f"ss://{user}@ss.example.net:8388#ss-node\n" + ) + + assert len(results) == 2 + assert results[0].ok is False + assert results[0].error == "unsupported node link scheme: bad" + assert results[1].ok is True + assert results[1].node is not None + assert results[1].node.protocol == "shadowsocks" + + +def test_parse_trojan_go_keeps_protocol_and_specific_fields() -> None: + node = parse_node_link( + "trojan-go://secret@example.org:443" + "?type=ws&host=cdn.example.org&path=%2Fgo&sni=edge.example.org" + "&encryption=ss%3Baes-128-gcm%3Asecret&allowInsecure=1#tg" + ) + + assert node.protocol == "trojan-go" + assert node.settings["password"] == "secret" + assert node.settings["encryption"] == "ss;aes-128-gcm:secret" + assert node.transport["network"] == "ws" + assert node.transport["host"] == "cdn.example.org" + assert node.security["allow_insecure"] is False + assert node.canonical_link.startswith("trojan-go://") + + +def test_parse_vless_xhttp_ws_and_grpc_extension_fields() -> None: + xhttp = parse_node_link( + "vless://00000000-0000-4000-8000-000000000001@example.com:443" + "?type=xhttp&security=tls&sni=www.example.com#xhttp" + ) + ws = parse_node_link( + "vless://00000000-0000-4000-8000-000000000001@example.com:443" + "?type=ws&security=tls&maxEarlyData=2048&earlyDataHeaderName=Sec-WebSocket-Protocol#ws" + ) + grpc = parse_node_link( + "vless://00000000-0000-4000-8000-000000000001@example.com:443" + "?type=grpc&security=tls&serviceName=svc&multiMode=true&idleTimeout=60" + "&healthCheckTimeout=20&permitWithoutStream=true&initialWindowsSize=65535#grpc" + ) + + assert xhttp.transport["network"] == "xhttp" + assert xhttp.transport["xhttp_mode"] == "auto" + assert ws.transport["max_early_data"] == "2048" + assert ws.transport["early_data_header_name"] == "Sec-WebSocket-Protocol" + assert grpc.transport["service_name"] == "svc" + assert grpc.transport["multi_mode"] == "true" + assert grpc.transport["idle_timeout"] == "60" + assert grpc.transport["health_check_timeout"] == "20" + assert grpc.transport["permit_without_stream"] == "true" + assert grpc.transport["initial_windows_size"] == "65535" + + +def test_parse_vmess_legacy_link() -> None: + payload = base64.urlsafe_b64encode(b"auto:00000000-0000-4000-8000-000000000003@legacy.example:443").decode() + + node = parse_node_link( + f"vmess://{payload}?remarks=legacy&obfs=websocket&obfsParam=cdn.example" + "&path=%2Fray&alterId=0&tls=1&sni=sni.example" + ) + + assert node.protocol == "vmess" + assert node.name == "legacy" + assert node.server == "legacy.example" + assert node.port == 443 + assert node.settings["uuid"] == "00000000-0000-4000-8000-000000000003" + assert node.transport["network"] == "ws" + assert node.transport["host"] == "cdn.example" + assert node.transport["path"] == "/ray" + assert node.security["type"] == "tls" + assert node.security["server_name"] == "sni.example" + + +def test_parse_vmess_moves_path_from_host_when_needed() -> None: + payload = { + "ps": "host-path", + "add": "vmess.example.net", + "port": "443", + "id": "00000000-0000-4000-8000-000000000004", + "host": "/ray", + "net": "ws", + } + encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + + node = parse_node_link(f"vmess://{encoded}") + + assert node.transport["host"] == "" + assert node.transport["path"] == "/ray" + + +def test_parse_shadowsocks_plugin_as_structured_sip003_options() -> None: + user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") + + node = parse_node_link( + f"ss://{user}@ss.example.net:8388?plugin=simpleobfs%3Bobfs%3Dhttp%3Bobfs-host%3Dcdn.example" + "%3Bobfs-uri%3Dws#ss-plugin" + ) + + assert node.settings["plugin_options"] == { + "name": "simple-obfs", + "raw": "simpleobfs;obfs=http;obfs-host=cdn.example;obfs-uri=ws", + "tls": "", + "obfs": "http", + "host": "cdn.example", + "path": "/ws", + "impl": "", + } diff --git a/tests/libs/test_tinytun_config.py b/tests/libs/test_tinytun_config.py new file mode 100644 index 0000000..95ecbc6 --- /dev/null +++ b/tests/libs/test_tinytun_config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pyxray.libs.nodes import parse_node_link +from pyxray.libs.xray_config import XrayConfigSettings, generate_tinytun_config, write_tinytun_config_file + + +def test_generate_tinytun_config_matches_v2raya_defaults() -> None: + settings = XrayConfigSettings() + node = parse_node_link(_ss_link("198.51.100.10")) + + config = generate_tinytun_config(node, settings, geosite_file="/data/geosite.dat") + + assert config["tun"] == { + "name": "tun0", + "ip": "198.18.0.1", + "netmask": "255.255.255.255", + "ipv6_mode": "auto", + "ipv6": "fd00::1", + "ipv6_prefix": 128, + "auto_route": True, + "mtu": 1500, + } + assert config["socks5"] == {"name": "proxy", "address": "127.0.0.1:52345"} + assert "198.51.100.10" in config["filtering"]["skip_ips"] + assert config["filtering"]["skip_networks"][:5] == ["127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] + assert config["filtering"]["block_ports"] == [22, 23, 25, 110, 143] + assert config["filtering"]["allow_ports"] == [80, 443, 53] + assert config["route"] == {"auto_detect_interface": True} + + +def test_generate_tinytun_dns_groups_and_routing_from_dns_rules() -> None: + settings = XrayConfigSettings() + node = parse_node_link(_ss_link("ss.example.net")) + + config = generate_tinytun_config(node, settings, geosite_file="/data/geosite.dat") + dns = config["dns"] + + assert dns["groups"][0] == {"name": "direct", "servers": ["223.5.5.5:53"], "strategy": "concurrent", "upstream": "direct", "protocol": "udp"} + assert dns["groups"][1] == {"name": "proxy", "servers": ["8.8.8.8:53"], "strategy": "concurrent", "upstream": "proxy", "protocol": "udp"} + assert "match(geosite:private),direct" in dns["routing"]["rules"] + assert "match(geosite:cn),direct" in dns["routing"]["rules"] + assert dns["routing"]["fallback_group"] == "proxy" + assert dns["routing"]["geosite_file"] == "/data/geosite.dat" + assert dns["routing"]["enable_cache"] is True + assert dns["routing"]["cache_capacity"] == 4096 + assert dns["hijack"] == {"enabled": False, "mark": 1, "table_id": 100, "capture_tcp": True} + + +def test_write_tinytun_config_file_only_for_tun_mode(tmp_path) -> None: + settings = XrayConfigSettings() + node = parse_node_link(_ss_link("198.51.100.10")) + + assert write_tinytun_config_file(node, settings, tmp_path) is None + + settings.transparent.mode = "proxy" + settings.transparent.type = "tun" + settings.transparent.tun_auto_route = False + settings.transparent.tun_bypass_interfaces = "172.17.0.0/16" + settings.transparent.tun_exclude_processes = "/usr/bin/xray, pyxray" + path = write_tinytun_config_file(node, settings, tmp_path, geosite_file="/data/geosite.dat") + content = path.read_text(encoding="utf-8") + + assert path.name == "tinytun.yaml" + assert "auto_route: false" in content + assert "- 172.17.0.0/16" in content + assert "- xray" in content + assert "- pyxray" in content + + +def _ss_link(host: str) -> str: + return f"ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpzZWNyZXQ@{host}:8388#ss-node" diff --git a/tests/libs/test_transparent_rules.py b/tests/libs/test_transparent_rules.py new file mode 100644 index 0000000..f82b778 --- /dev/null +++ b/tests/libs/test_transparent_rules.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +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 + + 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 "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.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 "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_redirect_nft_rules_include_table_and_loader_command() -> None: + settings = XrayConfigSettings() + settings.transparent.mode = "proxy" + settings.transparent.type = "redirect" + + 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_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" + + 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_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") diff --git a/tests/libs/test_xray_assets.py b/tests/libs/test_xray_assets.py new file mode 100644 index 0000000..d1bb378 --- /dev/null +++ b/tests/libs/test_xray_assets.py @@ -0,0 +1,163 @@ +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, download_bytes, download_bytes_stream, ensure_xray_assets, official_archive_url + + +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() == "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-64.zip" + + +def test_ensure_xray_assets_extracts_official_archive_files(tmp_path) -> None: # noqa: ANN001 + archive = _zip_bytes( + { + "xray": 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").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"}) + 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 + archive = _zip_bytes({"xray": 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").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") + (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", "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({"xray": b"bin", "geoip.dat": b"geoip"}) + + with pytest.raises(FileNotFoundError, match="geosite.dat"): + ensure_xray_assets(tmp_path, downloader=lambda url: archive) + + +def test_download_bytes_stream_reports_progress(monkeypatch) -> None: # noqa: ANN001 + payload = b"abcdef" + events = [] + + def fake_urlopen(url): # noqa: ANN001 + return addinfourl(BytesIO(payload), {"Content-Length": str(len(payload))}, url) + + monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.urlopen", fake_urlopen) + + result = download_bytes_stream("https://example.invalid/file", lambda *event: events.append(event), chunk_size=2) + + assert result == payload + assert events[0] == ("https://example.invalid/file", 0, 6) + assert events[-1] == ("https://example.invalid/file", 6, 6) + + +def test_download_bytes_uses_explicit_proxy(monkeypatch) -> None: # noqa: ANN001 + captured = {} + + class FakeOpener: + def open(self, url): # noqa: ANN001 + captured["url"] = url + return addinfourl(BytesIO(b"ok"), {}, url) + + def fake_proxy_handler(proxies): # noqa: ANN001 + captured["proxies"] = proxies + return object() + + monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.ProxyHandler", fake_proxy_handler) + monkeypatch.setattr("pyxray.libs.xray_assets.urllib.request.build_opener", lambda handler: FakeOpener()) + + result = download_bytes("https://example.invalid/file", proxy_url="http://proxy.example.invalid:8080") + + assert result == b"ok" + assert captured["url"] == "https://example.invalid/file" + assert captured["proxies"] == { + "http": "http://proxy.example.invalid:8080", + "https": "http://proxy.example.invalid:8080", + } diff --git a/tests/libs/test_xray_config.py b/tests/libs/test_xray_config.py new file mode 100644 index 0000000..77d6cab --- /dev/null +++ b/tests/libs/test_xray_config.py @@ -0,0 +1,376 @@ +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.transparent.mode == "close" + assert settings.transparent.type == "redirect" + assert settings.transparent.port == 52345 + assert settings.dns.query_strategy == "UseIPv4" + 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-http")["port"] == 20172 + 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_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, "dns-in")["listen"] == "127.2.0.17" + + +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": "direct"} 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_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.rule_socks_port = 20173 + 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-socks")["port"] == 20173 + 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() + settings.inbounds.rule_socks_port = 20173 + + whitelist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"] + assert {"type": "field", "inboundTag": ["rule-http", "rule-socks"], "domain": ["geosite:cn"], "outboundTag": "direct"} in whitelist + assert {"type": "field", "inboundTag": ["rule-http", "rule-socks"], "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-http", "rule-socks"], "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-http"], "outboundTag": "direct", "domain": ["keyword:google"]} + fallback = {"type": "field", "inboundTag": ["rule-http"], "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.inbounds.rule_socks_port = 20173 + 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-http", "rule-socks"], + "domain": ["geosite:google", "domain:example.com"], + "outboundTag": "proxy", + } + custom_ip = { + "type": "field", + "inboundTag": ["rule-http", "rule-socks"], + "ip": ["geoip:cn"], + "outboundTag": "direct", + } + + assert rules.index(custom_domain) < rules.index({"type": "field", "inboundTag": ["rule-http", "rule-socks"], "domain": ["geosite:cn"], "outboundTag": "direct"}) + assert rules.index(custom_ip) < rules.index({"type": "field", "inboundTag": ["rule-http", "rule-socks"], "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"}) + assert {"type": "field", "inboundTag": ["rule-http", "rule-socks"], "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_validate_settings_rejects_invalid_values() -> None: + settings = XrayConfigSettings() + settings.transparent.type = "bad" + + with pytest.raises(ValueError, match="transparent.type"): + validate_settings(settings) + + +def _ss_link() -> str: + user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=") + return f"ss://{user}@ss.example.net:8388#ss-node" + + +def _inbound(config: dict, tag: str) -> dict: + return next(item for item in config["inbounds"] if item.get("tag") == tag) + + +def _outbound(config: dict, tag: str) -> dict: + return next(item for item in config["outbounds"] if item.get("tag") == tag) diff --git a/tests/web/test_xray_assets_web.py b/tests/web/test_xray_assets_web.py new file mode 100644 index 0000000..ff93b07 --- /dev/null +++ b/tests/web/test_xray_assets_web.py @@ -0,0 +1,622 @@ +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path + +from pyxray.libs.xray_assets import XrayAssets +from pyxray.web.server import create_app + + +def test_index_shows_asset_status(tmp_path: Path) -> None: + (tmp_path / "xray").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) + (Path(directory) / "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", + 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_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) + (Path(directory) / "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", + 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: + xray = tmp_path / "xray" + xray.write_text("#!/bin/sh\necho xray-started\nsleep 30\n", encoding="utf-8") + os.chmod(xray, 0o755) + 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"] == "" + + +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) + 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: + xray = tmp_path / "xray" + xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8") + os.chmod(xray, 0o755) + 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_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) + 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 = xray_dir / "xray" + xray.write_text("#!/bin/sh\necho relative-started\nsleep 30\n", encoding="utf-8") + os.chmod(xray, 0o755) + 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: + xray = tmp_path / "xray" + xray.write_text("#!/bin/sh\nsleep 30\n", encoding="utf-8") + os.chmod(xray, 0o755) + 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: + xray = tmp_path / "xray" + xray.write_text("#!/bin/sh\necho 'bind: permission denied' >&2\nexit 23\n", encoding="utf-8") + os.chmod(xray, 0o755) + 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 logs.get_json()["content"] == "" + assert (tmp_path / "xray.log").read_text(encoding="utf-8") == "" + + +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() + (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) + 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_dir / "xray") + assert started["xray_dir"] == str(preferred_dir) + assert str(preferred_dir / "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_dir / "xray").write_text("#!/bin/sh\necho default-xray\nsleep 30\n", encoding="utf-8") + os.chmod(default_dir / "xray", 0o755) + 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_dir / "xray") + assert started["xray_dir"] == str(default_dir) + assert started["fallback_xray_dir"] == str(default_dir) + assert str(default_dir / "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_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_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.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", + "dns.query_strategy": "UseIPv4", + "dns.local_dns_listen": "on", + "dns.antipollution": "closed", + "dns.special_mode": "none", + "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 "route_only = true" in saved.get_json()["settings_toml"] + assert generated.status_code == 200 + assert config["log"]["loglevel"] == "error" + assert config["inbounds"][0]["port"] == 20180 + assert config["outbounds"][0]["mux"] == {"enabled": True, "concurrency": 16} + assert config["dns"]["queryStrategy"] == "UseIPv4" + + +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.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}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..305f916 --- /dev/null +++ b/uv.lock @@ -0,0 +1,197 @@ +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 = "0.1.0" +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" }, +]