6 Commits

23 changed files with 515 additions and 47 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列
| 分类 | 文档 | 主要影响 |
| --- | --- | --- |
| 核心 | [config/core.md](config/core.md) | Xray 日志、Mux、TCP Fast Open。 |
| 入站 | [config/inbounds.md](config/inbounds.md) | SOCKS/HTTP/rule/API/自定义入站。 |
| 入站 | [config/inbounds.md](config/inbounds.md) | Mixed/rule/API/自定义入站。 |
| 路由 | [config/routing.md](config/routing.md) | rule 入站和透明代理流量的分流策略。 |
| DNS | [config/dns.md](config/dns.md) | Xray DNS 模块、本地 DNS 入站、DNS 服务器路由。 |
| 透明代理 | [config/transparent.md](config/transparent.md) | transparent inbound、iptables/nft、resolv、TinyTun。 |
@@ -45,7 +45,7 @@ UI 中有些字段隐藏但仍存在于 `settings.toml`。下表的“UI”列
| 设置 | 默认值 | 说明 |
| --- | --- | --- |
| `core.log_level` | `info` | 日常日志等级。 |
| `inbounds.rule_http_port` | `20172` | 规则 HTTP 代理入口。 |
| `inbounds.rule_http_port` | `20172` | 规则 Mixed 代理入口。 |
| `routing.mode` | `whitelist` | 国内/私有直连,其它代理。 |
| `transparent.mode` | `close` | 默认不启用透明代理。 |
| `transparent.type` | `redirect` | 推荐先用 redirect。 |
+39
View File
@@ -8,9 +8,47 @@
| `mux_enabled` | 间接显示 | `false` | `bool` | 控制主 `proxy` outbound 是否生成 `mux`。UI 通过 `mux_concurrency = 0` 表示关闭。 | 节点支持且希望复用连接时开启;兼容性异常时关闭。 |
| `mux_concurrency` | 显示 | `8` | UI`0/1/2/4/8/16/32/64`;模型:`1-1024` | `mux.concurrency`。UI 选择 `0` 时保存为 `mux_enabled = false` 且并发恢复为 `8`。 | 高并发小连接场景可尝试 `8/16`;不确定用 `0`。 |
| `tcp_fast_open` | 隐藏 | `default` | `default` / `yes` / `no` | 非 `default` 时写入 outbound `streamSettings.sockopt.tcpFastOpen`。 | 只有明确知道系统和网络支持 TFO 时修改。 |
| `transparent.output_bypass_rules` | 显示 | `""` | 每行一条 `tcp/udp/all 目标[:端口]` | 在 transparent 系统规则的本机 `OUTPUT` 链前置 RETURN,避免宿主机进程被透明代理截获。 | easytier、其它 host network 服务需要直连固定 peer 时修改。 |
| `ss_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
| `trojan_backend` | 隐藏 | `""` | 字符串 | 预留字段,当前不影响 Xray JSON。 | 当前不用改。 |
## transparent
核心卡片里的 `transparent` 输入框对应 `[transparent].output_bypass_rules`
格式:
```text
tcp 117.72.47.28:33010
all 192.168.0.0/24
udp 198.51.100.10:3478
```
规则含义:
| 写法 | 作用 |
| --- | --- |
| `tcp 117.72.47.28:33010` | 本机 TCP 访问该 IP 和端口时直连,不进入 transparent。 |
| `all 192.168.0.0/24` | 本机访问该网段时直连;redirect 下只生成 TCPtproxy 下生成 TCP 和 UDP。 |
| `udp 198.51.100.10:3478` | tproxy 下本机 UDP 访问该 IP 和端口时直连;redirect 下忽略 UDP。 |
典型场景:
```text
tcp 117.72.47.28:33010
```
用于避免 easytier 这类 `network_mode: host` 服务的 peer 连接被 `nat OUTPUT -> TP_OUT -> REDIRECT` 截获。
生成顺序:
```sh
iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN
iptables -t nat -A TP_OUT -j TP_RULE
```
该设置只影响宿主机本机 `OUTPUT` 流量,不影响 Docker 容器透明代理的 `PREROUTING` 流量。
## 生成影响
| 条件 | 生成结果 |
@@ -20,3 +58,4 @@
| `log_level = none` | `log.loglevel = none``access = "none"``error = "none"`。 |
| `mux_enabled = true` | 主代理 outbound 增加 `mux.enabled = true``mux.concurrency`。 |
| `tcp_fast_open != default` | `proxy` / `direct` outbound 增加 `sockopt.tcpFastOpen`。 |
| `transparent.output_bypass_rules` 非空 | 在 `TP_OUT` 跳转 `TP_RULE` 前生成 OUTPUT 绕过 RETURN 规则。 |
+45 -3
View File
@@ -8,8 +8,10 @@
| `port_sharing` | 隐藏 | `false` | `bool` | 为 `true` 时监听地址强制变成 `0.0.0.0`。 | 当前 UI 用 `listen` 控制,通常不改。 |
| `socks_port` | 隐藏 | `20170` | `0-65535` | 普通 SOCKS 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 SOCKS 入口时手改。 |
| `http_port` | 隐藏 | `20171` | `0-65535` | 普通 HTTP 入站,流量最终兜底走 `proxy`。UI 保存时写 `0`。 | 需要无规则 HTTP 入口时手改。 |
| `rule_socks_port` | 显示 | `0` | `0-65535` | 规则 SOCKS 入站,流量按 `[routing]` 规则分流。UI 显示值会在为 `0` 时回退展示 `socks_port`。 | 应用要用 SOCKS 并希望按规则分流时设置。 |
| `rule_http_port` | 显示 | `20172` | `0-65535` | 规则 HTTP 入站,流量按 `[routing]` 规则分流。 | 浏览器/系统显式 HTTP 代理时使用。 |
| `rule_socks_port` | 隐藏 | `0` | `0-65535` | 旧版规则 SOCKS 入站兼容字段。当前 UI 保存时写 `0`。 | 通常不改。 |
| `rule_http_port` | 显示为 Mixed 端口 | `20172` | `0-65535` | 规则 mixed 入站,同一个端口同时支持 HTTP 和 SOCKS,流量按 `[routing]` 规则分流。 | 浏览器系统代理、CLI 工具显式代理时使用。 |
| `auth_user` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证用户名。 | 需要给局域网开放代理但不想裸奔时设置。 |
| `auth_password` | 显示 | `""` | 字符串 | mixed/SOCKS 入站认证密码。 | 与 `auth_user` 一起设置;两者都为空时使用 `noauth`。 |
| `vmess_port` | 隐藏 | `0` | `0-65535` | 额外 VMess 入站。`0` 不生成。 | 当前很少需要。 |
| `inbound_sniffing` | 显示 | `http,tls,quic` | `disable` / `http,tls` / `http,tls,quic` | 写入每个支持入站的 `sniffing.destOverride`。 | 域名路由不准时保持开启;兼容性异常时降级或关闭。 |
| `route_only` | 显示 | `false` | `bool` | 写入 `sniffing.routeOnly`。 | 只希望嗅探域名用于路由、不改连接目标时开启。 |
@@ -31,6 +33,46 @@
| 入站 tag | 来源 | 路由行为 |
| --- | --- | --- |
| `socks` / `http` | `socks_port` / `http_port` | 不进入 `[routing]` 模式规则,最终兜底走 `proxy`。 |
| `rule-socks` / `rule-http` | `rule_socks_port` / `rule_http_port` | 进入 `[routing]` 模式规则。 |
| `rule-mixed` | `rule_http_port` | 同一端口支持 HTTP 和 SOCKS进入 `[routing]` 模式规则。 |
| `vmess` | `vmess_port` | 额外 VMess 入站。 |
| `api-in` | `api.port` | 路由到 `api-out`。 |
## Mixed 入站
`rule-mixed` 生成示例:
```json
{
"tag": "rule-mixed",
"listen": "0.0.0.0",
"port": 20172,
"protocol": "mixed",
"settings": {
"auth": "noauth",
"udp": true,
"allowTransparent": false
}
}
```
如果填写用户名和密码:
```json
"settings": {
"auth": "password",
"udp": true,
"allowTransparent": false,
"accounts": [
{"user": "alice", "pass": "secret"}
]
}
```
使用方式:
```sh
curl -x http://10.11.11.100:20172 http://google.com/
curl --socks5-hostname 10.11.11.100:20172 https://google.com/
curl -x http://alice:secret@10.11.11.100:20172 http://google.com/
curl --socks5-hostname alice:secret@10.11.11.100:20172 https://google.com/
```
+74
View File
@@ -0,0 +1,74 @@
# easytier 连接 peer 超时
## 问题原因
`easytier` 使用 `network_mode: host`,它发起的 peer 连接属于宿主机本机流量,会经过 `nat OUTPUT`
`pyxray` 开启 transparent redirect 后,会把宿主机本机 TCP 流量转到 Xray transparent inbound
```sh
iptables -t nat -I OUTPUT -p tcp -j TP_OUT
iptables -t nat -A TP_OUT -j TP_RULE
iptables -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports 52345
```
因此 easytier 访问 peer 时,连接会被改写:
```mermaid
flowchart LR
E[easytier-core<br/>tcp://117.72.47.28:33010] --> O[nat OUTPUT]
O --> TPO[TP_OUT]
TPO --> TPR[TP_RULE]
TPR --> R[REDIRECT :52345]
R --> X[Xray transparent inbound]
```
结果是 easytier 没有直连到自己的 peer,日志表现为:
```text
connecting to peer dst=tcp://117.72.47.28:33010
connect to peer error ... Timeout
```
## 解决方案
在 UI 的“核心 -> transparent”里添加 OUTPUT 绕过规则,让 easytier peer 连接在进入 `TP_RULE` 前直接 `RETURN`
示例:
```text
tcp 117.72.47.28:33010
```
生成后的关键规则:
```sh
iptables -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN
iptables -t nat -A TP_OUT -j TP_RULE
```
修复后的流量路径:
```mermaid
flowchart LR
E[easytier-core<br/>tcp://117.72.47.28:33010] --> O[nat OUTPUT]
O --> TPO[TP_OUT]
TPO --> B{match tcp<br/>117.72.47.28:33010}
B -->|yes| D[RETURN<br/>直连 peer]
B -->|no| TPR[TP_RULE]
TPR --> R[REDIRECT :52345]
```
规则格式:
```text
tcp 117.72.47.28:33010
all 192.168.0.0/24
udp 198.51.100.10:3478
```
说明:
- `redirect` 模式只处理 TCP,因此只生成 TCP 绕过规则。
- `tproxy` 模式支持 TCP 和 UDP。
- 规则只作用于宿主机本机 `OUTPUT`,不改变 Docker 容器透明代理的 `PREROUTING` 行为。
+89
View File
@@ -0,0 +1,89 @@
# VLESS Reality Vision 开启 mux 后连接被关闭
## 问题
在节点使用 `VLESS + REALITY + xtls-rprx-vision` 时,开启 `mux` 后,透明代理和本地 HTTP/SOCKS 代理都会出现请求失败。
现象:
```text
curl google.com
curl: (52) Empty reply from server
```
Xray 日志:
```text
common/mux: dispatching request to tcp:google.com:80
proxy/vless/outbound: tunneling request to tcp:v1.mux.cool:9527
common/mux: failed to read metadata > io: read/write on closed pipe
```
关闭 `mux` 后,同一节点恢复正常:
```text
curl http://google.com/ -> HTTP/1.1 301
curl https://google.com/ -> HTTP/2 301
HTTP/SOCKS inbound 测试 -> 正常
```
## 原因
当前失败不在 transparent/iptables,而在 Xray outbound 层。
`mux` 会把多个 TCP 请求封装进一个 Mux.Cool 连接;官方文档说明它用于减少 TCP 握手延迟,默认关闭,并且不用于提升吞吐。`xtls-rprx-vision` 是 VLESS 的 XTLS flow,官方文档说明它在 `TCP + TLS/REALITY` 下会对 TLS 1.3 数据走底层直拷路径。
两者叠加时,业务连接不再按普通 VLESS 请求直接发送,而是先被封装成 `v1.mux.cool` 子连接:
```mermaid
flowchart LR
A[curl / app] --> I[Xray inbound]
I --> R[routing -> proxy]
R --> M[mux<br/>v1.mux.cool]
M --> V[VLESS<br/>flow=xtls-rprx-vision]
V --> T[REALITY/TCP server]
T --> X[server closes pipe]
```
Xray-core 讨论区有同类案例:配置为 `VLESS + REALITY + TCP + xtls-rprx-vision + mux` 时,请求报 `curl: (52) Empty reply from server`,服务端日志出现 `common/mux``closed pipe` 类错误;去掉 `mux` 后恢复。
因此这里的结论是:该节点组合下 `mux``xtls-rprx-vision` 不兼容或服务端不接受 mux 封装后的请求。
## 解决方案
在 UI 的“核心设置”里关闭 `mux`,保存并重启 Xray。
生成配置中不要出现:
```json
"mux": {
"enabled": true,
"concurrency": 4
}
```
修复后路径:
```mermaid
flowchart LR
A[curl / app] --> I[Xray inbound]
I --> R[routing -> proxy]
R --> V[VLESS<br/>flow=xtls-rprx-vision]
V --> T[REALITY/TCP server]
T --> G[google.com / target]
```
验证命令:
```sh
curl -v http://google.com/
curl -vk https://google.com/
curl -v -x http://127.0.0.1:20172 http://google.com/
curl -v --socks5-hostname 127.0.0.1:20170 https://google.com/
```
参考:
- [Project X: Outbound Proxy (Mux, XUDP)](https://xtls.github.io/en/config/outbound.html)
- [Project X: VLESS (XTLS Vision Seed)](https://xtls.github.io/en/config/inbounds/vless.html)
- [XTLS/Xray-core discussion #5481](https://github.com/XTLS/Xray-core/discussions/5481)
+1 -1
View File
@@ -167,7 +167,7 @@ tags = ["google"]
| 问题 | 原因 | 处理 |
| --- | --- | --- |
| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` / `rule_socks_port`,或开启透明代理。 |
| 规则没生效 | 入口不是 rule 入站或透明代理入站。 | 使用 `rule_http_port` 对应的 mixed 端口,或开启透明代理。 |
| 域名规则没命中 | 流量只有 IP,没有域名。 | 开启 sniffing,或改用 `ip(...)` 规则。 |
| IP 规则导致 DNS 查询 | 当前 pyxray 生成 `domainStrategy = "IPOnDemand"`。 | 避免过度使用 IP 规则,或接受 Xray 为路由进行 DNS 解析。 |
| `routing_a` 里的 `default:` 无效 | pyxray 解析器只识别 `domain(...)``ip(...)`。 | 用 `default_rule` 设置兜底。 |
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pyxray"
version = "1.0.2"
version = "1.0.4"
description = "A lightweight Linux xray control plane."
readme = "README.md"
requires-python = ">=3.14"
+6 -1
View File
@@ -1,5 +1,10 @@
"""pyxray package."""
from importlib.metadata import PackageNotFoundError, version
__all__ = ["__version__"]
__version__ = "0.1.0"
try:
__version__ = version("pyxray")
except PackageNotFoundError:
__version__ = "0.0.0"
+30 -3
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import stat
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
@@ -202,13 +203,39 @@ 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})
)
opener = _proxy_opener(proxy_url)
return opener.open(url)
return urllib.request.urlopen(url) # noqa: S310
def _proxy_opener(proxy_url: str):
parsed = urllib.parse.urlsplit(proxy_url)
if not parsed.username:
return urllib.request.build_opener(urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}))
clean_proxy_url = _proxy_url_without_auth(parsed)
password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
password_manager.add_password(
None,
clean_proxy_url,
urllib.parse.unquote(parsed.username),
urllib.parse.unquote(parsed.password or ""),
)
return urllib.request.build_opener(
urllib.request.ProxyHandler({"http": clean_proxy_url, "https": clean_proxy_url}),
urllib.request.ProxyBasicAuthHandler(password_manager),
urllib.request.ProxyDigestAuthHandler(password_manager),
)
def _proxy_url_without_auth(parsed: urllib.parse.SplitResult) -> str:
host = parsed.hostname or ""
if ":" in host and not host.startswith("["):
host = f"[{host}]"
netloc = f"{host}:{parsed.port}" if parsed.port else host
return urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
def _extract_from_zip(data: bytes, directory: Path, names: set[str]) -> None:
"""从 zip 中按文件名提取需要的文件,忽略目录层级。"""
+24 -4
View File
@@ -65,8 +65,7 @@ def _build_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]:
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),
_mixed_inbound(settings.inbounds.rule_http_port, listen, "rule-mixed", settings),
_vmess_inbound(settings.inbounds.vmess_port, listen, settings),
]
for custom in settings.inbounds.custom:
@@ -82,7 +81,7 @@ def _build_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]:
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},
{"port": port, "listen": listen, "protocol": "socks", "settings": _inbound_proxy_settings(settings), "tag": tag},
settings,
)
@@ -91,6 +90,19 @@ def _http_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings
return _with_sniffing({"port": port, "listen": listen, "protocol": "http", "tag": tag}, settings)
def _mixed_inbound(port: int, listen: str, tag: str, settings: XrayConfigSettings) -> dict[str, Any]:
return _with_sniffing(
{
"port": port,
"listen": listen,
"protocol": "mixed",
"settings": _inbound_proxy_settings(settings),
"tag": tag,
},
settings,
)
def _vmess_inbound(port: int, listen: str, settings: XrayConfigSettings) -> dict[str, Any]:
if port <= 0:
return {"port": 0}
@@ -101,6 +113,14 @@ def _vmess_inbound(port: int, listen: str, settings: XrayConfigSettings) -> dict
)
def _inbound_proxy_settings(settings: XrayConfigSettings) -> dict[str, Any]:
proxy_settings: dict[str, Any] = {"auth": "noauth", "udp": True, "allowTransparent": False}
if settings.inbounds.auth_user and settings.inbounds.auth_password:
proxy_settings["auth"] = "password"
proxy_settings["accounts"] = [{"user": settings.inbounds.auth_user, "pass": settings.inbounds.auth_password}]
return proxy_settings
def _transparent_inbounds(settings: XrayConfigSettings) -> list[dict[str, Any]]:
if settings.transparent.mode == "close":
return []
@@ -239,7 +259,7 @@ def _build_dns_routing(settings: XrayConfigSettings) -> list[dict[str, Any]]:
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]
tags = ["rule-mixed"] if settings.inbounds.rule_http_port > 0 else []
if not tags:
return []
mode = settings.routing.mode
+5
View File
@@ -43,6 +43,8 @@ class InboundSettings:
http_port: int = 20171
rule_socks_port: int = 0
rule_http_port: int = 20172
auth_user: str = ""
auth_password: str = ""
vmess_port: int = 0
inbound_sniffing: str = "http,tls,quic"
route_only: bool = False
@@ -83,6 +85,7 @@ class TransparentSettings:
docker_transparent: bool = True
docker_transparent_cidrs: str = "172.16.0.0/12"
tproxy_excluded_interfaces: str = "docker*,veth*,wg*,ppp*,br-*"
output_bypass_rules: str = ""
tproxy_white_country_codes: list[str] = field(default_factory=list)
tproxy_white_custom_ips: list[str] = field(default_factory=list)
tun_bypass_interfaces: str = ""
@@ -201,6 +204,8 @@ def validate_settings(settings: XrayConfigSettings) -> None:
if not 1 <= settings.core.mux_concurrency <= 1024:
raise ValueError("core.mux_concurrency must be between 1 and 1024")
_validate_choice(settings.inbounds.inbound_sniffing, {"disable", "http,tls", "http,tls,quic"}, "inbounds.inbound_sniffing")
if bool(settings.inbounds.auth_user) != bool(settings.inbounds.auth_password):
raise ValueError("inbounds.auth_user and inbounds.auth_password must be set together")
_validate_choice(settings.routing.mode, {"whitelist", "gfwlist", "custom", "routingA", "proxy", "direct", "block"}, "routing.mode")
_validate_choice(settings.routing.default_rule, {"direct", "proxy", "block"}, "routing.default_rule")
_validate_choice(settings.transparent.mode, {"close", "proxy", "whitelist", "gfwlist", "pac"}, "transparent.mode")
@@ -182,6 +182,7 @@ def _redirect_rules(settings: XrayConfigSettings, *, backend: str, ipv6: bool, n
f"iptables -w 2 -t nat -A TP_RULE -p tcp -j REDIRECT --to-ports {settings.transparent.port}",
"iptables -w 2 -t nat -I PREROUTING -p tcp -j TP_PRE",
"iptables -w 2 -t nat -I OUTPUT -p tcp -j TP_OUT",
*_iptables_output_bypass_rules(settings, table="nat", mode="redirect"),
*_redirect_prerouting_jumps(settings),
"iptables -w 2 -t nat -A TP_OUT -j TP_RULE",
]
@@ -237,6 +238,7 @@ def _tproxy_rules(
"iptables -w 2 -t mangle -I OUTPUT -j TP_OUT",
"iptables -w 2 -t mangle -I PREROUTING -j TP_PRE",
"iptables -w 2 -t mangle -A TP_OUT -m mark --mark 0x80/0x80 -j RETURN",
*_iptables_output_bypass_rules(settings, table="mangle", mode="tproxy"),
"iptables -w 2 -t mangle -A TP_OUT -p tcp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TP_RULE",
"iptables -w 2 -t mangle -A TP_OUT -p udp -m addrtype --src-type LOCAL ! --dst-type LOCAL -j TP_RULE",
"iptables -w 2 -t mangle -A TP_PRE -i lo -m mark ! --mark 0x40/0xc0 -j RETURN",
@@ -367,6 +369,7 @@ def _redirect_nft_table(settings: XrayConfigSettings, *, ipv6: bool) -> str:
chain tp_out {{
type nat hook output priority -105
{_nft_output_bypass_rules(settings, mode="redirect")}
{nfproto} meta l4proto tcp jump tp_rule
}}
}}"""
@@ -394,6 +397,7 @@ def _tproxy_nft_table(settings: XrayConfigSettings, *, ipv6: bool, tproxy_white_
chain tp_out {{
meta mark & 0x80 == 0x80 return
{_nft_output_bypass_rules(settings, mode="tproxy")}
meta l4proto {{ tcp, udp }} fib saddr type local fib daddr type != local jump tp_rule
}}
@@ -585,6 +589,65 @@ def _iptables_interface(value: str) -> str:
return value.replace("*", "+")
def _output_bypass_entries(settings: XrayConfigSettings) -> list[tuple[str, str, int | None]]:
entries: list[tuple[str, str, int | None]] = []
for raw in settings.transparent.output_bypass_rules.replace(",", "\n").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) != 2:
continue
protocol = parts[0].lower()
if protocol not in {"tcp", "udp", "all"}:
continue
target, port = _split_bypass_target(parts[1])
try:
ipaddress.ip_network(target, strict=False)
except ValueError:
continue
entries.append((protocol, target, port))
return entries
def _split_bypass_target(value: str) -> tuple[str, int | None]:
if ":" not in value:
return value, None
host, raw_port = value.rsplit(":", 1)
if not host or not raw_port.isdigit():
return value, None
port = int(raw_port)
if not 1 <= port <= 65535:
return value, None
return host, port
def _iptables_output_bypass_rules(settings: XrayConfigSettings, *, table: str, mode: str) -> list[str]:
lines: list[str] = []
allowed_protocols = {"tcp"} if mode == "redirect" else {"tcp", "udp"}
for protocol, target, port in _output_bypass_entries(settings):
protocols = sorted(allowed_protocols) if protocol == "all" else [protocol]
for item in protocols:
if item not in allowed_protocols:
continue
port_match = f" --dport {port}" if port is not None else ""
lines.append(f"iptables -w 2 -t {table} -A TP_OUT -p {item} -d {target}{port_match} -j RETURN")
return lines
def _nft_output_bypass_rules(settings: XrayConfigSettings, *, mode: str) -> str:
lines: list[str] = []
allowed_protocols = {"tcp"} if mode == "redirect" else {"tcp", "udp"}
for protocol, target, port in _output_bypass_entries(settings):
protocols = sorted(allowed_protocols) if protocol == "all" else [protocol]
for item in protocols:
if item not in allowed_protocols:
continue
port_match = f" th dport {port}" if port is not None else ""
lines.append(f" ip daddr {target} meta l4proto {item}{port_match} return")
return "\n".join(lines)
def _nft_set(name: str, kind: str, values: list[str]) -> str:
elements = ",\n ".join(values)
return f""" set {name} {{
+1 -1
View File
@@ -292,7 +292,7 @@ def _inbound_networks(inbound: dict) -> set[str]:
return {item.strip() for item in network.split(",") if item.strip() in {"tcp", "udp"}}
if protocol == "dokodemo-door":
return {"tcp", "udp"}
if protocol == "socks" and (inbound.get("settings") or {}).get("udp"):
if protocol in {"socks", "mixed"} and (inbound.get("settings") or {}).get("udp"):
return {"tcp", "udp"}
return {"tcp"}
+7 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from flask import Flask
from pyxray import __version__
from pyxray.web.dashboard import register_dashboard
from pyxray.web.jobs import init_job_store
from pyxray.web.nodes import register_nodes
@@ -46,7 +47,7 @@ def run_web(host: str, port: int, default_xray_dir: str | Path = "data/xray") ->
logging.getLogger("pyxray").setLevel(logging.INFO)
if os.environ.get("PYXRAY_ACCESS_LOG") not in {"1", "true", "yes", "on"}:
logging.getLogger("werkzeug").disabled = True
_print_listen_urls(host, port)
_print_startup_banner(host, port)
create_app(default_xray_dir).run(host=host, port=port)
@@ -67,6 +68,11 @@ def _print_listen_urls(host: str, port: int) -> None:
print(f" * Pyxray URL: http://{host}:{port}", flush=True)
def _print_startup_banner(host: str, port: int) -> None:
print(f" * Pyxray version: {__version__}", flush=True)
_print_listen_urls(host, port)
def _primary_lan_ip() -> str | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
+4
View File
@@ -19,4 +19,8 @@
</label>
<input name="core.tcp_fast_open" type="hidden" value="default" />
</div>
<label class="config-field mt-4">
<span>transparent</span>
<textarea name="transparent.output_bypass_rules" rows="3" placeholder="tcp 117.72.47.28:33010&#10;all 192.168.0.0/24">{{ settings.transparent.output_bypass_rules }}</textarea>
</label>
</section>
+19 -12
View File
@@ -1,22 +1,28 @@
<section class="config-card">
<h3 class="config-card-title">入站端口</h3>
<label class="config-field">
<span>监听地址</span>
<select name="inbounds.listen">
{% for value in ["127.0.0.1", "0.0.0.0"] %}
<option value="{{ value }}" {% if settings.inbounds.listen == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<div class="config-grid">
<label class="config-field">
<span>监听地址</span>
<select name="inbounds.listen">
{% for value in ["127.0.0.1", "0.0.0.0"] %}
<option value="{{ value }}" {% if settings.inbounds.listen == value %}selected{% endif %}>{{ value }}</option>
{% endfor %}
</select>
</label>
<label class="config-field">
<span>Mixed 端口</span>
<input name="inbounds.rule_http_port" type="number" min="0" max="65535" value="{{ settings.inbounds.rule_http_port }}" />
</label>
</div>
<div class="config-grid mt-4">
<label class="config-field">
<span>规则 SOCKS 端口</span>
<input name="inbounds.rule_socks_port" type="number" min="0" max="65535" value="{{ settings.inbounds.rule_socks_port or settings.inbounds.socks_port }}" />
<span>认证用户名</span>
<input name="inbounds.auth_user" type="text" autocomplete="username" value="{{ settings.inbounds.auth_user }}" placeholder="留空使用 noauth" />
</label>
<label class="config-field">
<span>规则 HTTP 端口</span>
<input name="inbounds.rule_http_port" type="number" min="0" max="65535" value="{{ settings.inbounds.rule_http_port }}" />
<span>认证密码</span>
<input name="inbounds.auth_password" type="password" autocomplete="current-password" value="{{ settings.inbounds.auth_password }}" placeholder="留空使用 noauth" />
</label>
</div>
@@ -40,6 +46,7 @@
<input name="inbounds.socks_port" type="hidden" value="0" />
<input name="inbounds.http_port" type="hidden" value="0" />
<input name="inbounds.rule_socks_port" type="hidden" value="0" />
<input name="inbounds.vmess_port" type="hidden" value="0" />
<input name="inbounds.api.port" type="hidden" value="0" />
<input name="inbounds.port_sharing" type="hidden" value="off" />
@@ -35,7 +35,7 @@
<label class="grid gap-2">
<span class="text-sm text-zinc-400">下载代理</span>
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="proxy_url" placeholder="空则使用系统代理;系统没有代理则直连" value="{{ form.proxy_url }}" />
<input class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 outline-none focus:border-zinc-400" name="proxy_url" placeholder="例如 http://user:pass@127.0.0.1:20172空则使用系统代理" value="{{ form.proxy_url }}" />
</label>
<label class="grid gap-2">
+6
View File
@@ -149,6 +149,8 @@ def _settings_from_request() -> XrayConfigSettings:
settings.inbounds.http_port = _int("inbounds.http_port", settings.inbounds.http_port)
settings.inbounds.rule_socks_port = _int("inbounds.rule_socks_port", settings.inbounds.rule_socks_port)
settings.inbounds.rule_http_port = _int("inbounds.rule_http_port", settings.inbounds.rule_http_port)
settings.inbounds.auth_user = form.get("inbounds.auth_user", settings.inbounds.auth_user).strip()
settings.inbounds.auth_password = form.get("inbounds.auth_password", settings.inbounds.auth_password).strip()
settings.inbounds.vmess_port = _int("inbounds.vmess_port", settings.inbounds.vmess_port)
settings.inbounds.inbound_sniffing = form.get("inbounds.inbound_sniffing", settings.inbounds.inbound_sniffing)
settings.inbounds.route_only = _enabled("inbounds.route_only")
@@ -181,6 +183,10 @@ def _settings_from_request() -> XrayConfigSettings:
"transparent.tproxy_excluded_interfaces",
settings.transparent.tproxy_excluded_interfaces,
)
settings.transparent.output_bypass_rules = form.get(
"transparent.output_bypass_rules",
settings.transparent.output_bypass_rules,
)
settings.transparent.tproxy_white_country_codes = _lines("transparent.tproxy_white_country_codes")
settings.transparent.tproxy_white_custom_ips = _lines("transparent.tproxy_white_custom_ips")
settings.transparent.tun_bypass_interfaces = form.get("transparent.tun_bypass_interfaces", settings.transparent.tun_bypass_interfaces)
+32
View File
@@ -179,6 +179,38 @@ def test_docker_transparent_limits_tproxy_prerouting_to_configured_source_cidrs(
assert "ip saddr 172.16.0.0/12 meta l4proto { tcp, udp }" in nft_rules.nftables
def test_redirect_output_bypass_rules_return_before_transparent_rule() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "proxy"
settings.transparent.type = "redirect"
settings.transparent.output_bypass_rules = "tcp 117.72.47.28:33010\nall 192.168.0.0/24\nudp 198.51.100.10:3478"
iptables_rules = generate_transparent_rules(settings, backend="iptables")
nft_rules = generate_transparent_rules(settings, backend="nft")
assert "iptables -w 2 -t nat -A TP_OUT -p tcp -d 117.72.47.28 --dport 33010 -j RETURN" in iptables_rules.setup
assert "iptables -w 2 -t nat -A TP_OUT -p tcp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup
assert "iptables -w 2 -t nat -A TP_OUT -p udp" not in iptables_rules.setup
assert iptables_rules.setup.index("-d 117.72.47.28 --dport 33010") < iptables_rules.setup.index("iptables -w 2 -t nat -A TP_OUT -j TP_RULE")
assert "ip daddr 117.72.47.28 meta l4proto tcp th dport 33010 return" in nft_rules.nftables
assert "meta l4proto udp" not in nft_rules.nftables
def test_tproxy_output_bypass_rules_support_udp_and_all() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "proxy"
settings.transparent.type = "tproxy"
settings.transparent.output_bypass_rules = "udp 198.51.100.10:3478\nall 192.168.0.0/24"
iptables_rules = generate_transparent_rules(settings, backend="iptables")
nft_rules = generate_transparent_rules(settings, backend="nft")
assert "iptables -w 2 -t mangle -A TP_OUT -p udp -d 198.51.100.10 --dport 3478 -j RETURN" in iptables_rules.setup
assert "iptables -w 2 -t mangle -A TP_OUT -p tcp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup
assert "iptables -w 2 -t mangle -A TP_OUT -p udp -d 192.168.0.0/24 -j RETURN" in iptables_rules.setup
assert "ip daddr 198.51.100.10 meta l4proto udp th dport 3478 return" in nft_rules.nftables
def test_close_mode_has_no_system_rules() -> None:
settings = XrayConfigSettings()
settings.transparent.mode = "close"
+43 -15
View File
@@ -80,11 +80,14 @@ def test_settings_defaults_match_v2raya_core_values() -> None:
assert settings.inbounds.http_port == 20171
assert settings.inbounds.rule_socks_port == 0
assert settings.inbounds.rule_http_port == 20172
assert settings.inbounds.auth_user == ""
assert settings.inbounds.auth_password == ""
assert settings.transparent.mode == "close"
assert settings.transparent.type == "redirect"
assert settings.transparent.port == 52345
assert settings.transparent.docker_transparent is True
assert settings.transparent.docker_transparent_cidrs == "172.16.0.0/12"
assert settings.transparent.output_bypass_rules == ""
assert settings.dns.query_strategy == "UseIPv4"
assert settings.dns.rules == [
DnsRuleSettings(server="localhost", domains="geosite:private", outbound="direct"),
@@ -102,7 +105,10 @@ def test_generate_default_config_matches_v2raya_template_shape() -> None:
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 _inbound(config, "rule-mixed")["port"] == 20172
assert _inbound(config, "rule-mixed")["protocol"] == "mixed"
assert _inbound(config, "rule-mixed")["settings"] == {"auth": "noauth", "udp": True, "allowTransparent": False}
assert "rule-http" not in {item["tag"] for item in config["inbounds"]}
assert "rule-socks" not in {item["tag"] for item in config["inbounds"]}
assert _outbound(config, "proxy")["protocol"] == "shadowsocks"
assert _outbound(config, "direct")["protocol"] == "freedom"
@@ -116,6 +122,22 @@ def test_generate_default_config_matches_v2raya_template_shape() -> None:
assert "dns-in" not in {item["tag"] for item in config["inbounds"]}
def test_generate_mixed_inbound_with_password_auth() -> None:
settings = XrayConfigSettings()
settings.inbounds.auth_user = "alice"
settings.inbounds.auth_password = "secret"
mixed = _inbound(generate_xray_config(parse_node_link(_ss_link()), settings), "rule-mixed")
assert mixed["protocol"] == "mixed"
assert mixed["settings"] == {
"auth": "password",
"udp": True,
"allowTransparent": False,
"accounts": [{"user": "alice", "pass": "secret"}],
}
def test_generate_none_log_level_disables_xray_logs() -> None:
settings = XrayConfigSettings()
settings.core.log_level = "none"
@@ -290,7 +312,6 @@ def test_generate_vless_ws_early_data_grpc_and_xhttp_settings() -> None:
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))
@@ -300,7 +321,8 @@ def test_generate_inbounds_custom_api_and_transparent_tproxy() -> None:
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, "rule-mixed")["listen"] == "0.0.0.0"
assert _inbound(config, "rule-mixed")["port"] == 20172
assert _inbound(config, "vmess")["protocol"] == "vmess"
assert _inbound(config, "extra-http")["protocol"] == "http"
assert _inbound(config, "transparent")["streamSettings"]["sockopt"]["tproxy"] == "tproxy"
@@ -311,15 +333,14 @@ def test_generate_inbounds_custom_api_and_transparent_tproxy() -> None:
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
assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"} in whitelist
assert {"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:google"], "outboundTag": "proxy"} in whitelist
settings.routing.mode = "gfwlist"
gfwlist = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
assert {"type": "field", "inboundTag": ["rule-http", "rule-socks"], "outboundTag": "direct"} in gfwlist
assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct"} in gfwlist
assert any("91.108.4.0/22" in rule.get("ip", []) for rule in gfwlist)
@@ -330,8 +351,8 @@ def test_generate_custom_routing_a_applies_before_proxy_mode_fallback() -> None:
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"}
google_rule = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "direct", "domain": ["keyword:google"]}
fallback = {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"}
assert google_rule in rules
assert fallback in rules
assert rules.index(google_rule) < rules.index(fallback)
@@ -339,7 +360,6 @@ def test_generate_custom_routing_a_applies_before_proxy_mode_fallback() -> None:
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"
@@ -347,20 +367,20 @@ def test_generate_custom_text_rules_before_route_mode_and_default_proxy() -> Non
rules = generate_xray_config(parse_node_link(_ss_link()), settings)["routing"]["rules"]
custom_domain = {
"type": "field",
"inboundTag": ["rule-http", "rule-socks"],
"inboundTag": ["rule-mixed"],
"domain": ["geosite:google", "domain:example.com"],
"outboundTag": "proxy",
}
custom_ip = {
"type": "field",
"inboundTag": ["rule-http", "rule-socks"],
"inboundTag": ["rule-mixed"],
"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
assert rules.index(custom_domain) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "domain": ["geosite:cn"], "outboundTag": "direct"})
assert rules.index(custom_ip) < rules.index({"type": "field", "inboundTag": ["rule-mixed"], "ip": ["geoip:private", "geoip:cn"], "outboundTag": "direct"})
assert {"type": "field", "inboundTag": ["rule-mixed"], "outboundTag": "proxy"} in rules
def test_generate_dns_rules_and_dns_routing() -> None:
@@ -391,6 +411,14 @@ def test_validate_settings_rejects_invalid_values() -> None:
validate_settings(settings)
def test_validate_settings_requires_complete_inbound_auth() -> None:
settings = XrayConfigSettings()
settings.inbounds.auth_user = "alice"
with pytest.raises(ValueError, match="auth_user"):
validate_settings(settings)
def _ss_link() -> str:
user = base64.urlsafe_b64encode(b"chacha20-ietf-poly1305:secret").decode().rstrip("=")
return f"ss://{user}@ss.example.net:8388#ss-node"
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from pyxray import __version__
from pyxray.web import server
def test_startup_banner_prints_version_first(capsys) -> None: # noqa: ANN001
server._print_startup_banner("127.0.0.1", 3309)
lines = capsys.readouterr().out.splitlines()
assert lines[0] == f" * Pyxray version: {__version__}"
assert lines[1] == " * Pyxray URL: http://127.0.0.1:3309"
+10 -1
View File
@@ -648,6 +648,8 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
"inbounds.http_port": "0",
"inbounds.rule_socks_port": "0",
"inbounds.rule_http_port": "20181",
"inbounds.auth_user": "alice",
"inbounds.auth_password": "secret",
"inbounds.vmess_port": "0",
"inbounds.inbound_sniffing": "http,tls",
"inbounds.route_only": "on",
@@ -661,6 +663,7 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
"transparent.socks_port": "52306",
"transparent.ipforward": "off",
"transparent.tun_auto_route": "on",
"transparent.output_bypass_rules": "tcp 117.72.47.28:33010",
"dns.query_strategy": "UseIPv4",
"dns.local_dns_listen": "on",
"dns.antipollution": "closed",
@@ -684,10 +687,16 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
assert "log_level = \"error\"" in saved.get_json()["settings_toml"]
assert "socks_port = 0" in saved.get_json()["settings_toml"]
assert "http_port = 0" in saved.get_json()["settings_toml"]
assert "auth_user = \"alice\"" in saved.get_json()["settings_toml"]
assert "auth_password = \"secret\"" in saved.get_json()["settings_toml"]
assert "route_only = true" in saved.get_json()["settings_toml"]
assert "output_bypass_rules = \"tcp 117.72.47.28:33010\"" in saved.get_json()["settings_toml"]
assert generated.status_code == 200
assert config["log"]["loglevel"] == "error"
assert config["inbounds"][0]["port"] == 20180
assert any(inbound["tag"] == "rule-mixed" and inbound["port"] == 20181 for inbound in config["inbounds"])
assert next(inbound for inbound in config["inbounds"] if inbound["tag"] == "rule-mixed")["settings"]["accounts"] == [
{"user": "alice", "pass": "secret"}
]
assert config["outbounds"][0]["mux"] == {"enabled": True, "concurrency": 16}
assert config["dns"]["queryStrategy"] == "UseIPv4"
Generated
+1 -1
View File
@@ -154,7 +154,7 @@ wheels = [
[[package]]
name = "pyxray"
version = "1.0.0"
version = "1.0.4"
source = { editable = "." }
dependencies = [
{ name = "flask" },