6 Commits

21 changed files with 509 additions and 26 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.0"
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} {{
+38
View File
@@ -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:
+14
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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})
+29
View File
@@ -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。"""
+53 -8
View File
@@ -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);
}
+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>
+1 -7
View File
@@ -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>
+7 -1
View File
@@ -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>
+4
View File
@@ -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})
+14 -1
View File
@@ -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)
+37 -3
View File
@@ -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]:
+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"),
+81 -3
View File
@@ -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
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" },