3 Commits

11 changed files with 222 additions and 2 deletions
+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 规则。 |
+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` 行为。
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pyxray"
version = "1.0.2"
version = "1.0.3"
description = "A lightweight Linux xray control plane."
readme = "README.md"
requires-python = ">=3.14"
+1
View File
@@ -83,6 +83,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 = ""
@@ -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} {{
+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>
+4
View File
@@ -181,6 +181,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"
+1
View File
@@ -85,6 +85,7 @@ def test_settings_defaults_match_v2raya_core_values() -> None:
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"),
+2
View File
@@ -661,6 +661,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",
@@ -685,6 +686,7 @@ def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> No
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 "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
Generated
+1 -1
View File
@@ -154,7 +154,7 @@ wheels = [
[[package]]
name = "pyxray"
version = "1.0.0"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "flask" },