Compare commits
6 Commits
@@ -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 下只生成 TCP,tproxy 下生成 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 规则。 |
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pyxray"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
description = "A lightweight Linux xray control plane."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
@@ -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} {{
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -218,6 +219,17 @@ def read_log_since(path: str | Path, offset: int) -> tuple[str, int]:
|
||||
return _tail_lines(content, 1000), size
|
||||
|
||||
|
||||
def compact_xray_log(content: str) -> str:
|
||||
"""Convert verbose Xray routing logs into concise route decisions."""
|
||||
|
||||
entries: list[str] = []
|
||||
for line in content.splitlines():
|
||||
entry = _compact_log_line(line)
|
||||
if entry:
|
||||
entries.append(entry)
|
||||
return "\n".join(entries)
|
||||
|
||||
|
||||
def log_file_size(path: str | Path) -> int:
|
||||
resolved = Path(path)
|
||||
if not resolved.exists():
|
||||
@@ -230,6 +242,32 @@ def _tail_lines(content: str, line_count: int) -> str:
|
||||
return "\n".join(lines[-line_count:])
|
||||
|
||||
|
||||
def _compact_log_line(line: str) -> str | None:
|
||||
match = _DETOUR_RE.search(line)
|
||||
if match:
|
||||
target = _compact_target(match.group("target"))
|
||||
return f"{_log_time(line)} {target} -> {match.group('outbound')}".strip()
|
||||
if _IMPORTANT_LOG_RE.search(line):
|
||||
return line.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _compact_target(target: str) -> str:
|
||||
if target.startswith(("tcp:", "udp:")):
|
||||
return target.split(":", 1)[1]
|
||||
return target
|
||||
|
||||
|
||||
def _log_time(line: str) -> str:
|
||||
match = _LOG_TIME_RE.match(line)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
_LOG_TIME_RE = re.compile(r"^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})")
|
||||
_DETOUR_RE = re.compile(r"taking detour \[(?P<outbound>[^\]]+)\] for \[(?P<target>[^\]]+)\]")
|
||||
_IMPORTANT_LOG_RE = re.compile(r"\[(Warning|Error)\]|\b(failed|error|timeout|denied)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _inbound_port_errors(inbound: dict) -> list[str]:
|
||||
port = int(inbound.get("port") or 0)
|
||||
if port <= 0:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
logger = logging.getLogger("pyxray")
|
||||
|
||||
|
||||
def log_activity(app: Flask, message: str) -> None:
|
||||
"""Write an operator-facing activity message to stdout/stderr logging."""
|
||||
|
||||
logger.info(message)
|
||||
@@ -31,6 +31,7 @@ def index(): # noqa: ANN202
|
||||
manager = get_node_manager(current_app)
|
||||
nodes = [node.to_dict() for node in manager.list_nodes()]
|
||||
selected_id = manager.selected_id()
|
||||
selected_node = manager.get_selected_node()
|
||||
settings_store = get_settings_store(current_app)
|
||||
settings = settings_store.load()
|
||||
service_status = get_xray_service(current_app).status()
|
||||
@@ -44,6 +45,7 @@ def index(): # noqa: ANN202
|
||||
official_url=official_archive_url(form["version"] or DEFAULT_VERSION),
|
||||
nodes=nodes,
|
||||
selected_id=selected_id,
|
||||
selected_name=selected_node.name if selected_node is not None else "",
|
||||
settings=settings.to_dict(),
|
||||
settings_toml=dump_settings_toml(settings),
|
||||
config_path=current_app.config["XRAY_CONFIG_PATH"],
|
||||
|
||||
+13
-1
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from flask import Blueprint, Flask, current_app, jsonify, request
|
||||
|
||||
from pyxray.libs.nodes import NodeManager, NodeStore
|
||||
from pyxray.web.activity_log import log_activity
|
||||
|
||||
|
||||
blueprint = Blueprint("nodes", __name__, url_prefix="/api/nodes")
|
||||
@@ -42,6 +43,9 @@ def import_nodes_api(): # noqa: ANN202
|
||||
|
||||
text = request.form.get("links", "").strip()
|
||||
results = get_node_manager(current_app).import_links(text)
|
||||
imported = sum(1 for item in results if item.node is not None)
|
||||
failed = len(results) - imported
|
||||
log_activity(current_app, f"Nodes imported: imported={imported} failed={failed}")
|
||||
return jsonify(
|
||||
{
|
||||
"results": [
|
||||
@@ -66,7 +70,14 @@ def select_node_api(): # noqa: ANN202
|
||||
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()})
|
||||
log_activity(current_app, f"Node selected: {node.name or node.id}")
|
||||
try:
|
||||
from pyxray.web.xray_service import restart_xray_service_if_running
|
||||
|
||||
restart_status = restart_xray_service_if_running(current_app, reason="node selected")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify({"error": str(exc), "node": node.to_dict()}), 400
|
||||
return jsonify({"node": node.to_dict(), "service": restart_status})
|
||||
|
||||
|
||||
@blueprint.delete("/<node_id>")
|
||||
@@ -74,4 +85,5 @@ def delete_node_api(node_id: str): # noqa: ANN202
|
||||
"""删除节点。"""
|
||||
|
||||
removed = get_node_manager(current_app).remove_node(node_id)
|
||||
log_activity(current_app, f"Node deleted: {node_id} removed={removed}")
|
||||
return jsonify({"removed": removed})
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -39,6 +42,11 @@ def create_app(
|
||||
def run_web(host: str, port: int, default_xray_dir: str | Path = "data/xray") -> None:
|
||||
"""启动开发模式 Web 服务。"""
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s", force=True)
|
||||
logging.getLogger("pyxray").setLevel(logging.INFO)
|
||||
if os.environ.get("PYXRAY_ACCESS_LOG") not in {"1", "true", "yes", "on"}:
|
||||
logging.getLogger("werkzeug").disabled = True
|
||||
_print_listen_urls(host, port)
|
||||
create_app(default_xray_dir).run(host=host, port=port)
|
||||
|
||||
|
||||
@@ -49,6 +57,27 @@ def _default_data_dir(default_xray_dir: str | Path) -> Path:
|
||||
return path.parent if path.name == "xray" else path
|
||||
|
||||
|
||||
def _print_listen_urls(host: str, port: int) -> None:
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
print(f" * Pyxray URL: http://127.0.0.1:{port}", flush=True)
|
||||
lan_ip = _primary_lan_ip()
|
||||
if lan_ip:
|
||||
print(f" * Pyxray URL: http://{lan_ip}:{port}", flush=True)
|
||||
return
|
||||
print(f" * Pyxray URL: http://{host}:{port}", flush=True)
|
||||
|
||||
|
||||
def _primary_lan_ip() -> str | None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def _bind_xray_lifecycle(app: Flask) -> None:
|
||||
"""确保 pyxray 进程退出时关闭它启动的 Xray。"""
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ 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 logFormatSelect = document.querySelector("#log-format-select");
|
||||
const logContent = document.querySelector("#xray-log-content");
|
||||
const activeJobKey = "pyxray.activeAssetJobId";
|
||||
const activeFormKey = "pyxray.activeAssetForm";
|
||||
@@ -134,6 +134,9 @@ nodeList.addEventListener("click", async (event) => {
|
||||
}
|
||||
selectedNodeId = payload.node.id;
|
||||
renderNodes();
|
||||
if (payload.service) renderServiceStatus(payload.service);
|
||||
else refreshServiceStatus();
|
||||
await refreshLogs();
|
||||
showBox(nodeMessage, `已选择:${payload.node.name}`, "done");
|
||||
}
|
||||
|
||||
@@ -147,6 +150,7 @@ nodeList.addEventListener("click", async (event) => {
|
||||
if (selectedNodeId === nodeId) selectedNodeId = "";
|
||||
showBox(nodeMessage, payload.removed ? "节点已删除" : "节点不存在", payload.removed ? "done" : "warn");
|
||||
await refreshNodes();
|
||||
refreshServiceStatus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,6 +167,8 @@ settingsForm.addEventListener("submit", async (event) => {
|
||||
}
|
||||
settingsSnapshot = serializeForm(settingsForm);
|
||||
updateSettingsActions();
|
||||
if (payload.service) renderServiceStatus(payload.service);
|
||||
await refreshLogs();
|
||||
showBox(configMessage, "设置已保存", "done");
|
||||
});
|
||||
|
||||
@@ -241,8 +247,8 @@ function initServiceControls() {
|
||||
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";
|
||||
serviceToggle.textContent = truncateServiceLabel(error.message);
|
||||
serviceToggle.className = "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300";
|
||||
await refreshServiceStatus();
|
||||
await refreshLogs();
|
||||
if (logContent && !logContent.textContent.includes(error.message)) {
|
||||
@@ -260,6 +266,7 @@ function initLogControls() {
|
||||
if (!refreshLogsButton) return;
|
||||
refreshLogsButton.addEventListener("click", refreshLogs);
|
||||
clearLogsButton.addEventListener("click", clearLogs);
|
||||
logFormatSelect?.addEventListener("change", loadLogs);
|
||||
resetLogOffset();
|
||||
setInterval(refreshLogs, 2000);
|
||||
}
|
||||
@@ -272,17 +279,24 @@ async function refreshServiceStatus() {
|
||||
|
||||
function renderServiceStatus(status) {
|
||||
serviceToggle.dataset.running = status.running ? "true" : "false";
|
||||
serviceToggle.textContent = status.running ? "停止 Xray" : "启动 Xray";
|
||||
serviceToggle.textContent = `${status.running ? "关闭" : "开启"}: ${selectedNodeNameShort()}`;
|
||||
serviceToggle.className = status.running
|
||||
? "rounded-full border border-red-500/30 bg-red-500/10 px-5 py-2 text-sm font-medium text-red-300"
|
||||
: "rounded-full border border-emerald-500/30 bg-emerald-500/10 px-5 py-2 text-sm font-medium text-emerald-300";
|
||||
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";
|
||||
}
|
||||
|
||||
function selectedNodeNameShort() {
|
||||
const selected = nodes.find((node) => node.id === selectedNodeId);
|
||||
return truncateServiceLabel(selected?.name || "未选择");
|
||||
}
|
||||
|
||||
function truncateServiceLabel(value) {
|
||||
return value.length > 8 ? value.slice(0, 8) : value;
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
if (!logContent) return;
|
||||
const query = logOffset === null ? "?offset=end" : `?offset=${encodeURIComponent(logOffset)}`;
|
||||
const query = logQuery(logOffset === null ? "end" : logOffset);
|
||||
const response = await fetch(`/api/xray/service/logs${query}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
@@ -315,15 +329,46 @@ async function clearLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (!logContent) return;
|
||||
const response = await fetch(`/api/xray/service/logs${logFormatQuery()}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
logContent.textContent = payload.error || `HTTP ${response.status}`;
|
||||
return;
|
||||
}
|
||||
if (typeof payload.offset === "number") logOffset = payload.offset;
|
||||
logContent.textContent = payload.content || "暂无日志。";
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
async function resetLogOffset() {
|
||||
if (!logContent) return;
|
||||
const response = await fetch("/api/xray/service/logs?offset=end");
|
||||
const response = await fetch(`/api/xray/service/logs${logQuery("end")}`);
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
logOffset = payload.offset || 0;
|
||||
logContent.textContent = "暂无日志。";
|
||||
}
|
||||
|
||||
function logQuery(offset) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("offset", String(offset));
|
||||
if ((logFormatSelect?.value || "compact") === "compact") {
|
||||
params.set("format", "compact");
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
|
||||
function logFormatQuery() {
|
||||
const params = new URLSearchParams();
|
||||
if ((logFormatSelect?.value || "compact") === "compact") {
|
||||
params.set("format", "compact");
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
function updateSettingsActions() {
|
||||
configActions.classList.toggle("is-visible", serializeForm(settingsForm) !== settingsSnapshot);
|
||||
}
|
||||
|
||||
@@ -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 all 192.168.0.0/24">{{ settings.transparent.output_bypass_rules }}</textarea>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
@@ -21,14 +21,8 @@
|
||||
type="button"
|
||||
data-running="{{ 'true' if service_status.running else 'false' }}"
|
||||
>
|
||||
{{ "停止 Xray" if service_status.running else "启动 Xray" }}
|
||||
{{ "关闭" if service_status.running else "开启" }}: {{ selected_name[:8] if selected_name else "未选择" }}
|
||||
</button>
|
||||
<div id="xray-service-state" class="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400">
|
||||
{{ "pid: " ~ service_status.pid if service_status.running else "xray: stopped" }}
|
||||
</div>
|
||||
<div class="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 font-mono text-xs text-zinc-400">
|
||||
config: {{ config_path }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
<h2 class="text-xl font-medium">运行日志</h2>
|
||||
<p class="mt-1 text-sm text-zinc-500">Xray stdout / stderr,日志文件:{{ log_path }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<select id="log-format-select" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-4 py-3 text-sm font-medium text-zinc-100 outline-none focus:border-emerald-500">
|
||||
<option value="compact" selected>解析优化日志</option>
|
||||
<option value="raw">原始日志</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="refresh-logs-button" class="rounded-2xl border border-zinc-700 bg-zinc-950 px-5 py-3 text-sm font-medium text-zinc-100 hover:bg-zinc-800" type="button">刷新日志</button>
|
||||
<button id="clear-logs-button" class="rounded-2xl border border-red-500/30 bg-red-500/10 px-5 py-3 text-sm font-medium text-red-300 hover:bg-red-500/20" type="button">清除日志</button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ from pyxray.libs.xray_assets import (
|
||||
ensure_xray_assets,
|
||||
)
|
||||
from pyxray.libs.xray_asset_settings import XrayAssetSettings, XrayAssetSettingsStore
|
||||
from pyxray.web.activity_log import log_activity
|
||||
from pyxray.web.jobs import Job, get_job_store
|
||||
|
||||
|
||||
@@ -35,6 +36,7 @@ def get_asset_settings_store(app: Flask) -> XrayAssetSettingsStore:
|
||||
def ensure_api(): # noqa: ANN202
|
||||
form = _form_values()
|
||||
get_asset_settings_store(current_app).save(_settings_from_form(form))
|
||||
log_activity(current_app, f"Asset ensure started: target={form['target'] or 'all'} directory={form['directory']}")
|
||||
job = get_job_store(current_app).start(lambda item: _run_asset_job(item, form), payload=form)
|
||||
return jsonify({"job_id": job["id"]})
|
||||
|
||||
@@ -50,6 +52,7 @@ def save_asset_settings_api(): # noqa: ANN202
|
||||
form = _form_values()
|
||||
settings = _settings_from_form(form)
|
||||
get_asset_settings_store(current_app).save(settings)
|
||||
log_activity(current_app, f"Asset settings saved: directory={settings.directory} version={settings.version}")
|
||||
return jsonify(settings.to_dict())
|
||||
|
||||
|
||||
@@ -66,6 +69,7 @@ def cancel_job_api(job_id: str): # noqa: ANN202
|
||||
job = get_job_store(current_app).cancel(job_id)
|
||||
if job is None:
|
||||
return jsonify({"error": "job not found"}), 404
|
||||
log_activity(current_app, f"Asset job cancelled: {job_id}")
|
||||
return jsonify({"job_id": job_id, "cancel_requested": True})
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from pyxray.libs.xray_config.settings import (
|
||||
OutboundSetting,
|
||||
)
|
||||
from pyxray.libs.xray_config.store import dump_settings_toml
|
||||
from pyxray.web.activity_log import log_activity
|
||||
from pyxray.web.nodes import get_node_manager
|
||||
|
||||
|
||||
@@ -66,7 +67,14 @@ def save_settings_api(): # noqa: ANN202
|
||||
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)})
|
||||
log_activity(current_app, "Xray settings saved")
|
||||
try:
|
||||
from pyxray.web.xray_service import restart_xray_service_if_running
|
||||
|
||||
restart_status = restart_xray_service_if_running(current_app, reason="settings saved")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return jsonify({"error": str(exc), "settings_toml": dump_settings_toml(settings)}), 400
|
||||
return jsonify({"settings_toml": dump_settings_toml(settings), "service": restart_status})
|
||||
|
||||
|
||||
@blueprint.post("/generate")
|
||||
@@ -77,6 +85,7 @@ def generate_config_api(): # noqa: ANN202
|
||||
generated = generate_current_xray_config(current_app)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
log_activity(current_app, f"Xray config generated: {generated['config_path']}")
|
||||
return jsonify(generated)
|
||||
|
||||
|
||||
@@ -172,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)
|
||||
|
||||
@@ -6,8 +6,9 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, Flask, current_app, jsonify, request
|
||||
|
||||
from pyxray.libs.xray_runtime import XrayServiceManager, log_file_size, read_log_since, read_log_tail
|
||||
from pyxray.libs.xray_runtime import XrayServiceManager, compact_xray_log, log_file_size, read_log_since, read_log_tail
|
||||
from pyxray.libs.xray_transparent_runtime import TransparentRuntime
|
||||
from pyxray.web.activity_log import log_activity
|
||||
from pyxray.web.xray_assets import get_asset_settings_store
|
||||
from pyxray.web.xray_config import generate_current_xray_config, get_settings_store
|
||||
|
||||
@@ -51,17 +52,22 @@ def status_api(): # noqa: ANN202
|
||||
@blueprint.post("/start")
|
||||
def start_api(): # noqa: ANN202
|
||||
try:
|
||||
log_activity(current_app, "Xray start requested")
|
||||
status = start_xray_service(current_app)
|
||||
save_service_state(current_app, desired_running=True)
|
||||
log_activity(current_app, f"Xray started: pid={status['pid']}")
|
||||
return jsonify(status)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_activity(current_app, f"Xray start failed: {exc}")
|
||||
return jsonify({"error": str(exc), "status": get_xray_service(current_app).status()}), 400
|
||||
|
||||
|
||||
@blueprint.post("/stop")
|
||||
def stop_api(): # noqa: ANN202
|
||||
log_activity(current_app, "Xray stop requested")
|
||||
status = get_xray_service(current_app).stop()
|
||||
save_service_state(current_app, desired_running=False)
|
||||
log_activity(current_app, "Xray stopped")
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
@@ -69,20 +75,27 @@ def stop_api(): # noqa: ANN202
|
||||
def logs_api(): # noqa: ANN202
|
||||
path = current_app.config["XRAY_LOG_PATH"]
|
||||
offset = request.args.get("offset")
|
||||
compact = request.args.get("format") == "compact"
|
||||
if offset == "end":
|
||||
size = log_file_size(path)
|
||||
return jsonify({"path": path, "content": "", "offset": size})
|
||||
if offset is not None:
|
||||
content, size = read_log_since(path, int(offset or 0))
|
||||
if compact:
|
||||
content = compact_xray_log(content)
|
||||
return jsonify({"path": path, "content": content, "offset": size})
|
||||
return jsonify({"path": path, "content": read_log_tail(path), "offset": log_file_size(path)})
|
||||
content = read_log_tail(path)
|
||||
if compact:
|
||||
content = compact_xray_log(content)
|
||||
return jsonify({"path": path, "content": content, "offset": log_file_size(path)})
|
||||
|
||||
|
||||
@blueprint.delete("/logs")
|
||||
def clear_logs_api(): # noqa: ANN202
|
||||
path = Path(current_app.config["XRAY_LOG_PATH"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return jsonify({"path": str(path), "content": "", "offset": log_file_size(path)})
|
||||
path.write_text("", encoding="utf-8")
|
||||
return jsonify({"path": str(path), "content": "", "offset": 0})
|
||||
|
||||
|
||||
def start_xray_service(app: Flask) -> dict[str, Any]:
|
||||
@@ -102,16 +115,37 @@ def start_xray_service(app: Flask) -> dict[str, Any]:
|
||||
return status
|
||||
|
||||
|
||||
def restart_xray_service_if_running(app: Flask, *, reason: str) -> dict[str, Any] | None:
|
||||
"""Restart Xray after a config-affecting change when it is currently running."""
|
||||
|
||||
service = get_xray_service(app)
|
||||
if not service.status()["running"]:
|
||||
return None
|
||||
log_activity(app, f"Xray restart requested: {reason}")
|
||||
service.stop()
|
||||
try:
|
||||
status = start_xray_service(app)
|
||||
except Exception as exc:
|
||||
log_activity(app, f"Xray restart failed: {reason}: {exc}")
|
||||
raise
|
||||
save_service_state(app, desired_running=True)
|
||||
log_activity(app, f"Xray restarted: pid={status['pid']}")
|
||||
return status
|
||||
|
||||
|
||||
def restore_xray_service(app: Flask) -> None:
|
||||
"""应用启动时按上次用户期望恢复 Xray 运行状态。"""
|
||||
|
||||
if not load_service_state(app).get("desired_running", False):
|
||||
return
|
||||
try:
|
||||
log_activity(app, "Xray restore requested")
|
||||
start_xray_service(app)
|
||||
_append_service_message(app, "restored desired running state")
|
||||
log_activity(app, "Xray restored")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_append_service_message(app, f"failed to restore desired running state: {exc}")
|
||||
log_activity(app, f"Xray restore failed: {exc}")
|
||||
|
||||
|
||||
def load_service_state(app: Flask) -> dict[str, Any]:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -426,10 +426,10 @@ def test_xray_service_api_clears_logs(tmp_path: Path) -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["content"] == ""
|
||||
assert response.get_json()["offset"] == len("old log")
|
||||
assert logs.get_json()["content"] == "old log"
|
||||
assert response.get_json()["offset"] == 0
|
||||
assert logs.get_json()["content"] == ""
|
||||
assert client.get(f"/api/xray/service/logs?offset={response.get_json()['offset']}").get_json()["content"] == ""
|
||||
assert (tmp_path / "xray.log").read_text(encoding="utf-8") == "old log"
|
||||
assert (tmp_path / "xray.log").read_text(encoding="utf-8") == ""
|
||||
|
||||
|
||||
def test_xray_service_logs_api_reads_from_offset(tmp_path: Path) -> None:
|
||||
@@ -447,6 +447,29 @@ def test_xray_service_logs_api_reads_from_offset(tmp_path: Path) -> None:
|
||||
assert payload["offset"] == log.stat().st_size
|
||||
|
||||
|
||||
def test_xray_service_logs_api_returns_compact_route_lines(tmp_path: Path) -> None:
|
||||
log = tmp_path / "xray.log"
|
||||
log.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"2026/05/27 04:17:06.829641 [Info] [3636958196] app/dispatcher: sniffed domain: git.pchuan.top",
|
||||
"2026/05/27 04:17:06.829662 [Info] [3636958196] app/dispatcher: taking detour [direct] for [tcp:git.pchuan.top:80]",
|
||||
"2026/05/27 04:17:06.829733 from 192.168.0.76:53842 accepted tcp:117.72.47.28:80 [transparent -> direct]",
|
||||
"2026/05/27 04:17:18.057882 [Info] app/proxyman/outbound: failed to process outbound traffic",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
|
||||
payload = client.get("/api/xray/service/logs?format=compact").get_json()
|
||||
|
||||
assert "2026/05/27 04:17:06 git.pchuan.top:80 -> direct" in payload["content"]
|
||||
assert "accepted tcp" not in payload["content"]
|
||||
assert "failed to process outbound traffic" in payload["content"]
|
||||
|
||||
|
||||
def test_xray_service_logs_api_returns_latest_1000_lines(tmp_path: Path) -> None:
|
||||
(tmp_path / "xray.log").write_text("\n".join(f"line-{index}" for index in range(1205)), encoding="utf-8")
|
||||
app = create_app(tmp_path)
|
||||
@@ -530,6 +553,31 @@ def test_nodes_api_imports_lists_selects_and_deletes_node(tmp_path: Path) -> Non
|
||||
assert client.get("/api/nodes").get_json()["nodes"] == []
|
||||
|
||||
|
||||
def test_selecting_node_restarts_running_xray(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho started-$@\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
client.post("/api/nodes/import", data={"links": "\n".join([_ss_link("one", "one"), _ss_link("two", "two")])})
|
||||
nodes = client.get("/api/nodes").get_json()["nodes"]
|
||||
client.post("/api/nodes/select", data={"node_id": nodes[0]["id"]})
|
||||
client.post(
|
||||
"/api/xray/config/settings",
|
||||
data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
|
||||
)
|
||||
first = client.post("/api/xray/service/start").get_json()
|
||||
|
||||
selected = client.post("/api/nodes/select", data={"node_id": nodes[1]["id"]})
|
||||
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
client.post("/api/xray/service/stop")
|
||||
|
||||
assert selected.status_code == 200
|
||||
assert selected.get_json()["service"]["running"] is True
|
||||
assert selected.get_json()["service"]["pid"] != first["pid"]
|
||||
assert config["outbounds"][0]["settings"]["servers"][0]["password"] == "two"
|
||||
|
||||
|
||||
def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) -> None:
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
@@ -554,6 +602,34 @@ def test_xray_config_api_saves_settings_and_generates_config(tmp_path: Path) ->
|
||||
assert (tmp_path / "transparent" / "transparent-iptables-setup.sh").exists()
|
||||
|
||||
|
||||
def test_saving_settings_restarts_running_xray(tmp_path: Path) -> None:
|
||||
xray = tmp_path / "xray"
|
||||
xray.write_text("#!/bin/sh\necho settings-started\nsleep 30\n", encoding="utf-8")
|
||||
os.chmod(xray, 0o755)
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
client.post("/api/nodes/import", data={"links": _ss_link("secret", "ss-node")})
|
||||
node = client.get("/api/nodes").get_json()["nodes"][0]
|
||||
client.post("/api/nodes/select", data={"node_id": node["id"]})
|
||||
client.post(
|
||||
"/api/xray/config/settings",
|
||||
data={"settings_toml": '[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
|
||||
)
|
||||
first = client.post("/api/xray/service/start").get_json()
|
||||
|
||||
saved = client.post(
|
||||
"/api/xray/config/settings",
|
||||
data={"settings_toml": '[core]\nlog_level = "debug"\n[inbounds]\nsocks_port = 0\nhttp_port = 0\nrule_http_port = 0\n'},
|
||||
)
|
||||
config = json.loads((tmp_path / "config.json").read_text(encoding="utf-8"))
|
||||
client.post("/api/xray/service/stop")
|
||||
|
||||
assert saved.status_code == 200
|
||||
assert saved.get_json()["service"]["running"] is True
|
||||
assert saved.get_json()["service"]["pid"] != first["pid"]
|
||||
assert config["log"]["loglevel"] == "debug"
|
||||
|
||||
|
||||
def test_xray_config_api_saves_settings_from_form_controls(tmp_path: Path) -> None:
|
||||
app = create_app(tmp_path)
|
||||
client = app.test_client()
|
||||
@@ -585,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",
|
||||
@@ -609,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
|
||||
|
||||
Reference in New Issue
Block a user